This commit is contained in:
2025-10-07 17:03:59 +08:00
parent 6f345611eb
commit 437da9d588
9 changed files with 859 additions and 864 deletions

View File

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

View File

@@ -1,9 +1,7 @@
<template> <template>
<n-card :title="title" size="small"> <n-card :title="title" size="small">
<template #header-extra> <template #header-extra>
<n-text depth="3" style="font-size: 12px"> <n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
全面评估学习情况
</n-text>
</template> </template>
<div class="chart"> <div class="chart">
<Chart type="bar" :data="data" :options="options" /> <Chart type="bar" :data="data" :options="options" />
@@ -173,11 +171,20 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
return `${dsLabel}: ${ctx.formattedValue}` return `${dsLabel}: ${ctx.formattedValue}`
}, },
footer: (items: TooltipItem<"bar">[]) => { footer: (items: TooltipItem<"bar">[]) => {
const barItems = items.filter(item => (item.dataset as any).yAxisID === "y") const barItems = items.filter(
(item) => (item.dataset as any).yAxisID === "y",
)
if (barItems.length >= 2) { if (barItems.length >= 2) {
const problemCount = barItems.find(item => item.dataset.label === "完成题目数")?.parsed.y || 0 const problemCount =
const submissionCount = barItems.find(item => item.dataset.label === "总提交次数")?.parsed.y || 0 barItems.find((item) => item.dataset.label === "完成题目数")
const efficiency = submissionCount > 0 ? ((problemCount / submissionCount) * 100).toFixed(1) : "0" ?.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 `AC率: ${efficiency}%`
} }
return "" return ""

View File

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

View File

@@ -1,9 +1,7 @@
<template> <template>
<n-card title="过去一年的提交热力图" size="small"> <n-card title="过去一年的提交热力图" size="small">
<template #header-extra> <template #header-extra>
<n-text depth="3" style="font-size: 12px"> <n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
激励持续学习
</n-text>
</template> </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">

View File

@@ -1,9 +1,7 @@
<template> <template>
<n-card :title="title" size="small" v-if="show"> <n-card :title="title" size="small" v-if="show">
<template #header-extra> <template #header-extra>
<n-text depth="3" style="font-size: 12px"> <n-text depth="3" style="font-size: 12px">追踪学习成长轨迹</n-text>
追踪学习成长轨迹
</n-text>
</template> </template>
<div class="chart"> <div class="chart">
<Chart type="line" :data="data" :options="options" /> <Chart type="line" :data="data" :options="options" />
@@ -81,13 +79,14 @@ const progressData = computed(() => {
// 计算本期等级的权重值 // 计算本期等级的权重值
const currentGradeValue = gradeOrder.indexOf(duration.grade || "C") const currentGradeValue = gradeOrder.indexOf(duration.grade || "C")
// 累加加权等级 // 累加加权等级
totalWeightedGrade += currentGradeValue * problemCount totalWeightedGrade += currentGradeValue * problemCount
totalProblems += problemCount totalProblems += problemCount
// 计算累计平均等级 // 计算累计平均等级
const avgGradeValue = totalProblems > 0 ? totalWeightedGrade / totalProblems : 0 const avgGradeValue =
totalProblems > 0 ? totalWeightedGrade / totalProblems : 0
return { return {
label: [ label: [

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
<template> <template>
<n-card :title="title" size="small" v-if="show"> <n-card :title="title" size="small" v-if="show">
<template #header-extra> <template #header-extra>
<n-text depth="3" style="font-size: 12px"> <n-text depth="3" style="font-size: 12px">可视化知识点覆盖面</n-text>
可视化知识点覆盖面
</n-text>
</template> </template>
<div class="chart"> <div class="chart">
<Radar :data="data" :options="options" /> <Radar :data="data" :options="options" />

View File

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