This commit is contained in:
2025-10-07 03:05:08 +08:00
parent 97baf85611
commit 6f345611eb
16 changed files with 1201 additions and 341 deletions

41
docs/图表.md Normal file
View File

@@ -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
展示内容: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
价值: 全面评估进步情况

View File

@@ -12,15 +12,18 @@
/>
</n-flex>
<Overview />
<n-grid :cols="2" :x-gap="20">
<n-gi :span="1">
<TagsChart />
<n-grid :cols="2" :x-gap="20" :y-gap="20">
<n-gi :span="isDesktop ? 1 : 2">
<DifficultyGradeChart />
</n-gi>
<n-gi :span="1">
<n-flex vertical :size="20">
<DifficultyChart />
<GradeChart />
</n-flex>
<n-gi :span="isDesktop ? 1 : 2">
<TagsRadarChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<RankDistributionChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<TimeActivityHeatmap />
</n-gi>
</n-grid>
<SolvedTable />
@@ -30,6 +33,7 @@
<n-flex vertical size="large">
<Heatmap />
<ProgressChart />
<EfficiencyChart />
<DurationChart />
<AI v-if="aiStore.detailsData.solved.length >= 10" />
</n-flex>
@@ -48,13 +52,15 @@
<script setup lang="ts">
import { isDesktop } from "shared/composables/breakpoints"
import { formatISO, sub, type Duration } from "date-fns"
import TagsChart from "./components/TagsChart.vue"
import DifficultyChart from "./components/DifficultyChart.vue"
import GradeChart from "./components/GradeChart.vue"
import TagsRadarChart from "./components/TagsRadarChart.vue"
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
import RankDistributionChart from "./components/RankDistributionChart.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 EfficiencyChart from "./components/EfficiencyChart.vue"
import AI from "./components/AI.vue"
import SolvedTable from "./components/SolvedTable.vue"
import { useAIStore } from "../store/ai"

View File

@@ -1,72 +0,0 @@
<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 { useAIStore } from "oj/store/ai"
// 仅注册柱状图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
Colors,
)
const aiStore = useAIStore()
const show = computed(() => {
return (
Object.values(aiStore.detailsData.difficulty).reduce((a, b) => a + b, 0) > 0
)
})
const data = computed(() => {
return {
labels: Object.keys(aiStore.detailsData.difficulty),
datasets: [
{
data: Object.values(aiStore.detailsData.difficulty),
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
},
],
}
})
const options = {
interaction: {
intersect: false,
},
maintainAspectRatio: false,
scales: {
y: {
ticks: {
stepSize: 1,
},
},
},
plugins: {
legend: {
display: false,
},
title: {
display: false,
},
},
}
</script>

View File

@@ -0,0 +1,145 @@
<template>
<n-card title="难度掌握情况" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
了解不同难度题目的完成等级分布
</n-text>
</template>
<div style="height: 300px">
<Bar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import type { Grade } from "utils/types"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
// 难度和等级的顺序(后端返回的是中文)
const difficultyOrder = ["简单", "中等", "困难"]
const gradeOrder: Grade[] = ["S", "A", "B", "C"]
// 统计每个难度-等级组合的题目数量
const matrix = computed(() => {
const result: { [difficulty: string]: { [grade: string]: number } } = {}
// 初始化矩阵
difficultyOrder.forEach((diff) => {
result[diff] = {}
gradeOrder.forEach((grade) => {
result[diff][grade] = 0
})
})
// 统计数据
aiStore.detailsData.solved.forEach((item) => {
const diff = item.difficulty
const grade = item.grade
if (diff && grade && result[diff]) {
result[diff][grade]++
}
})
return result
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
// 为每个等级准备数据集
const data = computed(() => {
// 为每个等级生成一个 dataset
const datasets = gradeOrder.map((grade) => {
return {
label: `等级 ${grade}`,
data: difficultyOrder.map((diff) => matrix.value[diff][grade]),
backgroundColor: getGradeColor(grade),
borderColor: getGradeColor(grade),
borderWidth: 1,
}
})
return {
labels: difficultyOrder,
datasets,
}
})
// 根据等级返回对应的颜色
function getGradeColor(grade: Grade): string {
const colors: { [key in Grade]: string } = {
S: "#FF6384",
A: "#FFCE56",
B: "#36A2EB",
C: "#95F204",
}
return colors[grade]
}
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index" as const,
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "题目数量",
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
footer: (items: any[]) => {
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
return `该难度总计: ${total}`
},
},
},
},
}
</script>

