feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
91
apps/web/src/oj/ai/components/AI.vue
Normal file
91
apps/web/src/oj/ai/components/AI.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<n-card size="small">
|
||||
<template #header>
|
||||
<div class="cool-title">
|
||||
<span class="title-text">AI 帮你分析</span>
|
||||
</div>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.ai" :delay="50">
|
||||
<n-flex align="center" justify="center" class="container">
|
||||
<n-button
|
||||
v-if="!aiStore.mdContent && !aiStore.loading.ai"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="aiStore.loading.fetching"
|
||||
@click="handleAnalyze"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="ph:sparkle" />
|
||||
</template>
|
||||
开始分析
|
||||
</n-button>
|
||||
<MdPreview v-else :model-value="aiStore.mdContent" />
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (aiStore.loading.fetching || aiStore.loading.ai) {
|
||||
return
|
||||
}
|
||||
if (aiStore.pinnedReport) {
|
||||
await aiStore.simulatePinnedStream()
|
||||
} else {
|
||||
await aiStore.fetchAIAnalysis()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!aiStore.targetUsername) {
|
||||
await aiStore.fetchPinnedReport()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.cool-title {
|
||||
position: relative;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2, #f093fb);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.8px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: gradient-flow 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-flow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
:deep(.md-editor-preview h1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
144
apps/web/src/oj/ai/components/DifficultyGradeChart.vue
Normal file
144
apps/web/src/oj/ai/components/DifficultyGradeChart.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<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>
|
||||
204
apps/web/src/oj/ai/components/DurationChart.vue
Normal file
204
apps/web/src/oj/ai/components/DurationChart.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="bar" :data="data" :options="options" />
|
||||
</div>
|
||||
</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,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
LineController,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
// 注册混合图表(Bar + Line)所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
LineController,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
|
||||
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 data = computed<ChartData<"bar" | "line">>(() => {
|
||||
return {
|
||||
labels: aiStore.durationData.map((duration) => {
|
||||
let prefix = "周"
|
||||
if (duration.unit === "months") {
|
||||
prefix = "月"
|
||||
}
|
||||
return [
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~")
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
type: "bar",
|
||||
label: "完成题目数",
|
||||
data: aiStore.durationData.map((duration) => duration.problem_count),
|
||||
yAxisID: "y",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: "bar",
|
||||
label: "总提交次数",
|
||||
data: aiStore.durationData.map((duration) => duration.submission_count),
|
||||
yAxisID: "y",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "等级",
|
||||
data: aiStore.durationData.map((duration) =>
|
||||
duration.grade ? gradeOrder.indexOf(duration.grade) : null,
|
||||
),
|
||||
spanGaps: false,
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
barThickness: 10,
|
||||
order: 1,
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed<ChartOptions<"bar" | "line">>(() => {
|
||||
return {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "数量",
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
y1: {
|
||||
type: "linear",
|
||||
position: "right",
|
||||
min: -0.5,
|
||||
max: gradeOrder.length - 0.5,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (v) => {
|
||||
const idx = Number(v)
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "等级",
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: TooltipItem<"bar" | "line">) => {
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||
const idx = Number(ctx.parsed.y)
|
||||
return `${dsLabel}: ${gradeOrder[idx] || ""}`
|
||||
}
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
},
|
||||
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||||
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 ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
234
apps/web/src/oj/ai/components/EfficiencyChart.vue
Normal file
234
apps/web/src/oj/ai/components/EfficiencyChart.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<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率:AC题目数 / 总提交次数(越高说明提交质量越好)
|
||||
const onePassRate =
|
||||
submissionCount > 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(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
autoSkip: true,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: "linear" as const,
|
||||
position: "left" as const,
|
||||
title: {
|
||||
display: true,
|
||||
text: "平均提交次数(次/题)",
|
||||
font: {
|
||||
size: 13,
|
||||
},
|
||||
},
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function (value: string | number) {
|
||||
return Number(value).toFixed(1)
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: "linear" as const,
|
||||
position: "right" as const,
|
||||
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)}%`,
|
||||
`提示: AC题目数 / 总提交次数,越高表示提交质量越好`,
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
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>
|
||||
53
apps/web/src/oj/ai/components/Grade.vue
Normal file
53
apps/web/src/oj/ai/components/Grade.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div align="center" style="display: inline-flex; margin: 0 10px">
|
||||
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
|
||||
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
|
||||
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
|
||||
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-icon size="16" style="cursor: help">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
</template>
|
||||
<div style="max-width: 300px; line-height: 1.4">
|
||||
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
|
||||
<div>使用加权平均方法计算综合等级:</div>
|
||||
<div>• S级 = 4分,A级 = 3分,B级 = 2分,C级 = 1分</div>
|
||||
<div>• 根据平均分数确定最终等级:</div>
|
||||
<div>- S级:≥3.5分</div>
|
||||
<div>- A级:2.5-3.5分</div>
|
||||
<div>- B级:1.5-2.5分</div>
|
||||
<div>- C级:<1.5分</div>
|
||||
</div>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
grade: "S" | "A" | "B" | "C"
|
||||
}>()
|
||||
</script>
|
||||
<style scoped>
|
||||
img {
|
||||
animation: shake 0.5s infinite;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
251
apps/web/src/oj/ai/components/Heatmap.vue
Normal file
251
apps/web/src/oj/ai/components/Heatmap.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<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" :delay="50">
|
||||
<div class="heatmap-container" ref="containerRef">
|
||||
<svg
|
||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
class="heatmap-svg"
|
||||
>
|
||||
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
|
||||
<text :x="label.x" :y="10" class="label" font-size="10">
|
||||
{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g v-for="(day, i) in WEEK_DAYS" :key="i">
|
||||
<text
|
||||
:x="0"
|
||||
:y="MONTH_HEIGHT + i * CELL_TOTAL + 8"
|
||||
class="label"
|
||||
font-size="9"
|
||||
>
|
||||
{{ day }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g :transform="`translate(${DAY_WIDTH}, ${MONTH_HEIGHT})`">
|
||||
<rect
|
||||
v-for="(cell, i) in cells"
|
||||
:key="i"
|
||||
:x="cell.x"
|
||||
:y="cell.y"
|
||||
:width="CELL_SIZE"
|
||||
:height="CELL_SIZE"
|
||||
:fill="cell.color"
|
||||
class="cell"
|
||||
rx="2"
|
||||
@mouseenter="(e) => showTooltip(e, cell)"
|
||||
@mouseleave="hideTooltip"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
|
||||
<div class="tooltip-date">{{ tooltip.date }}</div>
|
||||
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
|
||||
{{ tooltip.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
const containerRef = useTemplateRef<HTMLElement>("containerRef")
|
||||
|
||||
const CELL_SIZE = 12
|
||||
const CELL_GAP = 3
|
||||
const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
||||
const DAY_WIDTH = 20
|
||||
const MONTH_HEIGHT = 20
|
||||
const RIGHT_PADDING = 5
|
||||
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||
|
||||
const getColor = (count: number) =>
|
||||
count === 0
|
||||
? COLORS[0]
|
||||
: count <= 2
|
||||
? COLORS[1]
|
||||
: count <= 4
|
||||
? COLORS[2]
|
||||
: count <= 7
|
||||
? COLORS[3]
|
||||
: COLORS[4]
|
||||
|
||||
const cells = computed(() =>
|
||||
aiStore.heatmapData.map((item, i) => ({
|
||||
date: new Date(item.timestamp),
|
||||
count: item.value,
|
||||
color: getColor(item.value),
|
||||
week: Math.floor(i / 7),
|
||||
day: i % 7,
|
||||
x: Math.floor(i / 7) * CELL_TOTAL,
|
||||
y: (i % 7) * CELL_TOTAL,
|
||||
})),
|
||||
)
|
||||
|
||||
const monthLabels = computed(() => {
|
||||
const labels: { text: string; x: number }[] = []
|
||||
let lastMonth = -1
|
||||
|
||||
cells.value.forEach((cell, i) => {
|
||||
const month = cell.date.getMonth()
|
||||
const isWeekStart = cell.date.getDay() === 0 || i === 0
|
||||
|
||||
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
|
||||
labels.push({
|
||||
text: `${month + 1}月`,
|
||||
x: DAY_WIDTH + cell.week * CELL_TOTAL,
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
})
|
||||
|
||||
return labels
|
||||
})
|
||||
|
||||
const svgWidth = computed(
|
||||
() =>
|
||||
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
|
||||
)
|
||||
|
||||
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
|
||||
|
||||
interface Cell {
|
||||
date: Date
|
||||
count: number
|
||||
color: string
|
||||
week: number
|
||||
day: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const tooltip = ref<{
|
||||
x: number
|
||||
y: number
|
||||
date: string
|
||||
text: string
|
||||
count: number
|
||||
} | null>(null)
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltip.value?.x}px`,
|
||||
top: `${tooltip.value?.y}px`,
|
||||
}))
|
||||
|
||||
const getTooltipText = (count: number) =>
|
||||
count === 0 ? "没有提交记录" : `提交了 ${count} 次`
|
||||
|
||||
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
const containerRect = containerRef.value?.getBoundingClientRect()
|
||||
|
||||
if (containerRect) {
|
||||
tooltip.value = {
|
||||
x: rect.left - containerRect.left + rect.width / 2,
|
||||
y: rect.top - containerRect.top - 10,
|
||||
date: parseTime(cell.date, "YYYY年M月D日"),
|
||||
text: getTooltipText(cell.count),
|
||||
count: cell.count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
tooltip.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-container {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.label {
|
||||
fill: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cell {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
stroke: rgba(0, 0, 0, 0.05);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
stroke: rgba(0, 0, 0, 0.3);
|
||||
stroke-width: 1.5;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-date {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tooltip-count {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tooltip-count.active {
|
||||
color: #7bc96f;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-100% - 5px));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
apps/web/src/oj/ai/components/Overview.vue
Normal file
63
apps/web/src/oj/ai/components/Overview.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
>
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>你一共解决 </span>
|
||||
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
|
||||
<span> 道题</span>
|
||||
<span v-if="aiStore.detailsData.contest_count > 0">
|
||||
,并且参加
|
||||
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
|
||||
次比赛
|
||||
</span>
|
||||
<span>,综合评价给到</span>
|
||||
<Grade :grade="aiStore.detailsData.grade" />
|
||||
<span>{{ greeting }}</span>
|
||||
</n-alert>
|
||||
<n-flex vertical size="large" v-else>
|
||||
<n-alert type="error" title="你还没有完成任何题目">
|
||||
开始解题,看看你的学习能力吧!
|
||||
</n-alert>
|
||||
<AI />
|
||||
</n-flex>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import Grade from "./Grade.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import AI from "./AI.vue"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (aiStore.duration.includes("hours")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
|
||||
} else if (aiStore.duration.includes("days")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
|
||||
} else if (
|
||||
aiStore.duration.includes("weeks") ||
|
||||
aiStore.duration.includes("months")
|
||||
) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
|
||||
} else {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
|
||||
}
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
return {
|
||||
S: "要不试试高难度题目?",
|
||||
A: "你很棒,继续保持!",
|
||||
B: "请再接再厉!",
|
||||
C: "你还需要努力!",
|
||||
}[aiStore.detailsData.grade]
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.charming {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
271
apps/web/src/oj/ai/components/ProgressChart.vue
Normal file
271
apps/web/src/oj/ai/components/ProgressChart.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<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,
|
||||
Colors,
|
||||
Filler,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Grade } from "utils/types"
|
||||
|
||||
// 注册折线图所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
Filler,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
const gradeColors: Record<Grade, string> = {
|
||||
C: "#95F204",
|
||||
B: "#36A2EB",
|
||||
A: "#FFCE56",
|
||||
S: "#FF6384",
|
||||
}
|
||||
|
||||
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 progressData = computed(() => {
|
||||
let cumulativeCount = 0
|
||||
let totalWeightedGrade = 0 // 累计加权等级
|
||||
let totalProblems = 0 // 累计题目总数
|
||||
|
||||
return aiStore.durationData.map((duration) => {
|
||||
const problemCount = duration.problem_count || 0
|
||||
cumulativeCount += problemCount
|
||||
|
||||
// 计算本期等级的权重值
|
||||
const currentGradeValue = gradeOrder.indexOf(duration.grade || "C")
|
||||
|
||||
// 累加加权等级
|
||||
totalWeightedGrade += currentGradeValue * problemCount
|
||||
totalProblems += problemCount
|
||||
|
||||
// 计算累计平均等级
|
||||
const avgGradeValue =
|
||||
totalProblems > 0 ? totalWeightedGrade / totalProblems : 0
|
||||
|
||||
return {
|
||||
label: [
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~"),
|
||||
start: parseTime(duration.start, "YYYY-MM-DD"),
|
||||
end: parseTime(duration.end, "YYYY-MM-DD"),
|
||||
count: cumulativeCount,
|
||||
grade: duration.grade || "C",
|
||||
gradeValue: currentGradeValue,
|
||||
avgGradeValue: avgGradeValue, // 累计平均等级
|
||||
problemCount: problemCount,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 图表数据
|
||||
const data = computed<ChartData<"line">>(() => {
|
||||
const progress = progressData.value
|
||||
|
||||
return {
|
||||
labels: progress.map((p) => p.label),
|
||||
datasets: [
|
||||
{
|
||||
type: "line",
|
||||
label: "累计完成题目",
|
||||
data: progress.map((p) => p.count),
|
||||
borderColor: "#4CAF50",
|
||||
backgroundColor: "rgba(76, 175, 80, 0.1)",
|
||||
tension: 0.4,
|
||||
yAxisID: "y",
|
||||
fill: true,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: "#4CAF50",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "累计平均等级",
|
||||
data: progress.map((p) => p.avgGradeValue),
|
||||
borderColor: "#FF9800",
|
||||
backgroundColor: "rgba(255, 152, 0, 0.1)",
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
fill: false,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const options = computed<ChartOptions<"line">>(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 15,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: "linear",
|
||||
position: "left",
|
||||
title: {
|
||||
display: true,
|
||||
text: "累计题目数",
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
y1: {
|
||||
type: "linear",
|
||||
position: "right",
|
||||
min: -0.5,
|
||||
max: gradeOrder.length - 0.5,
|
||||
title: {
|
||||
display: true,
|
||||
text: "累计平均等级",
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (v: string | number) => {
|
||||
const idx = Math.round(Number(v))
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
title: (items: TooltipItem<"line">[]) => {
|
||||
if (items.length > 0) {
|
||||
const idx = items[0].dataIndex
|
||||
const progress = progressData.value[idx]
|
||||
return progress ? `${progress.start} ~ ${progress.end}` : ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
label: (ctx: TooltipItem<"line">) => {
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
const idx = ctx.dataIndex
|
||||
const progress = progressData.value[idx]
|
||||
|
||||
if (!progress) {
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
}
|
||||
|
||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||
// 累计平均等级轴
|
||||
const avgIdx = Math.round(Number(ctx.parsed.y))
|
||||
return [
|
||||
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
||||
`本期等级: ${progress.grade}`,
|
||||
`本期完成: ${progress.problemCount} 题`,
|
||||
]
|
||||
} else {
|
||||
// 累计题目数轴
|
||||
return [
|
||||
`${dsLabel}: ${ctx.formattedValue} 题`,
|
||||
`本期完成: ${progress.problemCount} 题`,
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
132
apps/web/src/oj/ai/components/RankDistributionChart.vue
Normal file
132
apps/web/src/oj/ai/components/RankDistributionChart.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<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.period_rank
|
||||
const acCount = item.period_ac_count
|
||||
|
||||
if (rank && acCount && acCount > 0) {
|
||||
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>
|
||||
154
apps/web/src/oj/ai/components/SolvedTable.vue
Normal file
154
apps/web/src/oj/ai/components/SolvedTable.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<n-tabs animated v-if="submissions.length && flowcharts.length">
|
||||
<n-tab-pane name="代码提交">
|
||||
<n-data-table
|
||||
striped
|
||||
:data="submissions"
|
||||
:columns="columns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="流程图提交">
|
||||
<n-data-table
|
||||
striped
|
||||
:data="flowcharts"
|
||||
:columns="flowchartsColumns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-data-table
|
||||
v-else-if="submissions.length"
|
||||
striped
|
||||
:data="submissions"
|
||||
:columns="columns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
/>
|
||||
<n-data-table
|
||||
v-else-if="flowcharts.length"
|
||||
striped
|
||||
:data="flowcharts"
|
||||
:columns="flowchartsColumns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { NButton, NTooltip } from "naive-ui"
|
||||
import TagTitle from "./TagTitle.vue"
|
||||
import type { FlowchartSummary, SolvedProblem } from "utils/types"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const router = useRouter()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const submissions = computed(() => aiStore.detailsData.solved)
|
||||
const flowcharts = computed(() => aiStore.detailsData.flowcharts)
|
||||
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
title: "完成的题目",
|
||||
key: "problem.title",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.problem.contest_id) {
|
||||
router.push(
|
||||
"/contest/" +
|
||||
row.problem.contest_id +
|
||||
"/problem/" +
|
||||
row.problem.display_id,
|
||||
)
|
||||
} else {
|
||||
router.push("/problem/" + row.problem.display_id)
|
||||
}
|
||||
},
|
||||
},
|
||||
() => {
|
||||
if (row.problem.contest_id) {
|
||||
return h(TagTitle, { problem: row.problem })
|
||||
} else {
|
||||
return row.problem.display_id + " " + row.problem.title
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
|
||||
key: "rank",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (row) => row.rank + " / " + row.ac_count,
|
||||
},
|
||||
{
|
||||
title: "同期排名",
|
||||
key: "period_rank",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (row) => row.period_rank + " / " + row.period_ac_count,
|
||||
},
|
||||
{
|
||||
title: () =>
|
||||
h(NTooltip, null, {
|
||||
trigger: () =>
|
||||
h(
|
||||
"span",
|
||||
{ style: "cursor:help; border-bottom: 1px dashed" },
|
||||
"等级",
|
||||
),
|
||||
default: () =>
|
||||
h("div", null, [
|
||||
h("div", null, "基于同时段排名的百分位:"),
|
||||
h("div", null, "S — 前 10%"),
|
||||
h("div", null, "A — 前 35%"),
|
||||
h("div", null, "B — 前 75%"),
|
||||
h("div", null, "C — 其余"),
|
||||
]),
|
||||
}),
|
||||
key: "grade",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
]
|
||||
|
||||
const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
|
||||
{
|
||||
title: "完成的题目",
|
||||
key: "problem_title",
|
||||
width: 300,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
router.push("/problem/" + row.problem__id)
|
||||
},
|
||||
},
|
||||
() => `${row.problem__id} ${row.problem_title}`,
|
||||
),
|
||||
},
|
||||
{ title: "提交次数", key: "submission_count", width: 100, align: "center" },
|
||||
{
|
||||
title: "最高分",
|
||||
key: "best",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (row) => `${row.best_score} (${row.best_grade})`,
|
||||
},
|
||||
{
|
||||
title: "最新提交时间",
|
||||
key: "latest_submission_time",
|
||||
width: 200,
|
||||
align: "center",
|
||||
render: (row) => parseTime(row.latest_submission_time),
|
||||
},
|
||||
{ title: "平均分", key: "avg_score", width: 100, align: "center" },
|
||||
]
|
||||
</script>
|
||||
177
apps/web/src/oj/ai/components/StreakStats.vue
Normal file
177
apps/web/src/oj/ai/components/StreakStats.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<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" :delay="50">
|
||||
<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>
|
||||
21
apps/web/src/oj/ai/components/TagTitle.vue
Normal file
21
apps/web/src/oj/ai/components/TagTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<n-flex vertical align="start">
|
||||
<n-flex align="center">
|
||||
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
|
||||
<span>{{ problem.contest_title }}</span>
|
||||
</n-flex>
|
||||
<span>{{ problem.display_id }} {{ problem.title }}</span>
|
||||
</n-flex>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
problem: {
|
||||
title: string
|
||||
display_id: string
|
||||
contest_title: string
|
||||
contest_id: number
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
154
apps/web/src/oj/ai/components/TagsRadarChart.vue
Normal file
154
apps/web/src/oj/ai/components/TagsRadarChart.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<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>
|
||||
154
apps/web/src/oj/ai/components/TimeActivityHeatmap.vue
Normal file
154
apps/web/src/oj/ai/components/TimeActivityHeatmap.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<n-card title="时间活跃度分析" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
基于 AC 时间,发现解题高峰时段
|
||||
</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>
|
||||
Reference in New Issue
Block a user