update
This commit is contained in:
41
docs/图表.md
Normal file
41
docs/图表.md
Normal 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
|
||||
展示内容: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
|
||||
价值: 全面评估进步情况
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
145
src/oj/ai/components/DifficultyGradeChart.vue
Normal file
145
src/oj/ai/components/DifficultyGradeChart.vue
Normal 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>
|
||||
|
||||
@@ -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 ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
236
src/oj/ai/components/EfficiencyChart.vue
Normal file
236
src/oj/ai/components/EfficiencyChart.vue
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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%);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
134
src/oj/ai/components/RankDistributionChart.vue
Normal file
134
src/oj/ai/components/RankDistributionChart.vue
Normal 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>
|
||||
|
||||
178
src/oj/ai/components/StreakStats.vue
Normal file
178
src/oj/ai/components/StreakStats.vue
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
156
src/oj/ai/components/TagsRadarChart.vue
Normal file
156
src/oj/ai/components/TagsRadarChart.vue
Normal 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>
|
||||
153
src/oj/ai/components/TimeActivityHeatmap.vue
Normal file
153
src/oj/ai/components/TimeActivityHeatmap.vue
Normal 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-6,0是周日
|
||||
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>
|
||||
|
||||
@@ -50,7 +50,6 @@ export const useAIStore = defineStore("ai", () => {
|
||||
loading.heatmap = false
|
||||
}
|
||||
|
||||
// 统一获取分析数据(details + duration)
|
||||
async function fetchAnalysisData(
|
||||
start: string,
|
||||
end: string,
|
||||
|
||||
@@ -433,6 +433,7 @@ export interface SolvedProblem {
|
||||
rank: number
|
||||
ac_count: number
|
||||
grade: Grade
|
||||
difficulty: string
|
||||
}
|
||||
|
||||
export interface DetailsData {
|
||||
|
||||
Reference in New Issue
Block a user