重构 AI

This commit is contained in:
2025-10-07 00:26:22 +08:00
parent 0c8e32aad4
commit c1d5119a0a
16 changed files with 966 additions and 322 deletions

View File

@@ -1,5 +1,19 @@
# API接口对比分析
## 更新日志
### 最近更新2025-10
-**AI分析功能增强**完善了AI智能分析模块的文档说明
- 详细说明了4个AI相关接口的功能和参数
- 新增等级系统说明S/A/B/C包含特殊规则
- 补充了时间范围选择功能
- 说明了流式响应的实现方式
- 前端组件从 `WeeklyChart.vue` 升级为 `DurationChart.vue`(混合图表)
- 🔧 **数据缓存优化**后端AI接口增加了缓存机制提升性能
- 🐛 **修正等级系统说明**更正了等级阈值A级前35%B级前75%),并补充了小规模参与惩罚规则
---
## 一、前端已使用的API接口
### 1. 用户认证相关shared/api.ts
@@ -65,10 +79,23 @@
- `GET /api/tutorials` - 获取教程列表
#### 2.11 AI分析相关
- `GET /api/ai/detail` - 获取AI详细数据
- `GET /api/ai/weekly` - 获取AI周数据
- `GET /api/ai/heatmap` - 获取AI热力图数据
- `POST /api/ai/analysis` - AI分析生成使用fetch直接调用流式响应
- `GET /api/ai/detail` - 获取用户详细数据
- **参数**: start, end时间范围
- **返回**: 用户等级(S/A/B/C)、已解决题目列表、标签统计、难度统计、参赛次数等
- **特点**: 包含班级排名对比,计算每道题的解题排名和等级
- `GET /api/ai/duration` - 获取时段数据
- **参数**: end结束时间, duration时间单位如 "months:6", "weeks:1"
- **返回**: 每周/每月的综合情况(题目数、提交数、等级)
- **用途**: 用于绘制时间趋势图,展示学习进度变化
- `GET /api/ai/heatmap` - 获取热力图数据
- **返回**: 用户的提交热力图数据(按日期统计提交数)
- **用途**: 可视化用户活跃度分布
- `POST /api/ai/analysis` - AI智能分析生成
- **请求体**: details详细数据, duration时段数据
- **响应方式**: 流式响应Server-Sent Events
- **AI提供商**: DeepSeek
- **功能**: 根据用户学习数据生成个性化学习建议和鼓励
- **实现**: 使用fetch直接调用`oj/store/ai.ts` 中处理流式输出
### 3. 管理员APIadmin/api.ts
#### 3.1 仪表板
@@ -278,8 +305,40 @@
### 特殊说明
#### 1. 流式接口
`POST /api/ai/analysis` 接口使用了**流式响应Server-Sent Events**,因此没有在 `oj/api.ts` 中定义封装函数,而是在 `oj/store/ai.ts` 中直接使用 `fetch` API 调用用于实时流式输出AI生成的分析内容。
#### 1. AI智能分析功能 ✨
**功能概述**: 基于用户的学习数据使用DeepSeek AI生成个性化的学习分析报告和建议。
**涉及接口**:
- `GET /api/ai/detail` - 获取详细学习数据
- `GET /api/ai/duration` - 获取时段趋势数据
- `GET /api/ai/heatmap` - 获取活跃度热力图
- `POST /api/ai/analysis` - 生成AI分析流式响应
**前端实现**:
- **页面**: `src/oj/ai/analysis.vue`
- **Store**: `src/oj/store/ai.ts`
- **组件**:
- `DurationChart.vue` - 混合图表(柱状图+折线图),展示题目数、提交数、等级变化
- `Heatmap.vue` - 提交热力图
- `Details.vue` - 详细数据展示
- `AI.vue` - AI分析结果展示Markdown格式
**时间范围选择**:
支持多种时间范围:一节课(1小时)、两节课(2小时)、一天、一周、一个月、两个月、半年、一年
**等级系统**:
- **S级**: 排名前10%卓越水平约10%的人)
- **A级**: 排名前35%优秀水平约25%的人)
- **B级**: 排名前75%良好水平约40%的人)
- **C级**: 75%之后及格水平约25%的人)
- **特殊规则**: 参与人数少于10人时S级降为A级A级降为B级避免因人少而评级虚高
**流式接口实现**:
`POST /api/ai/analysis` 使用了**流式响应Server-Sent Events**,在 `oj/store/ai.ts` 中直接使用 `fetch` API调用配合 `consumeJSONEventStream` 工具函数处理流式数据实现AI内容的实时流式输出。
**数据缓存**:
为提升性能,后端对 `ai/detail``ai/duration` 接口的返回数据进行了缓存,相同参数的请求会直接返回缓存结果。
#### 2. ACM 比赛辅助检查功能 ✨