View File

@@ -1,5 +1,10 @@
<template>
<n-card :title="title" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
全面评估学习情况
</n-text>
</template>
<div class="chart">
<Chart type="bar" :data="data" :options="options" />
</div>
@@ -69,15 +74,17 @@ const data = computed<ChartData<"bar" | "line">>(() => {
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<ChartData<"bar" | "line">>(() => {
tension: 0.4,
yAxisID: "y1",
barThickness: 10,
order: 1,
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
}
@@ -100,16 +111,26 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
},
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<ChartOptions<"bar" | "line">>(() => {
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<ChartOptions<"bar" | "line">>(() => {
}
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 ""
},
},
},
},

View File

@@ -0,0 +1,236 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
反映刷题质量提升
</n-text>
</template>
<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,
Filler,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
// 注册折线图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
)
const aiStore = useAIStore()
const title = computed(() => {
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.durationData.length > 0
})
// 计算提交效率数据
const efficiencyData = computed(() => {
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
const submissionCount = duration.submission_count || 0
// 计算效率:提交次数/完成题目数
// 值越接近1说明一次AC率越高
const efficiency = problemCount > 0 ? submissionCount / problemCount : 0
// 计算一次AC率百分比
const onePassRate = problemCount > 0 ? (problemCount / submissionCount) * 100 : 0
return {
label: [
parseTime(duration.start, "M月D日"),
parseTime(duration.end, "M月D日"),
].join(""),
efficiency: efficiency,
onePassRate: onePassRate,
problemCount: problemCount,
submissionCount: submissionCount,
}
})
})
// 图表数据
const data = computed<ChartData<"line">>(() => {
const efficiency = efficiencyData.value
return {
labels: efficiency.map((e) => e.label),
datasets: [
{
label: "平均提交次数",
data: efficiency.map((e) => e.efficiency),
borderColor: "rgb(99, 102, 241)",
backgroundColor: "rgba(99, 102, 241, 0.1)",
tension: 0.4,
fill: true,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: "rgb(99, 102, 241)",
pointBorderColor: "#fff",
pointBorderWidth: 2,
yAxisID: "y",
},
{
label: "一次AC率",
data: efficiency.map((e) => e.onePassRate),
borderColor: "rgb(34, 197, 94)",
backgroundColor: "rgba(34, 197, 94, 0.1)",
tension: 0.4,
fill: true,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: "rgb(34, 197, 94)",
pointBorderColor: "#fff",
pointBorderWidth: 2,
yAxisID: "y1",
},
],
}
})
// 图表配置
const options = computed<ChartOptions<"line">>(() => {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
minRotation: 0,
autoSkip: true,
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: "平均提交次数(次/题)",
font: {
size: 13,
},
},
beginAtZero: true,
ticks: {
callback: function (value: string | number) {
return Number(value).toFixed(1)
},
},
},
y1: {
type: "linear",
position: "right",
min: 0,
max: 100,
title: {
display: true,
text: "一次AC率%",
font: {
size: 13,
},
},
ticks: {
callback: function (value: string | number) {
return Number(value).toFixed(0) + "%"
},
},
grid: {
drawOnChartArea: false,
},
},
},
plugins: {
title: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
callbacks: {
label: function (ctx: TooltipItem<"line">) {
const index = ctx.dataIndex
const item = efficiencyData.value[index]
const dsLabel = ctx.dataset.label || ""
if (ctx.datasetIndex === 0) {
// 平均提交次数
return [
`${dsLabel}: ${item.efficiency.toFixed(2)} 次/题`,
`完成题目: ${item.problemCount}`,
`总提交: ${item.submissionCount}`,
]
} else {
// 一次AC率
return [
`${dsLabel}: ${item.onePassRate.toFixed(1)}%`,
`提示: 值越高表示刷题质量越好`,
]
}
},
},
},
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -1,93 +0,0 @@
<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,5 +1,10 @@
<template>
<n-card title="过去一年的提交热力图" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
激励持续学习
</n-text>
</template>
<n-spin :show="aiStore.loading.heatmap">
<div class="heatmap-container" ref="containerRef">
<svg
@@ -41,18 +46,6 @@
</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 }">
@@ -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%);

View File

