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>
|
</n-flex>
|
||||||
<Overview />
|
<Overview />
|
||||||
<n-grid :cols="2" :x-gap="20">
|
<n-grid :cols="2" :x-gap="20" :y-gap="20">
|
||||||
<n-gi :span="1">
|
<n-gi :span="isDesktop ? 1 : 2">
|
||||||
<TagsChart />
|
<DifficultyGradeChart />
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi :span="1">
|
<n-gi :span="isDesktop ? 1 : 2">
|
||||||
<n-flex vertical :size="20">
|
<TagsRadarChart />
|
||||||
<DifficultyChart />
|
</n-gi>
|
||||||
<GradeChart />
|
<n-gi :span="isDesktop ? 1 : 2">
|
||||||
</n-flex>
|
<RankDistributionChart />
|
||||||
|
</n-gi>
|
||||||
|
<n-gi :span="isDesktop ? 1 : 2">
|
||||||
|
<TimeActivityHeatmap />
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
<SolvedTable />
|
<SolvedTable />
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
<n-flex vertical size="large">
|
<n-flex vertical size="large">
|
||||||
<Heatmap />
|
<Heatmap />
|
||||||
<ProgressChart />
|
<ProgressChart />
|
||||||
|
<EfficiencyChart />
|
||||||
<DurationChart />
|
<DurationChart />
|
||||||
<AI v-if="aiStore.detailsData.solved.length >= 10" />
|
<AI v-if="aiStore.detailsData.solved.length >= 10" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -48,13 +52,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { formatISO, sub, type Duration } from "date-fns"
|
import { formatISO, sub, type Duration } from "date-fns"
|
||||||
import TagsChart from "./components/TagsChart.vue"
|
import TagsRadarChart from "./components/TagsRadarChart.vue"
|
||||||
import DifficultyChart from "./components/DifficultyChart.vue"
|
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
|
||||||
import GradeChart from "./components/GradeChart.vue"
|
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
|
||||||
|
import RankDistributionChart from "./components/RankDistributionChart.vue"
|
||||||
import Overview from "./components/Overview.vue"
|
import Overview from "./components/Overview.vue"
|
||||||
import Heatmap from "./components/Heatmap.vue"
|
import Heatmap from "./components/Heatmap.vue"
|
||||||
import ProgressChart from "./components/ProgressChart.vue"
|
import ProgressChart from "./components/ProgressChart.vue"
|
||||||
import DurationChart from "./components/DurationChart.vue"
|
import DurationChart from "./components/DurationChart.vue"
|
||||||
|
import EfficiencyChart from "./components/EfficiencyChart.vue"
|
||||||
import AI from "./components/AI.vue"
|
import AI from "./components/AI.vue"
|
||||||
import SolvedTable from "./components/SolvedTable.vue"
|
import SolvedTable from "./components/SolvedTable.vue"
|
||||||
import { useAIStore } from "../store/ai"
|
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>
|
<template>
|
||||||
<n-card :title="title" size="small">
|
<n-card :title="title" size="small">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-text depth="3" style="font-size: 12px">
|
||||||
|
全面评估学习情况
|
||||||
|
</n-text>
|
||||||
|
</template>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<Chart type="bar" :data="data" :options="options" />
|
<Chart type="bar" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
@@ -69,15 +74,17 @@ const data = computed<ChartData<"bar" | "line">>(() => {
|
|||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
type: "bar",
|
type: "bar",
|
||||||
label: "完成题目数量",
|
label: "完成题目数",
|
||||||
data: aiStore.durationData.map((duration) => duration.problem_count),
|
data: aiStore.durationData.map((duration) => duration.problem_count),
|
||||||
yAxisID: "y",
|
yAxisID: "y",
|
||||||
|
order: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "bar",
|
type: "bar",
|
||||||
label: "总提交次数",
|
label: "总提交次数",
|
||||||
data: aiStore.durationData.map((duration) => duration.submission_count),
|
data: aiStore.durationData.map((duration) => duration.submission_count),
|
||||||
yAxisID: "y",
|
yAxisID: "y",
|
||||||
|
order: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "line",
|
type: "line",
|
||||||
@@ -88,6 +95,10 @@ const data = computed<ChartData<"bar" | "line">>(() => {
|
|||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
yAxisID: "y1",
|
yAxisID: "y1",
|
||||||
barThickness: 10,
|
barThickness: 10,
|
||||||
|
order: 1,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -100,16 +111,26 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
},
|
},
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
scales: {
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
y: {
|
y: {
|
||||||
ticks: {
|
ticks: {
|
||||||
stepSize: 1,
|
stepSize: 1,
|
||||||
},
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "数量",
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
type: "linear",
|
type: "linear",
|
||||||
position: "right",
|
position: "right",
|
||||||
min: -1,
|
min: -0.5,
|
||||||
max: gradeOrder.length,
|
max: gradeOrder.length - 0.5,
|
||||||
ticks: {
|
ticks: {
|
||||||
stepSize: 1,
|
stepSize: 1,
|
||||||
callback: (v) => {
|
callback: (v) => {
|
||||||
@@ -117,9 +138,27 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
return gradeOrder[idx] || ""
|
return gradeOrder[idx] || ""
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "等级",
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: "bottom" as const,
|
||||||
|
labels: {
|
||||||
|
boxWidth: 12,
|
||||||
|
padding: 8,
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
@@ -133,6 +172,16 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
}
|
}
|
||||||
return `${dsLabel}: ${ctx.formattedValue}`
|
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>
|
<template>
|
||||||
<n-card title="过去一年的提交热力图" size="small">
|
<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-spin :show="aiStore.loading.heatmap">
|
||||||
<div class="heatmap-container" ref="containerRef">
|
<div class="heatmap-container" ref="containerRef">
|
||||||
<svg
|
<svg
|
||||||
@@ -41,18 +46,6 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</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 v-if="tooltip" class="tooltip" :style="tooltipStyle">
|
||||||
<div class="tooltip-date">{{ tooltip.date }}</div>
|
<div class="tooltip-date">{{ tooltip.date }}</div>
|
||||||
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
|
<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 DAY_WIDTH = 20
|
||||||
const MONTH_HEIGHT = 20
|
const MONTH_HEIGHT = 20
|
||||||
const RIGHT_PADDING = 5
|
const RIGHT_PADDING = 5
|
||||||
const LEGEND_HEIGHT = 20
|
|
||||||
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||||
|
|
||||||
@@ -129,7 +121,7 @@ const svgWidth = computed(
|
|||||||
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
|
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 {
|
interface Cell {
|
||||||
date: Date
|
date: Date
|
||||||
@@ -208,28 +200,6 @@ const hideTooltip = () => {
|
|||||||
filter: brightness(0.9);
|
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 {
|
.tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translate(-50%, -100%);
|
transform: translate(-50%, -100%);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-card :title="title" size="small" v-if="show">
|
<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">
|
<div class="chart">
|
||||||
<Chart type="line" :data="data" :options="options" />
|
<Chart type="line" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +28,6 @@ import {
|
|||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { Grade } from "utils/types"
|
import type { Grade } from "utils/types"
|
||||||
import { DURATION_OPTIONS } from "utils/constants"
|
|
||||||
|
|
||||||
// 注册折线图所需的 Chart.js 组件
|
// 注册折线图所需的 Chart.js 组件
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -49,49 +53,54 @@ const gradeColors: Record<Grade, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
const option = DURATION_OPTIONS.find((opt) => opt.value === aiStore.duration)
|
if (aiStore.duration === "months:2") {
|
||||||
return option ? `${option.label}的进步曲线` : "进步曲线"
|
return "过去两个月的进步曲线"
|
||||||
|
} else if (aiStore.duration === "months:6") {
|
||||||
|
return "过去半年的进步曲线"
|
||||||
|
} else if (aiStore.duration === "years:1") {
|
||||||
|
return "过去一年的进步曲线"
|
||||||
|
} else {
|
||||||
|
return "过去四周的进步曲线"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断是否有数据
|
// 判断是否有数据
|
||||||
const show = computed(() => {
|
const show = computed(() => {
|
||||||
return aiStore.detailsData.solved.length > 0
|
return aiStore.durationData.length > 0
|
||||||
})
|
|
||||||
|
|
||||||
// 按时间排序的题目列表
|
|
||||||
const sortedProblems = computed(() => {
|
|
||||||
return [...aiStore.detailsData.solved].sort(
|
|
||||||
(a, b) => new Date(a.ac_time).getTime() - new Date(b.ac_time).getTime(),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算累计题目数量和等级趋势
|
// 计算累计题目数量和等级趋势
|
||||||
const progressData = computed(() => {
|
const progressData = computed(() => {
|
||||||
const problems = sortedProblems.value
|
|
||||||
let cumulativeCount = 0
|
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) => {
|
return aiStore.durationData.map((duration) => {
|
||||||
cumulativeCount++
|
const problemCount = duration.problem_count || 0
|
||||||
const grade = problem.grade || "C"
|
cumulativeCount += problemCount
|
||||||
gradeValues[grade]++
|
|
||||||
|
|
||||||
// 计算平均等级(加权平均)
|
// 计算本期等级的权重值
|
||||||
let totalWeight = 0
|
const currentGradeValue = gradeOrder.indexOf(duration.grade || "C")
|
||||||
let weightedSum = 0
|
|
||||||
for (const [g, count] of Object.entries(gradeValues)) {
|
// 累加加权等级
|
||||||
totalWeight += count
|
totalWeightedGrade += currentGradeValue * problemCount
|
||||||
weightedSum += gradeOrder.indexOf(g as Grade) * count
|
totalProblems += problemCount
|
||||||
}
|
|
||||||
const avgGrade = totalWeight > 0 ? weightedSum / totalWeight : 0
|
// 计算累计平均等级
|
||||||
|
const avgGradeValue = totalProblems > 0 ? totalWeightedGrade / totalProblems : 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
time: parseTime(problem.ac_time, "M/D"),
|
label: [
|
||||||
fullTime: parseTime(problem.ac_time, "YYYY-MM-DD HH:mm:ss"),
|
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,
|
count: cumulativeCount,
|
||||||
grade: problem.grade || "C",
|
grade: duration.grade || "C",
|
||||||
avgGrade: avgGrade,
|
gradeValue: currentGradeValue,
|
||||||
problem: problem.problem,
|
avgGradeValue: avgGradeValue, // 累计平均等级
|
||||||
|
problemCount: problemCount,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -101,7 +110,7 @@ const data = computed<ChartData<"line">>(() => {
|
|||||||
const progress = progressData.value
|
const progress = progressData.value
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: progress.map((p) => p.time),
|
labels: progress.map((p) => p.label),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
type: "line",
|
type: "line",
|
||||||
@@ -109,27 +118,30 @@ const data = computed<ChartData<"line">>(() => {
|
|||||||
data: progress.map((p) => p.count),
|
data: progress.map((p) => p.count),
|
||||||
borderColor: "#4CAF50",
|
borderColor: "#4CAF50",
|
||||||
backgroundColor: "rgba(76, 175, 80, 0.1)",
|
backgroundColor: "rgba(76, 175, 80, 0.1)",
|
||||||
tension: 0.3,
|
tension: 0.4,
|
||||||
yAxisID: "y",
|
yAxisID: "y",
|
||||||
fill: true,
|
fill: true,
|
||||||
pointRadius: 4,
|
pointRadius: 5,
|
||||||
pointHoverRadius: 6,
|
pointHoverRadius: 7,
|
||||||
borderWidth: 2,
|
borderWidth: 2.5,
|
||||||
|
pointBackgroundColor: "#4CAF50",
|
||||||
|
pointBorderColor: "#fff",
|
||||||
|
pointBorderWidth: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "line",
|
type: "line",
|
||||||
label: "平均等级",
|
label: "累计平均等级",
|
||||||
data: progress.map((p) => p.avgGrade),
|
data: progress.map((p) => p.avgGradeValue),
|
||||||
borderColor: "#FF9800",
|
borderColor: "#FF9800",
|
||||||
backgroundColor: "rgba(255, 152, 0, 0.1)",
|
backgroundColor: "rgba(255, 152, 0, 0.1)",
|
||||||
tension: 0.3,
|
tension: 0.4,
|
||||||
yAxisID: "y1",
|
yAxisID: "y1",
|
||||||
fill: false,
|
fill: false,
|
||||||
pointRadius: 4,
|
pointRadius: 5,
|
||||||
pointHoverRadius: 6,
|
pointHoverRadius: 7,
|
||||||
borderWidth: 2,
|
borderWidth: 2.5,
|
||||||
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
|
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
|
||||||
pointBorderColor: progress.map((p) => gradeColors[p.grade]),
|
pointBorderColor: "#fff",
|
||||||
pointBorderWidth: 2,
|
pointBorderWidth: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -176,14 +188,14 @@ const options = computed<ChartOptions<"line">>(() => {
|
|||||||
max: gradeOrder.length - 0.5,
|
max: gradeOrder.length - 0.5,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: "平均等级",
|
text: "累计平均等级",
|
||||||
font: {
|
font: {
|
||||||
size: 14,
|
size: 14,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
stepSize: 1,
|
stepSize: 1,
|
||||||
callback: (v) => {
|
callback: (v: string | number) => {
|
||||||
const idx = Math.round(Number(v))
|
const idx = Math.round(Number(v))
|
||||||
return gradeOrder[idx] || ""
|
return gradeOrder[idx] || ""
|
||||||
},
|
},
|
||||||
@@ -198,41 +210,51 @@ const options = computed<ChartOptions<"line">>(() => {
|
|||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||||
|
padding: 12,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
title: (items: TooltipItem<"line">[]) => {
|
title: (items: TooltipItem<"line">[]) => {
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
const idx = items[0].dataIndex
|
const idx = items[0].dataIndex
|
||||||
return progressData.value[idx]?.fullTime || ""
|
const progress = progressData.value[idx]
|
||||||
|
return progress ? `${progress.start} ~ ${progress.end}` : ""
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
},
|
},
|
||||||
label: (ctx: TooltipItem<"line">) => {
|
label: (ctx: TooltipItem<"line">) => {
|
||||||
const dsLabel = ctx.dataset.label || ""
|
const dsLabel = ctx.dataset.label || ""
|
||||||
const idx = ctx.dataIndex
|
const idx = ctx.dataIndex
|
||||||
|
const progress = progressData.value[idx]
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
return `${dsLabel}: ${ctx.formattedValue}`
|
||||||
|
}
|
||||||
|
|
||||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||||
const progress = progressData.value[idx]
|
// 累计平均等级轴
|
||||||
if (progress) {
|
|
||||||
const avgIdx = Math.round(Number(ctx.parsed.y))
|
const avgIdx = Math.round(Number(ctx.parsed.y))
|
||||||
return [
|
return [
|
||||||
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
||||||
`当前题目等级: ${progress.grade}`,
|
`本期等级: ${progress.grade}`,
|
||||||
`题目: ${progress.problem.title}`,
|
`本期完成: ${progress.problemCount} 题`,
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
// 累计题目数轴
|
||||||
|
return [
|
||||||
|
`${dsLabel}: ${ctx.formattedValue} 题`,
|
||||||
|
`本期完成: ${progress.problemCount} 题`,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return `${dsLabel}: ${ctx.formattedValue}`
|
|
||||||
}
|
|
||||||
return `${dsLabel}: ${ctx.formattedValue}`
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: true,
|
||||||
position: "top",
|
position: "bottom" as const,
|
||||||
labels: {
|
labels: {
|
||||||
usePointStyle: true,
|
boxWidth: 12,
|
||||||
padding: 15,
|
boxHeight: 12,
|
||||||
|
padding: 8,
|
||||||
font: {
|
font: {
|
||||||
size: 12,
|
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
|
loading.heatmap = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一获取分析数据(details + duration)
|
|
||||||
async function fetchAnalysisData(
|
async function fetchAnalysisData(
|
||||||
start: string,
|
start: string,
|
||||||
end: string,
|
end: string,
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ export interface SolvedProblem {
|
|||||||
rank: number
|
rank: number
|
||||||
ac_count: number
|
ac_count: number
|
||||||
grade: Grade
|
grade: Grade
|
||||||
|
difficulty: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetailsData {
|
export interface DetailsData {
|
||||||
|
|||||||
Reference in New Issue
Block a user