View File

@@ -1,5 +1,6 @@
<template>
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20">
<n-spin :show="aiStore.loading.fetching">
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="2">
<n-flex vertical size="large">
<n-flex align="center" justify="space-between">
@@ -10,30 +11,50 @@
v-model:value="aiStore.duration"
/>
</n-flex>
<Details :start="start" :end="end" />
<Overview />
<n-grid :cols="2" :x-gap="20">
<n-gi :span="1">
<TagsChart />
</n-gi>
<n-gi :span="1">
<n-flex vertical :size="20">
<DifficultyChart />
<GradeChart />
</n-flex>
</n-gi>
</n-grid>
<SolvedTable />
</n-flex>
</n-gi>
<n-gi :span="3">
<n-flex vertical size="large">
<Heatmap />
<WeeklyChart :end="end" />
<AI v-if="aiStore.detailsData.solved.length" />
<ProgressChart />
<DurationChart />
<AI v-if="aiStore.detailsData.solved.length >= 10" />
</n-flex>
</n-gi>
<n-gi :span="5">
<AI v-if="aiStore.detailsData.solved.length > 0 && aiStore.detailsData.solved.length < 10" />
</n-gi>
</n-grid>
</n-spin>
</template>
<script setup lang="ts">
import { isDesktop } from "shared/composables/breakpoints"
import { formatISO, sub, type Duration } from "date-fns"
import WeeklyChart from "./components/WeeklyChart.vue"
import Details from "./components/Details.vue"
import TagsChart from "./components/TagsChart.vue"
import DifficultyChart from "./components/DifficultyChart.vue"
import GradeChart from "./components/GradeChart.vue"
import Overview from "./components/Overview.vue"
import Heatmap from "./components/Heatmap.vue"
import ProgressChart from "./components/ProgressChart.vue"
import DurationChart from "./components/DurationChart.vue"
import AI from "./components/AI.vue"
import SolvedTable from "./components/SolvedTable.vue"
import { useAIStore } from "../store/ai"
const aiStore = useAIStore()
const start = ref("")
const end = ref("")
const aiStore = useAIStore()
const options: SelectOption[] = [
{ label: "一节课内", value: "hours:1" },
@@ -54,11 +75,25 @@ const subOptions = computed<Duration>(() => {
return { [unit]: parseInt(n) } as Duration
})
function updateRange() {
const start = computed(() => {
const current = new Date()
end.value = formatISO(current)
start.value = formatISO(sub(current, subOptions.value))
}
return formatISO(sub(current, subOptions.value))
})
watch(() => aiStore.duration, updateRange, { immediate: true })
const end = computed(() => {
return formatISO(new Date())
})
// 获取热力图数据(仅一次)
onMounted(() => {
aiStore.fetchHeatmapData()
})
watch(
() => aiStore.duration,
() => {
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
},
{ immediate: true },
)
</script>

View File

@@ -1,9 +1,11 @@
<template>
<n-card title="AI 智能分析" size="small">
<n-spin :show="aiStore.loading.ai">
<div class="container">
<MdPreview :model-value="aiStore.mdContent" />
</div>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
@@ -12,9 +14,9 @@ import "md-editor-v3/lib/preview.css"
const aiStore = useAIStore()
watch(
() => [aiStore.loading.details, aiStore.loading.weekly],
(newVal) => {
if (newVal.every((val) => val === false)) {
() => aiStore.loading.fetching,
(isLoading) => {
if (!isLoading) {
aiStore.fetchAIAnalysis()
}
},

View File

@@ -1,145 +0,0 @@
<template>
<n-spin :show="aiStore.loading.details">
<n-flex vertical size="large">
<n-alert
:show-icon="false"
type="success"
v-if="aiStore.detailsData.solved.length"
>
<span>{{ durationLabel }}</span>
<span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
<span> 道题</span>
<span v-if="aiStore.detailsData.contest_count > 0">
并且参加
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
次比赛
</span>
<span>综合评价给到</span>
<Grade :grade="aiStore.detailsData.grade" />
<span>{{ greeting }}</span>
</n-alert>
<n-flex vertical size="large" v-else>
<n-alert type="error" title="你还没有完成任何题目"></n-alert>
<AI />
</n-flex>
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="10" :y-gap="10">
<n-gi>
<TagsChart :tags="aiStore.detailsData.tags" />
</n-gi>
<n-gi>
<DifficultyChart :difficulty="aiStore.detailsData.difficulty" />
</n-gi>
</n-grid>
<n-data-table
v-if="aiStore.detailsData.solved.length"
striped
:data="aiStore.detailsData.solved"
:columns="columns"
/>
</n-flex>
</n-spin>
</template>
<script lang="ts" setup>
import { NButton } from "naive-ui"
import Grade from "./Grade.vue"
import TagsChart from "./TagsChart.vue"
import DifficultyChart from "./DifficultyChart.vue"
import TagTitle from "./TagTitle.vue"
import AI from "./AI.vue"
import { parseTime } from "utils/functions"
import { SolvedProblem } from "utils/types"
import { useAIStore } from "oj/store/ai"
import { isDesktop } from "shared/composables/breakpoints"
const props = defineProps<{
start: string
end: string
}>()
const router = useRouter()
const aiStore = useAIStore()
const columns: DataTableColumn<SolvedProblem>[] = [
{
title: "完成的题目",
key: "problem.title",
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
if (row.problem.contest_id) {
router.push(
"/contest/" +
row.problem.contest_id +
"/problem/" +
row.problem.display_id,
)
} else {
router.push("/problem/" + row.problem.display_id)
}
},
},
() => {
if (row.problem.contest_id) {
return h(TagTitle, { problem: row.problem })
} else {
return row.problem.display_id + " " + row.problem.title
}
},
),
},
{
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
key: "rank",
width: 100,
align: "center",
render: (row) => row.rank + " / " + row.ac_count,
},
{
title: "等级",
key: "grade",
width: 100,
align: "center",
},
]
const durationLabel = computed(() => {
if (aiStore.duration.includes("hours")) {
return `${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
} else if (aiStore.duration.includes("days")) {
return `${parseTime(aiStore.detailsData.end, "MM月DD日")}`
} else if (
aiStore.duration.includes("weeks") ||
aiStore.duration.includes("months")
) {
return `${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
} else {
return `${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
}
})
const greeting = computed(() => {
return {
S: "要不试试高难度题目?",
A: "你很棒,继续保持!",
B: "请再接再厉!",
C: "你还需要努力!",
}[aiStore.detailsData.grade]
})
watch(
() => aiStore.duration,
() => {
aiStore.fetchDetailsData(props.start, props.end)
},
{ immediate: true },
)
</script>
<style scoped>
.charming {
font-size: 1.2rem;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="chart" v-if="show">
<n-card title="难度统计" size="small" v-if="show">
<Bar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
@@ -15,6 +15,7 @@ import {
Legend,
Colors,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
// 仅注册柱状图所需的 Chart.js 组件
ChartJS.register(
@@ -27,20 +28,18 @@ ChartJS.register(
Colors,
)
const props = defineProps<{
difficulty: { [key: string]: number }
}>()
const aiStore = useAIStore()
const show = computed(() => {
return Object.values(props.difficulty).reduce((a, b) => a + b, 0) > 0
return Object.values(aiStore.detailsData.difficulty).reduce((a, b) => a + b, 0) > 0
})
const data = computed(() => {
return {
labels: Object.keys(props.difficulty),
labels: Object.keys(aiStore.detailsData.difficulty),
datasets: [
{
data: Object.values(props.difficulty),
data: Object.values(aiStore.detailsData.difficulty),
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
},
],
@@ -52,28 +51,20 @@ const options = {
intersect: false,
},
maintainAspectRatio: false,
scales: {
y: {
ticks: {
stepSize: 1,
},
},
},
plugins: {
legend: {
display: false,
},
title: {
text: "题目的难度统计",
display: true,
font: {
size: 20,
},
display: false,
},
},
}
</script>
<style scoped>
.chart {
height: 100%;
width: 100%;
}
</style>

View File

@@ -1,9 +1,9 @@
<template>
<n-spin :show="aiStore.loading.weekly">
<n-card :title="title" size="small">
<div class="chart">
<Chart type="bar" :data="data" :options="options" />
</div>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
@@ -38,56 +38,52 @@ ChartJS.register(
LineController,
)
const props = defineProps<{
end: string
}>()
const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的每周综合情况一览图"
return "过去两个月的每周综合情况"
} else if (aiStore.duration === "months:6") {
return "过去半年的每月综合情况一览图"
return "过去半年的每月综合情况"
} else if (aiStore.duration === "years:1") {
return "过去一年的每月综合情况一览图"
return "过去一年的每月综合情况"
} else {
return "过去四周的综合情况一览图"
return "过去四周的综合情况"
}
})
const data = computed<ChartData<"bar" | "line">>(() => {
return {
labels: aiStore.weeklyData.map((weekly) => {
labels: aiStore.durationData.map((duration) => {
let prefix = "周"
if (weekly.unit === "months") {
if (duration.unit === "months") {
prefix = "月"
}
return [
parseTime(weekly.start, "M月D日"),
parseTime(weekly.end, "M月D日"),
parseTime(duration.start, "M月D日"),
parseTime(duration.end, "M月D日"),
].join("")
}),
datasets: [
{
type: "bar",
label: "完成题目数量",
data: aiStore.weeklyData.map((weekly) => weekly.problem_count),
data: aiStore.durationData.map((duration) => duration.problem_count),
yAxisID: "y",
},
{
type: "bar",
label: "总提交次数",
data: aiStore.weeklyData.map((weekly) => weekly.submission_count),
data: aiStore.durationData.map((duration) => duration.submission_count),
yAxisID: "y",
},
{
type: "line",
label: "等级",
data: aiStore.weeklyData.map((weekly) =>
gradeOrder.indexOf(weekly.grade || "C"),
data: aiStore.durationData.map((duration) =>
gradeOrder.indexOf(duration.grade || "C"),
),
tension: 0.4,
yAxisID: "y1",
@@ -125,11 +121,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
},
plugins: {
title: {
text: title.value,
display: true,
font: {
size: 20,
},
display: false,
},
tooltip: {
callbacks: {
@@ -147,13 +139,6 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
}
})
watch(
() => aiStore.duration,
() => {
aiStore.fetchWeeklyData(props.end, aiStore.duration)
},
{ immediate: true },
)
</script>
<style scoped>
.chart {

View File

@@ -0,0 +1,93 @@
<template>
<n-card title="等级统计" size="small" v-if="show">
<Bar :data="data" :options="options" />
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
Colors,
} from "chart.js"
import { Grade } from "utils/types"
import { useAIStore } from "oj/store/ai"
// 仅注册柱状图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
Colors,
)
const aiStore = useAIStore()
const gradeOrder = ["S", "A", "B", "C"]
const grades = computed(() =>
aiStore.detailsData.solved.map((item) => item.grade),
)
// 统计每个等级的题目数量
const gradeCount = computed(() => {
const count: { [key: string]: number } = {
C: 0,
B: 0,
A: 0,
S: 0,
}
grades.value.forEach((grade) => {
if (grade && grade in count) {
count[grade]++
}
})
return count
})
const show = computed(() => {
return grades.value.length > 0
})
const data = computed(() => {
return {
labels: gradeOrder,
datasets: [
{
data: gradeOrder.map((grade) => gradeCount.value[grade]),
backgroundColor: ["#FF6384", "#FFCE56", "#36A2EB", "#95F204"],
},
],
}
})
const options = {
interaction: {
intersect: false,
},
maintainAspectRatio: false,
scales: {
y: {
ticks: {
stepSize: 1,
},
},
},
plugins: {
legend: {
display: false,
},
title: {
display: false,
},
},
}
</script>

View File

@@ -1,43 +1,270 @@
<template>
<div class="chart">
<n-h1 class="title">过去一年的提交次数热力图</n-h1>
<n-heatmap
:loading="!data.length"
:color-theme="getRandomColorTheme()"
size="large"
:data="data"
:tooltip="{ placement: 'top' }"
<n-card title="过去一年的提交热力图" size="small">
<n-spin :show="aiStore.loading.heatmap">
<div class="heatmap-container" ref="containerRef">
<svg
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
preserveAspectRatio="xMinYMin meet"
class="heatmap-svg"
>
<template #tooltip="{ timestamp, value }">
<div>{{ new Date(timestamp).toLocaleDateString() }}</div>
<div>提交次数: {{ value }}</div>
</template>
</n-heatmap>
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
<text :x="label.x" :y="10" class="label" font-size="10">
{{ label.text }}
</text>
</g>
<g v-for="(day, i) in WEEK_DAYS" :key="i">
<text :x="0" :y="MONTH_HEIGHT + i * CELL_TOTAL + 8" class="label" font-size="9">
{{ day }}
</text>
</g>
<g :transform="`translate(${DAY_WIDTH}, ${MONTH_HEIGHT})`">
<rect
v-for="(cell, i) in cells"
:key="i"
:x="cell.x"
:y="cell.y"
:width="CELL_SIZE"
:height="CELL_SIZE"
:fill="cell.color"
class="cell"
rx="2"
@mouseenter="(e) => showTooltip(e, cell)"
@mouseleave="hideTooltip"
/>
</g>
</svg>
<div class="legend">
<span></span>
<div class="legend-colors">
<div v-for="(color, i) in COLORS" :key="i" :style="{ backgroundColor: color }" />
</div>
<span></span>
</div>
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
<div class="tooltip-date">{{ tooltip.date }}</div>
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
{{ tooltip.text }}
</div>
</div>
</div>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { type HeatmapData } from "naive-ui"
import { getAIHeatmapData } from "oj/api"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
const data = ref<HeatmapData>([])
const aiStore = useAIStore()
const containerRef = ref<HTMLElement>()
function getRandomColorTheme() {
const themes = ["green", "blue", "orange", "purple", "red"] as const
return themes[Math.floor(Math.random() * themes.length)]
}
const CELL_SIZE = 12
const CELL_GAP = 3
const CELL_TOTAL = CELL_SIZE + CELL_GAP
const DAY_WIDTH = 20
const MONTH_HEIGHT = 20
const RIGHT_PADDING = 5
const LEGEND_HEIGHT = 20
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
onMounted(async () => {
const res = await getAIHeatmapData()
data.value = res.data
const getColor = (count: number) =>
count === 0 ? COLORS[0] :
count <= 2 ? COLORS[1] :
count <= 4 ? COLORS[2] :
count <= 7 ? COLORS[3] : COLORS[4]
const cells = computed(() =>
aiStore.heatmapData.map((item, i) => ({
date: new Date(item.timestamp),
count: item.value,
color: getColor(item.value),
week: Math.floor(i / 7),
day: i % 7,
x: Math.floor(i / 7) * CELL_TOTAL,
y: (i % 7) * CELL_TOTAL,
}))
)
const monthLabels = computed(() => {
const labels: { text: string; x: number }[] = []
let lastMonth = -1
cells.value.forEach((cell, i) => {
const month = cell.date.getMonth()
const isWeekStart = cell.date.getDay() === 0 || i === 0
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
labels.push({
text: `${month + 1}`,
x: DAY_WIDTH + cell.week * CELL_TOTAL,
})
</script>
<style scoped>
.chart {
margin: 0 auto;
lastMonth = month
}
})
return labels
})
const svgWidth = computed(() =>
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING
)
const svgHeight = computed(() =>
MONTH_HEIGHT + 7 * CELL_TOTAL + LEGEND_HEIGHT
)
interface Cell {
date: Date
count: number
color: string
week: number
day: number
x: number
y: number
}
const tooltip = ref<{
x: number
y: number
date: string
text: string
count: number
} | null>(null)
const tooltipStyle = computed(() => ({
left: `${tooltip.value?.x}px`,
top: `${tooltip.value?.y}px`,
}))
const getTooltipText = (count: number) =>
count === 0 ? "没有提交记录" : `提交了 ${count}`
const showTooltip = (e: MouseEvent, cell: Cell) => {
const rect = (e.target as HTMLElement).getBoundingClientRect()
const containerRect = containerRef.value?.getBoundingClientRect()
if (containerRect) {
tooltip.value = {
x: rect.left - containerRect.left + rect.width / 2,
y: rect.top - containerRect.top - 10,
date: parseTime(cell.date, "YYYY年M月D日"),
text: getTooltipText(cell.count),
count: cell.count,
}
}
}
const hideTooltip = () => {
tooltip.value = null
}
</script>
<style scoped>
.heatmap-container {
width: 100%;
padding: 10px 0;
position: relative;
}
.heatmap-svg {
width: 100%;
height: auto;
display: block;
}
.label {
fill: currentColor;
opacity: 0.7;
}
.cell {
cursor: pointer;
transition: all 0.2s ease;
stroke: rgba(0, 0, 0, 0.05);
stroke-width: 0.5;
}
.cell:hover {
stroke: rgba(0, 0, 0, 0.3);
stroke-width: 1.5;
filter: brightness(0.9);
}
.legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: 10px;
font-size: 12px;
opacity: 0.7;
}
.legend-colors {
display: flex;
gap: 3px;
}
.legend-colors > div {
width: 12px;
height: 12px;
border-radius: 2px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.tooltip {
position: absolute;
transform: translate(-50%, -100%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fade-in 0.2s ease;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 0, 0, 0.9);
}
.tooltip-date {
font-weight: 500;
margin-bottom: 2px;
}
.tooltip-count {
opacity: 0.6;
}
.tooltip-count.active {
color: #7bc96f;
opacity: 0.9;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translate(-50%, calc(-100% - 5px));
}
to {
opacity: 1;
transform: translate(-50%, -100%);
}
.title {
text-align: center;
font-size: 20px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<n-alert
:show-icon="false"
type="success"
v-if="aiStore.detailsData.solved.length"
>
<span>{{ durationLabel }}</span>
<span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
<span> 道题</span>
<span v-if="aiStore.detailsData.contest_count > 0">
并且参加
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
次比赛
</span>
<span>综合评价给到</span>
<Grade :grade="aiStore.detailsData.grade" />
<span>{{ greeting }}</span>
</n-alert>
<n-flex vertical size="large" v-else>
<n-alert type="error" title="你还没有完成任何题目">
开始解题看看你的学习能力吧
</n-alert>
<AI />
</n-flex>
</template>
<script lang="ts" setup>
import Grade from "./Grade.vue"
import { parseTime } from "utils/functions"
import { useAIStore } from "oj/store/ai"
import AI from "./AI.vue"
const aiStore = useAIStore()
const durationLabel = computed(() => {
if (aiStore.duration.includes("hours")) {
return `${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
} else if (aiStore.duration.includes("days")) {
return `${parseTime(aiStore.detailsData.end, "MM月DD日")}`
} else if (
aiStore.duration.includes("weeks") ||
aiStore.duration.includes("months")
) {
return `${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
} else {
return `${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
}
})
const greeting = computed(() => {
return {
S: "要不试试高难度题目?",
A: "你很棒,继续保持!",
B: "请再接再厉!",
C: "你还需要努力!",
}[aiStore.detailsData.grade]
})
</script>
<style scoped>
.charming {
font-size: 1.2rem;
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<n-card :title="title" size="small" v-if="show">
<div class="chart">
<Chart type="line" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Colors,
Filler,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
import type { Grade } from "utils/types"
// 注册折线图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Colors,
Filler,
)
const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const
const gradeColors: Record<Grade, string> = {
C: "#95F204",
B: "#36A2EB",
A: "#FFCE56",
S: "#FF6384",
}
const title = computed(() => {
const durationMap: Record<string, string> = {
"hours:1": "一节课内",
"hours:2": "两节课内",
"days:1": "一天内",
"weeks:1": "一周内",
"months:1": "一个月内",
"months:2": "两个月内",
"months:6": "半年内",
"years:1": "一年内",
}
const label = durationMap[aiStore.duration] || ""
return label ? `${label}做题的进步曲线` : "做题的进步曲线"
})
// 判断是否有数据
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
// 按时间排序的题目列表
const sortedProblems = computed(() => {
return [...aiStore.detailsData.solved].sort(
(a, b) => new Date(a.ac_time).getTime() - new Date(b.ac_time).getTime(),
)
})
// 计算累计题目数量和等级趋势
const progressData = computed(() => {
const problems = sortedProblems.value
let cumulativeCount = 0
const gradeValues: { [key: string]: number } = { C: 0, B: 0, A: 0, S: 0 }
return problems.map((problem) => {
cumulativeCount++
const grade = problem.grade || "C"
gradeValues[grade]++
// 计算平均等级(加权平均)
let totalWeight = 0
let weightedSum = 0
for (const [g, count] of Object.entries(gradeValues)) {
totalWeight += count
weightedSum += gradeOrder.indexOf(g as Grade) * count
}
const avgGrade = totalWeight > 0 ? weightedSum / totalWeight : 0
return {
time: parseTime(problem.ac_time, "M/D"),
fullTime: parseTime(problem.ac_time, "YYYY-MM-DD HH:mm:ss"),
count: cumulativeCount,
grade: problem.grade || "C",
avgGrade: avgGrade,
problem: problem.problem,
}
})
})
// 图表数据
const data = computed<ChartData<"line">>(() => {
const progress = progressData.value
return {
labels: progress.map((p) => p.time),
datasets: [
{
type: "line",
label: "累计完成题目",
data: progress.map((p) => p.count),
borderColor: "#4CAF50",
backgroundColor: "rgba(76, 175, 80, 0.1)",
tension: 0.3,
yAxisID: "y",
fill: true,
pointRadius: 4,
pointHoverRadius: 6,
borderWidth: 2,
},
{
type: "line",
label: "平均等级",
data: progress.map((p) => p.avgGrade),
borderColor: "#FF9800",
backgroundColor: "rgba(255, 152, 0, 0.1)",
tension: 0.3,
yAxisID: "y1",
fill: false,
pointRadius: 4,
pointHoverRadius: 6,
borderWidth: 2,
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
pointBorderColor: progress.map((p) => gradeColors[p.grade]),
pointBorderWidth: 2,
},
],
}
})
// 图表配置
const options = computed<ChartOptions<"line">>(() => {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
minRotation: 0,
autoSkip: true,
maxTicksLimit: 15,
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: "累计题目数",
font: {
size: 14,
},
},
ticks: {
stepSize: 1,
},
beginAtZero: true,
},
y1: {
type: "linear",
position: "right",
min: -0.5,
max: gradeOrder.length - 0.5,
title: {
display: true,
text: "平均等级",
font: {
size: 14,
},
},
ticks: {
stepSize: 1,
callback: (v) => {
const idx = Math.round(Number(v))
return gradeOrder[idx] || ""
},
},
grid: {
drawOnChartArea: false,
},
},
},
plugins: {
title: {
display: false,
},
tooltip: {
callbacks: {
title: (items: TooltipItem<"line">[]) => {
if (items.length > 0) {
const idx = items[0].dataIndex
return progressData.value[idx]?.fullTime || ""
}
return ""
},
label: (ctx: TooltipItem<"line">) => {
const dsLabel = ctx.dataset.label || ""
const idx = ctx.dataIndex
if ((ctx.dataset as any).yAxisID === "y1") {
const progress = progressData.value[idx]
if (progress) {
const avgIdx = Math.round(Number(ctx.parsed.y))
return [
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
`当前题目等级: ${progress.grade}`,
`题目: ${progress.problem.title}`,
]
}
} else {
return `${dsLabel}: ${ctx.formattedValue}`
}
return `${dsLabel}: ${ctx.formattedValue}`
},
},
},
legend: {
display: true,
position: "top",
labels: {
usePointStyle: true,
padding: 15,
font: {
size: 12,
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<n-data-table
v-if="solvedProblems.length"
striped
:data="solvedProblems"
:columns="columns"
:max-height="isDesktop ? 2000 : 500"
/>
</template>
<script lang="ts" setup>
import { NButton } from "naive-ui"
import TagTitle from "./TagTitle.vue"
import { SolvedProblem } from "utils/types"
import { useAIStore } from "oj/store/ai"
import { isDesktop } from "shared/composables/breakpoints"
const router = useRouter()
const aiStore = useAIStore()
const solvedProblems = computed(() => aiStore.detailsData.solved)
const columns: DataTableColumn<SolvedProblem>[] = [
{
title: "完成的题目",
key: "problem.title",
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
if (row.problem.contest_id) {
router.push(
"/contest/" +
row.problem.contest_id +
"/problem/" +
row.problem.display_id,
)
} else {
router.push("/problem/" + row.problem.display_id)
}
},
},
() => {
if (row.problem.contest_id) {
return h(TagTitle, { problem: row.problem })
} else {
return row.problem.display_id + " " + row.problem.title
}
},
),
},
{
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
key: "rank",
width: 100,
align: "center",
render: (row) => row.rank + " / " + row.ac_count,
},
{
title: "等级",
key: "grade",
width: 100,
align: "center",
},
]
</script>

View File

@@ -1,7 +1,9 @@
<template>
<div class="chart" v-if="show">
<n-card :title="title" size="small" v-if="show">
<div class="chart">
<Pie :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Pie } from "vue-chartjs"
@@ -13,24 +15,27 @@ import {
Legend,
Colors,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
// 仅注册饼图所需的 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
const props = defineProps<{
tags: { [key: string]: number }
}>()
const aiStore = useAIStore()
const show = computed(() => {
return Object.keys(props.tags).length > 0
return Object.keys(aiStore.detailsData.tags).length > 0
})
const title = computed(() => {
return `标签分布(前${Object.keys(aiStore.detailsData.tags).length}个)`
})
const data = computed(() => {
return {
labels: Object.keys(props.tags),
labels: Object.keys(aiStore.detailsData.tags),
datasets: [
{
data: Object.values(props.tags),
data: Object.values(aiStore.detailsData.tags),
backgroundColor: [
"#FF6384",
"#36A2EB",
@@ -49,15 +54,6 @@ const options = computed(() => {
intersect: false,
},
maintainAspectRatio: false,
plugins: {
title: {
text: `题目的标签分布(前${Object.keys(props.tags).length}个)`,
display: true,
font: {
size: 20,
},
},
},
}
})
</script>

View File

@@ -257,8 +257,8 @@ export function getAIDetailData(start: string, end: string) {
return http.get("ai/detail", { params: { start, end } })
}
export function getAIWeeklyData(end: string, duration: string) {
return http.get("ai/weekly", { params: { end, duration } })
export function getAIDurationData(end: string, duration: string) {
return http.get("ai/duration", { params: { end, duration } })
}
export function getAIHeatmapData() {

View File

@@ -1,11 +1,11 @@
import { DetailsData, WeeklyData } from "utils/types"
import { DetailsData, DurationData } from "utils/types"
import { consumeJSONEventStream } from "utils/stream"
import { getAIDetailData, getAIWeeklyData } from "../api"
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api"
import { getCSRFToken } from "utils/functions"
export const useAIStore = defineStore("ai", () => {
const duration = ref("months:6")
const weeklyData = ref<WeeklyData[]>([])
const durationData = ref<DurationData[]>([])
const detailsData = reactive<DetailsData>({
start: "",
end: "",
@@ -16,17 +16,17 @@ export const useAIStore = defineStore("ai", () => {
contest_count: 0,
solved: [],
})
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
const loading = reactive({
details: false,
weekly: false,
fetching: false, // 合并 details 和 duration 的 loading
ai: false,
heatmap: false,
})
const mdContent = ref("")
async function fetchDetailsData(start: string, end: string) {
loading.details = true
const res = await getAIDetailData(start, end)
detailsData.start = res.data.start
detailsData.end = res.data.end
@@ -36,14 +36,31 @@ export const useAIStore = defineStore("ai", () => {
detailsData.tags = res.data.tags
detailsData.difficulty = res.data.difficulty
detailsData.contest_count = res.data.contest_count
loading.details = false
}
async function fetchWeeklyData(end: string, duration: string) {
loading.weekly = true
const res = await getAIWeeklyData(end, duration)
weeklyData.value = res.data
loading.weekly = false
async function fetchDurationData(end: string, duration: string) {
const res = await getAIDurationData(end, duration)
durationData.value = res.data
}
async function fetchHeatmapData() {
loading.heatmap = true
const res = await getAIHeatmapData()
heatmapData.value = res.data
loading.heatmap = false
}
// 统一获取分析数据details + duration
async function fetchAnalysisData(start: string, end: string, duration: string) {
loading.fetching = true
try {
await Promise.all([
fetchDetailsData(start, end),
fetchDurationData(end, duration),
])
} finally {
loading.fetching = false
}
}
let aiController: AbortController | null = null
@@ -72,7 +89,7 @@ export const useAIStore = defineStore("ai", () => {
headers,
body: JSON.stringify({
details: detailsData,
weekly: weeklyData.value,
duration: durationData.value,
}),
signal: controller.signal,
})
@@ -126,11 +143,12 @@ export const useAIStore = defineStore("ai", () => {
}
return {
fetchWeeklyData,
fetchDetailsData,
fetchAnalysisData,
fetchHeatmapData,
fetchAIAnalysis,
weeklyData,
durationData,
detailsData,
heatmapData,
duration,
loading,
mdContent,

View File

@@ -126,16 +126,9 @@ onMounted(async () => {
<template>
<n-layout has-sider position="absolute">
<!-- 侧边栏 -->
<n-layout-sider
bordered
:width="100"
:native-scrollbar="false"
>
<n-layout-sider bordered :width="100" :native-scrollbar="false">
<!-- 菜单 -->
<n-menu
:options="options"
:value="active"
/>
<n-menu :options="options" :value="active" />
</n-layout-sider>
<!-- 主内容区域 -->

View File

@@ -412,7 +412,7 @@ export interface Tutorial {
created_at?: Date
}
export interface WeeklyData {
export interface DurationData {
unit: string
index: number
start: string