@@ -1,5 +1,10 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
追踪学习成长轨迹
</n-text>
</template>
<div class="chart">
<Chart type="line" :data="data" :options="options" />
</div>
@@ -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<Grade, string> = {
}
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<ChartData<"line">>(() => {
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<ChartData<"line">>(() => {
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<ChartOptions<"line">>(() => {
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<ChartOptions<"line">>(() => {
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 (!progress) {
return `${dsLabel}: ${ctx.formattedValue}`
}
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}`,
`本期等级: ${progress.grade}`,
`本期完成: ${progress.problemCount}`,
]
} else {
// 累计题目数轴
return [
`${dsLabel}: ${ctx.formattedValue}`,
`本期完成: ${progress.problemCount}`,
]
}
} else {
return `${dsLabel}: ${ctx.formattedValue}`
}
return `${dsLabel}: ${ctx.formattedValue}`
},
},
},
legend: {
display: true,
position: "top",
position: "bottom" as const,
labels: {
usePointStyle: true,
padding: 15,
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},

View File

@@ -0,0 +1,134 @@
<template>
<n-card title="解题排名分布" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
了解解题速度和竞争力
</n-text>
</template>
<div style="height: 300px">
<Pie :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Pie } from "vue-chartjs"
import {
Chart as ChartJS,
ArcElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
ChartJS.register(ArcElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
// 排名区间定义
const RANK_RANGES = [
{ label: "前10%", min: 0, max: 10, color: "#FF6384" },
{ label: "10-30%", min: 10, max: 30, color: "#FFCE56" },
{ label: "30-50%", min: 30, max: 50, color: "#36A2EB" },
{ label: "50-70%", min: 50, max: 70, color: "#4BC0C0" },
{ label: "70%以后", min: 70, max: 100, color: "#9966FF" },
]
// 计算每道题的排名百分位并分类
const rankDistribution = computed(() => {
const distribution = RANK_RANGES.map((range) => ({
...range,
count: 0,
problems: [] as string[],
}))
aiStore.detailsData.solved.forEach((item) => {
const rank = item.rank
const acCount = item.ac_count
if (rank && acCount && acCount > 0) {
// 计算百分位:(rank / acCount) * 100
// 例如第5名/共100人 = 5%
const percentile = (rank / acCount) * 100
// 找到对应的区间
const rangeIndex = RANK_RANGES.findIndex(
(r) => percentile >= r.min && percentile < r.max,
)
if (rangeIndex !== -1) {
distribution[rangeIndex].count++
distribution[rangeIndex].problems.push(
`${item.problem.display_id}: ${item.problem.title}`,
)
}
}
})
return distribution
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
const data = computed(() => {
return {
labels: RANK_RANGES.map((r) => r.label),
datasets: [
{
label: "题目数量",
data: rankDistribution.value.map((r) => r.count),
backgroundColor: RANK_RANGES.map((r) => r.color),
borderColor: RANK_RANGES.map((r) => r.color),
borderWidth: 1,
},
],
}
})
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
label: (context: any) => {
const count = context.parsed
const total = rankDistribution.value.reduce((sum, r) => sum + r.count, 0)
const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : "0.0"
const label = context.label || ""
return `${label}: ${count} 道题 (${percentage}%)`
},
afterLabel: (context: any) => {
const index = context.dataIndex
const problems = rankDistribution.value[index].problems
if (problems.length > 0 && problems.length <= 5) {
return problems
} else if (problems.length > 5) {
return [...problems.slice(0, 3), `... 还有 ${problems.length - 3} 道题`]
}
return ""
},
},
},
},
}
</script>

View File

@@ -0,0 +1,178 @@
<template>
<n-card title="连续做题统计" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
激励持续学习
</n-text>
</template>
<n-spin :show="aiStore.loading.heatmap">
<n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-gi>
<n-statistic label="当前连续" :value="currentStreak">
<template #suffix>
<span style="font-size: 14px"></span>
<span v-if="currentStreak > 0" style="font-size: 20px; margin-left: 4px">
🔥
</span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="最长连续" :value="maxStreak">
<template #suffix>
<span style="font-size: 14px"></span>
<span v-if="maxStreak >= 7" style="font-size: 20px; margin-left: 4px">
</span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="本周做题" :value="weekCount">
<template #suffix>
<span style="font-size: 14px"></span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="本月做题" :value="monthCount">
<template #suffix>
<span style="font-size: 14px"></span>
</template>
</n-statistic>
</n-gi>
</n-grid>
<n-divider style="margin: 12px 0" />
<n-flex vertical size="small">
<n-text depth="2" style="font-size: 12px">
<span v-if="currentStreak === 0">
开始做题建立学习连续记录
</span>
<span v-else-if="currentStreak < 3">
继续保持争取连续3天
</span>
<span v-else-if="currentStreak < 7">
很棒继续保持一周连续记录
</span>
<span v-else-if="currentStreak < 30">
太棒了坚持满30天将获得持之以恒成就
</span>
<span v-else>
🎉 恭喜你你已经连续学习 {{ currentStreak }} 真的非常厉害
</span>
</n-text>
</n-flex>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
const aiStore = useAIStore()
// 计算连续天数
const streakData = computed(() => {
const heatmap = aiStore.heatmapData
if (!heatmap || heatmap.length === 0) {
return {
currentStreak: 0,
maxStreak: 0,
weekCount: 0,
monthCount: 0,
}
}
// 按时间戳排序
const sortedData = [...heatmap].sort((a, b) => a.timestamp - b.timestamp)
let currentStreak = 0
let maxStreak = 0
let tempStreak = 0
let lastDate: Date | null = null
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
let weekCount = 0
let monthCount = 0
// 检查今天是否有做题
const todayData = sortedData.find((item) => {
const itemDate = new Date(item.timestamp)
return (
itemDate.getFullYear() === today.getFullYear() &&
itemDate.getMonth() === today.getMonth() &&
itemDate.getDate() === today.getDate()
)
})
const hasToday = todayData && todayData.value > 0
// 遍历数据计算连续天数
for (const item of sortedData) {
if (item.value > 0) {
const currentDate = new Date(item.timestamp)
// 统计本周和本月
if (currentDate >= weekAgo) {
weekCount++
}
if (currentDate >= monthAgo) {
monthCount++
}
if (lastDate === null) {
tempStreak = 1
} else {
const dayDiff = Math.floor(
(currentDate.getTime() - lastDate.getTime()) / (24 * 60 * 60 * 1000),
)
if (dayDiff === 1) {
tempStreak++
} else {
maxStreak = Math.max(maxStreak, tempStreak)
tempStreak = 1
}
}
lastDate = currentDate
}
}
maxStreak = Math.max(maxStreak, tempStreak)
// 计算当前连续天数(必须包含今天或昨天)
if (lastDate) {
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
const lastDateOnly = new Date(
lastDate.getFullYear(),
lastDate.getMonth(),
lastDate.getDate(),
)
if (
lastDateOnly.getTime() === today.getTime() ||
lastDateOnly.getTime() === yesterday.getTime()
) {
currentStreak = tempStreak
} else {
currentStreak = 0
}
}
return {
currentStreak,
maxStreak,
weekCount,
monthCount,
}
})
const currentStreak = computed(() => streakData.value.currentStreak)
const maxStreak = computed(() => streakData.value.maxStreak)
const weekCount = computed(() => streakData.value.weekCount)
const monthCount = computed(() => streakData.value.monthCount)
</script>

View File

@@ -1,65 +0,0 @@
<template>
<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"
import {
Chart as ChartJS,
ArcElement,
Title,
Tooltip,
Legend,
Colors,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
// 仅注册饼图所需的 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
const aiStore = useAIStore()
const show = computed(() => {
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(aiStore.detailsData.tags),
datasets: [
{
data: Object.values(aiStore.detailsData.tags),
backgroundColor: [
"#FF6384",
"#36A2EB",
"#FFCE56",
"#4BC0C0",
"#9966FF",
],
},
],
}
})
const options = computed(() => {
return {
interaction: {
intersect: false,
},
maintainAspectRatio: false,
}
})
</script>
<style scoped>
.chart {
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
可视化知识点覆盖面
</n-text>
</template>
<div class="chart">
<Radar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Radar } from "vue-chartjs"
import {
Chart as ChartJS,
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
// 注册雷达图所需的 Chart.js 组件
ChartJS.register(
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
)
const aiStore = useAIStore()
const show = computed(() => {
return Object.keys(aiStore.detailsData.tags).length > 0
})
// 最多显示前10个标签避免雷达图过于拥挤
const MAX_TAGS = 10
const title = computed(() => {
const totalTags = Object.keys(aiStore.detailsData.tags).length
const displayTags = Math.min(totalTags, MAX_TAGS)
return `标签雷达图(前${displayTags}个)`
})
// 计算归一化的数据(用于雷达图展示)
const normalizedData = computed(() => {
const tags = aiStore.detailsData.tags
// 按题目数量降序排序取前MAX_TAGS个
const sortedTags = Object.entries(tags)
.sort(([, a], [, b]) => b - a)
.slice(0, MAX_TAGS)
const values = sortedTags.map(([, value]) => value)
const maxValue = Math.max(...values, 1) // 避免除以0
// 归一化到0-100的范围
return sortedTags.map(([label, value]) => ({
label,
value,
normalized: (value / maxValue) * 100,
}))
})
const data = computed(() => {
const tagData = normalizedData.value
return {
labels: tagData.map((item) => item.label),
datasets: [
{
label: "掌握程度",
data: tagData.map((item) => item.normalized),
backgroundColor: "rgba(99, 102, 241, 0.25)",
borderColor: "rgb(99, 102, 241)",
borderWidth: 2.5,
pointBackgroundColor: "rgb(99, 102, 241)",
pointBorderColor: "#fff",
pointHoverBackgroundColor: "#fff",
pointHoverBorderColor: "rgb(99, 102, 241)",
pointRadius: 5,
pointHoverRadius: 7,
pointBorderWidth: 2,
},
],
}
})
const options = computed(() => {
const tagData = normalizedData.value
return {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
beginAtZero: true,
max: 100,
min: 0,
ticks: {
stepSize: 20,
backdropColor: "transparent",
callback: function (value: string | number) {
return Number(value) + "%"
},
font: {
size: 11,
},
},
grid: {
color: "rgba(0, 0, 0, 0.1)",
circular: true,
},
angleLines: {
color: "rgba(0, 0, 0, 0.1)",
},
pointLabels: {
font: {
size: 13,
weight: 500 as const,
},
padding: 10,
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
callbacks: {
label: function (context: any) {
const index = context.dataIndex
const actualValue = tagData[index].value
const percentage = Math.round(Number(context.parsed.r))
return `完成 ${actualValue} 道题 (掌握度 ${percentage}%)`
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<n-card title="时间活跃度分析" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
发现最佳学习时段
</n-text>
</template>
<div style="height: 300px">
<Bar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
const TIME_PERIODS = [
{ label: "凌晨(0-6)", start: 0, end: 6 },
{ label: "上午(6-12)", start: 6, end: 12 },
{ label: "下午(12-18)", start: 12, end: 18 },
{ label: "晚上(18-24)", start: 18, end: 24 },
]
// 统计每个星期几和时间段的做题数量
const activityMatrix = computed(() => {
const matrix: { [weekday: number]: { [period: number]: number } } = {}
// 初始化矩阵
for (let i = 0; i < 7; i++) {
matrix[i] = {}
for (let j = 0; j < TIME_PERIODS.length; j++) {
matrix[i][j] = 0
}
}
// 统计数据
aiStore.detailsData.solved.forEach((item) => {
const date = new Date(item.ac_time)
const weekday = date.getDay() // 0-60是周日
const hour = date.getHours() // 0-23
// 找到对应的时间段
const periodIndex = TIME_PERIODS.findIndex(
(p) => hour >= p.start && hour < p.end,
)
if (periodIndex !== -1) {
matrix[weekday][periodIndex]++
}
})
return matrix
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
// 为每个时间段准备数据集
const data = computed(() => {
const datasets = TIME_PERIODS.map((period, periodIndex) => {
return {
label: period.label,
data: WEEKDAYS.map((_, weekday) => activityMatrix.value[weekday][periodIndex]),
backgroundColor: getTimePeriodColor(periodIndex),
borderColor: getTimePeriodColor(periodIndex),
borderWidth: 1,
}
})
return {
labels: WEEKDAYS,
datasets,
}
})
// 根据时间段返回对应的颜色
function getTimePeriodColor(periodIndex: number): string {
const colors = [
"#9D9D9D", // 凌晨 - 灰色
"#FFD700", // 上午 - 金色
"#4ECDC4", // 下午 - 青色
"#5B5F97", // 晚上 - 深蓝紫
]
return colors[periodIndex] || "#999"
}
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index" as const,
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "完成题目数",
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
footer: (items: any[]) => {
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
return `当天总计: ${total}`
},
},
},
},
}
</script>

View File

@@ -50,7 +50,6 @@ export const useAIStore = defineStore("ai", () => {
loading.heatmap = false
}
// 统一获取分析数据details + duration
async function fetchAnalysisData(
start: string,
end: string,

View File

@@ -433,6 +433,7 @@ export interface SolvedProblem {
rank: number
ac_count: number
grade: Grade
difficulty: string
}
export interface DetailsData {