diff --git a/docs/图表.md b/docs/图表.md new file mode 100644 index 0000000..1de026e --- /dev/null +++ b/docs/图表.md @@ -0,0 +1,41 @@ +📊 可以添加的图表类型 +1. 提交效率趋势图 (折线图) +数据来源: durationData 中的 submission_count / problem_count +展示内容: 每个时间段的提交效率(提交次数/完成题目数),值越接近1说明一次AC率越高 +价值: 反映刷题质量的提升 +2. 排名分布图 (直方图/箱线图) +数据来源: solved 数组中每道题的 rank 和 ac_count +展示内容: 用户解题排名的分布情况(如:前10%、10-30%、30-50%等区间的题目数量) +价值: 了解解题速度和竞争力 +3. 等级分布饼图/环形图 +数据来源: solved 数组中每道题的 grade +展示内容: S/A/B/C 各等级题目的数量和占比 +价值: 直观看出题目质量分布 +4. 标签雷达图 +数据来源: tags 对象 +展示内容: 多维度展示各类标签的掌握程度(可以归一化处理) +价值: 可视化知识点覆盖面 +5. 时间活跃度分析 (热力矩阵) +数据来源: solved 数组中的 ac_time +展示内容: 按星期几和时间段统计做题分布(如:工作日vs周末,早中晚时段) +价值: 了解学习习惯和时间规律 +6. 难度-等级关联散点图 +数据来源: solved 数组中的难度信息和 grade +展示内容: X轴为难度,Y轴为等级,每个点代表一道题 +价值: 分析在不同难度下的表现 +7. 做题加速度图 +数据来源: durationData +展示内容: 每个时间段完成题目数的变化率 +价值: 看出学习动力的变化趋势 +8. 竞赛题目占比 +数据来源: solved 数组中的 contest_id 和 contest_count +展示内容: 竞赛题 vs 常规题的数量对比 +价值: 了解竞赛参与情况 +9. 连续做题天数统计 +数据来源: heatmapData +展示内容: 最长连续做题天数、当前连续天数等 +价值: 激励持续学习 +10. 月度对比雷达图 +数据来源: durationData +展示内容: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比 +价值: 全面评估进步情况 \ No newline at end of file diff --git a/src/oj/ai/analysis.vue b/src/oj/ai/analysis.vue index e20348d..3b4ebeb 100644 --- a/src/oj/ai/analysis.vue +++ b/src/oj/ai/analysis.vue @@ -12,15 +12,18 @@ /> - - - + + + - - - - - + + + + + + + + @@ -30,6 +33,7 @@ + @@ -48,13 +52,15 @@ diff --git a/src/oj/ai/components/DifficultyGradeChart.vue b/src/oj/ai/components/DifficultyGradeChart.vue new file mode 100644 index 0000000..51b9170 --- /dev/null +++ b/src/oj/ai/components/DifficultyGradeChart.vue @@ -0,0 +1,145 @@ + + + + + 了解不同难度题目的完成等级分布 + + + + + + + + + + diff --git a/src/oj/ai/components/DurationChart.vue b/src/oj/ai/components/DurationChart.vue index b73334d..e1912d4 100644 --- a/src/oj/ai/components/DurationChart.vue +++ b/src/oj/ai/components/DurationChart.vue @@ -1,5 +1,10 @@ + + + 全面评估学习情况 + + @@ -69,15 +74,17 @@ const data = computed>(() => { datasets: [ { type: "bar", - label: "完成题目数量", + label: "完成题目数", data: aiStore.durationData.map((duration) => duration.problem_count), yAxisID: "y", + order: 2, }, { type: "bar", label: "总提交次数", data: aiStore.durationData.map((duration) => duration.submission_count), yAxisID: "y", + order: 2, }, { type: "line", @@ -88,6 +95,10 @@ const data = computed>(() => { tension: 0.4, yAxisID: "y1", barThickness: 10, + order: 1, + borderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, }, ], } @@ -100,16 +111,26 @@ const options = computed>(() => { }, maintainAspectRatio: false, scales: { + x: { + grid: { + display: false, + }, + }, y: { ticks: { stepSize: 1, }, + title: { + display: true, + text: "数量", + }, + beginAtZero: true, }, y1: { type: "linear", position: "right", - min: -1, - max: gradeOrder.length, + min: -0.5, + max: gradeOrder.length - 0.5, ticks: { stepSize: 1, callback: (v) => { @@ -117,9 +138,27 @@ const options = computed>(() => { return gradeOrder[idx] || "" }, }, + title: { + display: true, + text: "等级", + }, + grid: { + display: false, + }, }, }, plugins: { + legend: { + display: true, + position: "bottom" as const, + labels: { + boxWidth: 12, + padding: 8, + font: { + size: 11, + }, + }, + }, title: { display: false, }, @@ -133,6 +172,16 @@ const options = computed>(() => { } return `${dsLabel}: ${ctx.formattedValue}` }, + footer: (items: TooltipItem<"bar">[]) => { + const barItems = items.filter(item => (item.dataset as any).yAxisID === "y") + if (barItems.length >= 2) { + const problemCount = barItems.find(item => item.dataset.label === "完成题目数")?.parsed.y || 0 + const submissionCount = barItems.find(item => item.dataset.label === "总提交次数")?.parsed.y || 0 + const efficiency = submissionCount > 0 ? ((problemCount / submissionCount) * 100).toFixed(1) : "0" + return `AC率: ${efficiency}%` + } + return "" + }, }, }, }, diff --git a/src/oj/ai/components/EfficiencyChart.vue b/src/oj/ai/components/EfficiencyChart.vue new file mode 100644 index 0000000..3bbb2c6 --- /dev/null +++ b/src/oj/ai/components/EfficiencyChart.vue @@ -0,0 +1,236 @@ + + + + + 反映刷题质量提升 + + + + + + + + + + diff --git a/src/oj/ai/components/GradeChart.vue b/src/oj/ai/components/GradeChart.vue deleted file mode 100644 index 30b8199..0000000 --- a/src/oj/ai/components/GradeChart.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - diff --git a/src/oj/ai/components/Heatmap.vue b/src/oj/ai/components/Heatmap.vue index 40d6527..8ab9cc3 100644 --- a/src/oj/ai/components/Heatmap.vue +++ b/src/oj/ai/components/Heatmap.vue @@ -1,5 +1,10 @@ + + + 激励持续学习 + + - - 少 - - - - 多 - - {{ tooltip.date }} @@ -77,7 +70,6 @@ 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 = ["", "一", "", "三", "", "五", ""] @@ -129,7 +121,7 @@ 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) +const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL) interface Cell { date: Date @@ -208,28 +200,6 @@ const hideTooltip = () => { 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%); diff --git a/src/oj/ai/components/ProgressChart.vue b/src/oj/ai/components/ProgressChart.vue index 1071e0d..4ccefd1 100644 --- a/src/oj/ai/components/ProgressChart.vue +++ b/src/oj/ai/components/ProgressChart.vue @@ -1,5 +1,10 @@ + + + 追踪学习成长轨迹 + + @@ -23,7 +28,6 @@ import { import { useAIStore } from "oj/store/ai" import { parseTime } from "utils/functions" import type { Grade } from "utils/types" -import { DURATION_OPTIONS } from "utils/constants" // 注册折线图所需的 Chart.js 组件 ChartJS.register( @@ -49,49 +53,54 @@ const gradeColors: Record = { } const title = computed(() => { - const option = DURATION_OPTIONS.find((opt) => opt.value === aiStore.duration) - return option ? `${option.label}的进步曲线` : "进步曲线" + if (aiStore.duration === "months:2") { + return "过去两个月的进步曲线" + } else if (aiStore.duration === "months:6") { + return "过去半年的进步曲线" + } else if (aiStore.duration === "years:1") { + return "过去一年的进步曲线" + } else { + return "过去四周的进步曲线" + } }) // 判断是否有数据 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(), - ) + return aiStore.durationData.length > 0 }) // 计算累计题目数量和等级趋势 const progressData = computed(() => { - const problems = sortedProblems.value let cumulativeCount = 0 - const gradeValues: { [key: string]: number } = { C: 0, B: 0, A: 0, S: 0 } + let totalWeightedGrade = 0 // 累计加权等级 + let totalProblems = 0 // 累计题目总数 - return problems.map((problem) => { - cumulativeCount++ - const grade = problem.grade || "C" - gradeValues[grade]++ + return aiStore.durationData.map((duration) => { + const problemCount = duration.problem_count || 0 + cumulativeCount += problemCount - // 计算平均等级(加权平均) - 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 + // 计算本期等级的权重值 + const currentGradeValue = gradeOrder.indexOf(duration.grade || "C") + + // 累加加权等级 + totalWeightedGrade += currentGradeValue * problemCount + totalProblems += problemCount + + // 计算累计平均等级 + const avgGradeValue = totalProblems > 0 ? totalWeightedGrade / totalProblems : 0 return { - time: parseTime(problem.ac_time, "M/D"), - fullTime: parseTime(problem.ac_time, "YYYY-MM-DD HH:mm:ss"), + label: [ + parseTime(duration.start, "M月D日"), + parseTime(duration.end, "M月D日"), + ].join("~"), + start: parseTime(duration.start, "YYYY-MM-DD"), + end: parseTime(duration.end, "YYYY-MM-DD"), count: cumulativeCount, - grade: problem.grade || "C", - avgGrade: avgGrade, - problem: problem.problem, + grade: duration.grade || "C", + gradeValue: currentGradeValue, + avgGradeValue: avgGradeValue, // 累计平均等级 + problemCount: problemCount, } }) }) @@ -101,7 +110,7 @@ const data = computed>(() => { const progress = progressData.value return { - labels: progress.map((p) => p.time), + labels: progress.map((p) => p.label), datasets: [ { type: "line", @@ -109,27 +118,30 @@ const data = computed>(() => { data: progress.map((p) => p.count), borderColor: "#4CAF50", backgroundColor: "rgba(76, 175, 80, 0.1)", - tension: 0.3, + tension: 0.4, yAxisID: "y", fill: true, - pointRadius: 4, - pointHoverRadius: 6, - borderWidth: 2, + pointRadius: 5, + pointHoverRadius: 7, + borderWidth: 2.5, + pointBackgroundColor: "#4CAF50", + pointBorderColor: "#fff", + pointBorderWidth: 2, }, { type: "line", - label: "平均等级", - data: progress.map((p) => p.avgGrade), + label: "累计平均等级", + data: progress.map((p) => p.avgGradeValue), borderColor: "#FF9800", backgroundColor: "rgba(255, 152, 0, 0.1)", - tension: 0.3, + tension: 0.4, yAxisID: "y1", fill: false, - pointRadius: 4, - pointHoverRadius: 6, - borderWidth: 2, + pointRadius: 5, + pointHoverRadius: 7, + borderWidth: 2.5, pointBackgroundColor: progress.map((p) => gradeColors[p.grade]), - pointBorderColor: progress.map((p) => gradeColors[p.grade]), + pointBorderColor: "#fff", pointBorderWidth: 2, }, ], @@ -176,14 +188,14 @@ const options = computed>(() => { max: gradeOrder.length - 0.5, title: { display: true, - text: "平均等级", + text: "累计平均等级", font: { size: 14, }, }, ticks: { stepSize: 1, - callback: (v) => { + callback: (v: string | number) => { const idx = Math.round(Number(v)) return gradeOrder[idx] || "" }, @@ -198,41 +210,51 @@ const options = computed>(() => { display: false, }, tooltip: { + backgroundColor: "rgba(0, 0, 0, 0.8)", + padding: 12, callbacks: { title: (items: TooltipItem<"line">[]) => { if (items.length > 0) { const idx = items[0].dataIndex - return progressData.value[idx]?.fullTime || "" + const progress = progressData.value[idx] + return progress ? `${progress.start} ~ ${progress.end}` : "" } return "" }, label: (ctx: TooltipItem<"line">) => { const dsLabel = ctx.dataset.label || "" const idx = ctx.dataIndex + const progress = progressData.value[idx] - 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 { + if (!progress) { return `${dsLabel}: ${ctx.formattedValue}` } - return `${dsLabel}: ${ctx.formattedValue}` + + if ((ctx.dataset as any).yAxisID === "y1") { + // 累计平均等级轴 + const avgIdx = Math.round(Number(ctx.parsed.y)) + return [ + `${dsLabel}: ${gradeOrder[avgIdx] || ""}`, + `本期等级: ${progress.grade}`, + `本期完成: ${progress.problemCount} 题`, + ] + } else { + // 累计题目数轴 + return [ + `${dsLabel}: ${ctx.formattedValue} 题`, + `本期完成: ${progress.problemCount} 题`, + ] + } }, }, }, legend: { display: true, - position: "top", + position: "bottom" as const, labels: { - usePointStyle: true, - padding: 15, + boxWidth: 12, + boxHeight: 12, + padding: 8, font: { size: 12, }, diff --git a/src/oj/ai/components/RankDistributionChart.vue b/src/oj/ai/components/RankDistributionChart.vue new file mode 100644 index 0000000..bb00053 --- /dev/null +++ b/src/oj/ai/components/RankDistributionChart.vue @@ -0,0 +1,134 @@ + + + + + 了解解题速度和竞争力 + + + + + + + + + + diff --git a/src/oj/ai/components/StreakStats.vue b/src/oj/ai/components/StreakStats.vue new file mode 100644 index 0000000..7e7541a --- /dev/null +++ b/src/oj/ai/components/StreakStats.vue @@ -0,0 +1,178 @@ + + + + + 激励持续学习 + + + + + + + + 天 + + 🔥 + + + + + + + + 天 + + ⭐ + + + + + + + + 天 + + + + + + + 天 + + + + + + + + + 开始做题,建立学习连续记录! + + + 继续保持,争取连续3天! + + + 很棒!继续保持一周连续记录! + + + 太棒了!坚持满30天将获得「持之以恒」成就! + + + 🎉 恭喜你!你已经连续学习 {{ currentStreak }} 天,真的非常厉害! + + + + + + + + + diff --git a/src/oj/ai/components/TagsChart.vue b/src/oj/ai/components/TagsChart.vue deleted file mode 100644 index 35a2f6f..0000000 --- a/src/oj/ai/components/TagsChart.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - diff --git a/src/oj/ai/components/TagsRadarChart.vue b/src/oj/ai/components/TagsRadarChart.vue new file mode 100644 index 0000000..d54550b --- /dev/null +++ b/src/oj/ai/components/TagsRadarChart.vue @@ -0,0 +1,156 @@ + + + + + 可视化知识点覆盖面 + + + + + + + + + diff --git a/src/oj/ai/components/TimeActivityHeatmap.vue b/src/oj/ai/components/TimeActivityHeatmap.vue new file mode 100644 index 0000000..4c1a308 --- /dev/null +++ b/src/oj/ai/components/TimeActivityHeatmap.vue @@ -0,0 +1,153 @@ + + + + + 发现最佳学习时段 + + + + + + + + + + diff --git a/src/oj/store/ai.ts b/src/oj/store/ai.ts index c66c8fd..f9f93e7 100644 --- a/src/oj/store/ai.ts +++ b/src/oj/store/ai.ts @@ -50,7 +50,6 @@ export const useAIStore = defineStore("ai", () => { loading.heatmap = false } - // 统一获取分析数据(details + duration) async function fetchAnalysisData( start: string, end: string, diff --git a/src/utils/types.ts b/src/utils/types.ts index 0a13fe2..48765ae 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -433,6 +433,7 @@ export interface SolvedProblem { rank: number ac_count: number grade: Grade + difficulty: string } export interface DetailsData {