重构 AI
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
# API接口对比分析
|
||||
|
||||
## 更新日志
|
||||
|
||||
### 最近更新(2025-10)
|
||||
- ✨ **AI分析功能增强**:完善了AI智能分析模块的文档说明
|
||||
- 详细说明了4个AI相关接口的功能和参数
|
||||
- 新增等级系统说明(S/A/B/C),包含特殊规则
|
||||
- 补充了时间范围选择功能
|
||||
- 说明了流式响应的实现方式
|
||||
- 前端组件从 `WeeklyChart.vue` 升级为 `DurationChart.vue`(混合图表)
|
||||
- 🔧 **数据缓存优化**:后端AI接口增加了缓存机制,提升性能
|
||||
- 🐛 **修正等级系统说明**:更正了等级阈值(A级:前35%,B级:前75%),并补充了小规模参与惩罚规则
|
||||
|
||||
---
|
||||
|
||||
## 一、前端已使用的API接口
|
||||
|
||||
### 1. 用户认证相关(shared/api.ts)
|
||||
@@ -65,10 +79,23 @@
|
||||
- `GET /api/tutorials` - 获取教程列表
|
||||
|
||||
#### 2.11 AI分析相关
|
||||
- `GET /api/ai/detail` - 获取AI详细数据
|
||||
- `GET /api/ai/weekly` - 获取AI周数据
|
||||
- `GET /api/ai/heatmap` - 获取AI热力图数据
|
||||
- `POST /api/ai/analysis` - AI分析生成(使用fetch直接调用,流式响应)
|
||||
- `GET /api/ai/detail` - 获取用户详细数据
|
||||
- **参数**: start, end(时间范围)
|
||||
- **返回**: 用户等级(S/A/B/C)、已解决题目列表、标签统计、难度统计、参赛次数等
|
||||
- **特点**: 包含班级排名对比,计算每道题的解题排名和等级
|
||||
- `GET /api/ai/duration` - 获取时段数据
|
||||
- **参数**: end(结束时间), duration(时间单位,如 "months:6", "weeks:1")
|
||||
- **返回**: 每周/每月的综合情况(题目数、提交数、等级)
|
||||
- **用途**: 用于绘制时间趋势图,展示学习进度变化
|
||||
- `GET /api/ai/heatmap` - 获取热力图数据
|
||||
- **返回**: 用户的提交热力图数据(按日期统计提交数)
|
||||
- **用途**: 可视化用户活跃度分布
|
||||
- `POST /api/ai/analysis` - AI智能分析生成
|
||||
- **请求体**: details(详细数据), duration(时段数据)
|
||||
- **响应方式**: 流式响应(Server-Sent Events)
|
||||
- **AI提供商**: DeepSeek
|
||||
- **功能**: 根据用户学习数据生成个性化学习建议和鼓励
|
||||
- **实现**: 使用fetch直接调用,在 `oj/store/ai.ts` 中处理流式输出
|
||||
|
||||
### 3. 管理员API(admin/api.ts)
|
||||
#### 3.1 仪表板
|
||||
@@ -278,8 +305,40 @@
|
||||
|
||||
### 特殊说明
|
||||
|
||||
#### 1. 流式接口
|
||||
`POST /api/ai/analysis` 接口使用了**流式响应(Server-Sent Events)**,因此没有在 `oj/api.ts` 中定义封装函数,而是在 `oj/store/ai.ts` 中直接使用 `fetch` API 调用,用于实时流式输出AI生成的分析内容。
|
||||
#### 1. AI智能分析功能 ✨
|
||||
|
||||
**功能概述**: 基于用户的学习数据,使用DeepSeek AI生成个性化的学习分析报告和建议。
|
||||
|
||||
**涉及接口**:
|
||||
- `GET /api/ai/detail` - 获取详细学习数据
|
||||
- `GET /api/ai/duration` - 获取时段趋势数据
|
||||
- `GET /api/ai/heatmap` - 获取活跃度热力图
|
||||
- `POST /api/ai/analysis` - 生成AI分析(流式响应)
|
||||
|
||||
**前端实现**:
|
||||
- **页面**: `src/oj/ai/analysis.vue`
|
||||
- **Store**: `src/oj/store/ai.ts`
|
||||
- **组件**:
|
||||
- `DurationChart.vue` - 混合图表(柱状图+折线图),展示题目数、提交数、等级变化
|
||||
- `Heatmap.vue` - 提交热力图
|
||||
- `Details.vue` - 详细数据展示
|
||||
- `AI.vue` - AI分析结果展示(Markdown格式)
|
||||
|
||||
**时间范围选择**:
|
||||
支持多种时间范围:一节课(1小时)、两节课(2小时)、一天、一周、一个月、两个月、半年、一年
|
||||
|
||||
**等级系统**:
|
||||
- **S级**: 排名前10%(卓越水平,约10%的人)
|
||||
- **A级**: 排名前35%(优秀水平,约25%的人)
|
||||
- **B级**: 排名前75%(良好水平,约40%的人)
|
||||
- **C级**: 75%之后(及格水平,约25%的人)
|
||||
- **特殊规则**: 参与人数少于10人时,S级降为A级,A级降为B级(避免因人少而评级虚高)
|
||||
|
||||
**流式接口实现**:
|
||||
`POST /api/ai/analysis` 使用了**流式响应(Server-Sent Events)**,在 `oj/store/ai.ts` 中直接使用 `fetch` API调用,配合 `consumeJSONEventStream` 工具函数处理流式数据,实现AI内容的实时流式输出。
|
||||
|
||||
**数据缓存**:
|
||||
为提升性能,后端对 `ai/detail` 和 `ai/duration` 接口的返回数据进行了缓存,相同参数的请求会直接返回缓存结果。
|
||||
|
||||
#### 2. ACM 比赛辅助检查功能 ✨
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20">
|
||||
<n-spin :show="aiStore.loading.fetching">
|
||||
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20" :y-gap="20">
|
||||
<n-gi :span="2">
|
||||
<n-flex vertical size="large">
|
||||
<n-flex align="center" justify="space-between">
|
||||
@@ -10,30 +11,50 @@
|
||||
v-model:value="aiStore.duration"
|
||||
/>
|
||||
</n-flex>
|
||||
<Details :start="start" :end="end" />
|
||||
<Overview />
|
||||
<n-grid :cols="2" :x-gap="20">
|
||||
<n-gi :span="1">
|
||||
<TagsChart />
|
||||
</n-gi>
|
||||
<n-gi :span="1">
|
||||
<n-flex vertical :size="20">
|
||||
<DifficultyChart />
|
||||
<GradeChart />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<SolvedTable />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="3">
|
||||
<n-flex vertical size="large">
|
||||
<Heatmap />
|
||||
<WeeklyChart :end="end" />
|
||||
<AI v-if="aiStore.detailsData.solved.length" />
|
||||
<ProgressChart />
|
||||
<DurationChart />
|
||||
<AI v-if="aiStore.detailsData.solved.length >= 10" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="5">
|
||||
<AI v-if="aiStore.detailsData.solved.length > 0 && aiStore.detailsData.solved.length < 10" />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import WeeklyChart from "./components/WeeklyChart.vue"
|
||||
import Details from "./components/Details.vue"
|
||||
import TagsChart from "./components/TagsChart.vue"
|
||||
import DifficultyChart from "./components/DifficultyChart.vue"
|
||||
import GradeChart from "./components/GradeChart.vue"
|
||||
import Overview from "./components/Overview.vue"
|
||||
import Heatmap from "./components/Heatmap.vue"
|
||||
import ProgressChart from "./components/ProgressChart.vue"
|
||||
import DurationChart from "./components/DurationChart.vue"
|
||||
import AI from "./components/AI.vue"
|
||||
import SolvedTable from "./components/SolvedTable.vue"
|
||||
import { useAIStore } from "../store/ai"
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const start = ref("")
|
||||
const end = ref("")
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "一节课内", value: "hours:1" },
|
||||
@@ -54,11 +75,25 @@ const subOptions = computed<Duration>(() => {
|
||||
return { [unit]: parseInt(n) } as Duration
|
||||
})
|
||||
|
||||
function updateRange() {
|
||||
const start = computed(() => {
|
||||
const current = new Date()
|
||||
end.value = formatISO(current)
|
||||
start.value = formatISO(sub(current, subOptions.value))
|
||||
}
|
||||
return formatISO(sub(current, subOptions.value))
|
||||
})
|
||||
|
||||
watch(() => aiStore.duration, updateRange, { immediate: true })
|
||||
const end = computed(() => {
|
||||
return formatISO(new Date())
|
||||
})
|
||||
|
||||
// 获取热力图数据(仅一次)
|
||||
onMounted(() => {
|
||||
aiStore.fetchHeatmapData()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<n-card title="AI 智能分析" size="small">
|
||||
<n-spin :show="aiStore.loading.ai">
|
||||
<div class="container">
|
||||
<MdPreview :model-value="aiStore.mdContent" />
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
@@ -12,9 +14,9 @@ import "md-editor-v3/lib/preview.css"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
watch(
|
||||
() => [aiStore.loading.details, aiStore.loading.weekly],
|
||||
(newVal) => {
|
||||
if (newVal.every((val) => val === false)) {
|
||||
() => aiStore.loading.fetching,
|
||||
(isLoading) => {
|
||||
if (!isLoading) {
|
||||
aiStore.fetchAIAnalysis()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.details">
|
||||
<n-flex vertical size="large">
|
||||
<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>
|
||||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="10" :y-gap="10">
|
||||
<n-gi>
|
||||
<TagsChart :tags="aiStore.detailsData.tags" />
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<DifficultyChart :difficulty="aiStore.detailsData.difficulty" />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-data-table
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
striped
|
||||
:data="aiStore.detailsData.solved"
|
||||
:columns="columns"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import Grade from "./Grade.vue"
|
||||
import TagsChart from "./TagsChart.vue"
|
||||
import DifficultyChart from "./DifficultyChart.vue"
|
||||
import TagTitle from "./TagTitle.vue"
|
||||
import AI from "./AI.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { SolvedProblem } from "utils/types"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
|
||||
const props = defineProps<{
|
||||
start: string
|
||||
end: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
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: "grade",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
]
|
||||
|
||||
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]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchDetailsData(props.start, props.end)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.charming {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="chart" v-if="show">
|
||||
<n-card title="难度统计" size="small" v-if="show">
|
||||
<Bar :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Legend,
|
||||
Colors,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
|
||||
// 仅注册柱状图所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
@@ -27,20 +28,18 @@ ChartJS.register(
|
||||
Colors,
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
difficulty: { [key: string]: number }
|
||||
}>()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const show = computed(() => {
|
||||
return Object.values(props.difficulty).reduce((a, b) => a + b, 0) > 0
|
||||
return Object.values(aiStore.detailsData.difficulty).reduce((a, b) => a + b, 0) > 0
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
return {
|
||||
labels: Object.keys(props.difficulty),
|
||||
labels: Object.keys(aiStore.detailsData.difficulty),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(props.difficulty),
|
||||
data: Object.values(aiStore.detailsData.difficulty),
|
||||
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
|
||||
},
|
||||
],
|
||||
@@ -52,28 +51,20 @@ const options = {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
text: "题目的难度统计",
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.weekly">
|
||||
<n-card :title="title" size="small">
|
||||
<div class="chart">
|
||||
<Chart type="bar" :data="data" :options="options" />
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
@@ -38,56 +38,52 @@ ChartJS.register(
|
||||
LineController,
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
end: string
|
||||
}>()
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
|
||||
const title = computed(() => {
|
||||
if (aiStore.duration === "months:2") {
|
||||
return "过去两个月的每周综合情况一览图"
|
||||
return "过去两个月的每周综合情况"
|
||||
} else if (aiStore.duration === "months:6") {
|
||||
return "过去半年的每月综合情况一览图"
|
||||
return "过去半年的每月综合情况"
|
||||
} else if (aiStore.duration === "years:1") {
|
||||
return "过去一年的每月综合情况一览图"
|
||||
return "过去一年的每月综合情况"
|
||||
} else {
|
||||
return "过去四周的综合情况一览图"
|
||||
return "过去四周的综合情况"
|
||||
}
|
||||
})
|
||||
|
||||
const data = computed<ChartData<"bar" | "line">>(() => {
|
||||
return {
|
||||
labels: aiStore.weeklyData.map((weekly) => {
|
||||
labels: aiStore.durationData.map((duration) => {
|
||||
let prefix = "周"
|
||||
if (weekly.unit === "months") {
|
||||
if (duration.unit === "months") {
|
||||
prefix = "月"
|
||||
}
|
||||
return [
|
||||
parseTime(weekly.start, "M月D日"),
|
||||
parseTime(weekly.end, "M月D日"),
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~")
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
type: "bar",
|
||||
label: "完成题目数量",
|
||||
data: aiStore.weeklyData.map((weekly) => weekly.problem_count),
|
||||
data: aiStore.durationData.map((duration) => duration.problem_count),
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
type: "bar",
|
||||
label: "总提交次数",
|
||||
data: aiStore.weeklyData.map((weekly) => weekly.submission_count),
|
||||
data: aiStore.durationData.map((duration) => duration.submission_count),
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "等级",
|
||||
data: aiStore.weeklyData.map((weekly) =>
|
||||
gradeOrder.indexOf(weekly.grade || "C"),
|
||||
data: aiStore.durationData.map((duration) =>
|
||||
gradeOrder.indexOf(duration.grade || "C"),
|
||||
),
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
@@ -125,11 +121,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
text: title.value,
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
@@ -147,13 +139,6 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchWeeklyData(props.end, aiStore.duration)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
93
src/oj/ai/components/GradeChart.vue
Normal file
93
src/oj/ai/components/GradeChart.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<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,43 +1,270 @@
|
||||
<template>
|
||||
<div class="chart">
|
||||
<n-h1 class="title">过去一年的提交次数热力图</n-h1>
|
||||
<n-heatmap
|
||||
:loading="!data.length"
|
||||
:color-theme="getRandomColorTheme()"
|
||||
size="large"
|
||||
:data="data"
|
||||
:tooltip="{ placement: 'top' }"
|
||||
<n-card title="过去一年的提交热力图" size="small">
|
||||
<n-spin :show="aiStore.loading.heatmap">
|
||||
<div class="heatmap-container" ref="containerRef">
|
||||
<svg
|
||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
class="heatmap-svg"
|
||||
>
|
||||
<template #tooltip="{ timestamp, value }">
|
||||
<div>{{ new Date(timestamp).toLocaleDateString() }}</div>
|
||||
<div>提交次数: {{ value }}</div>
|
||||
</template>
|
||||
</n-heatmap>
|
||||
<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 class="legend">
|
||||
<span>少</span>
|
||||
<div class="legend-colors">
|
||||
<div v-for="(color, i) in COLORS" :key="i" :style="{ backgroundColor: color }" />
|
||||
</div>
|
||||
<span>多</span>
|
||||
</div>
|
||||
|
||||
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
|
||||
<div class="tooltip-date">{{ tooltip.date }}</div>
|
||||
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
|
||||
{{ tooltip.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type HeatmapData } from "naive-ui"
|
||||
import { getAIHeatmapData } from "oj/api"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const data = ref<HeatmapData>([])
|
||||
const aiStore = useAIStore()
|
||||
const containerRef = ref<HTMLElement>()
|
||||
|
||||
function getRandomColorTheme() {
|
||||
const themes = ["green", "blue", "orange", "purple", "red"] as const
|
||||
return themes[Math.floor(Math.random() * themes.length)]
|
||||
}
|
||||
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 LEGEND_HEIGHT = 20
|
||||
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getAIHeatmapData()
|
||||
data.value = res.data
|
||||
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
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
margin: 0 auto;
|
||||
|
||||
const svgWidth = computed(() =>
|
||||
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING
|
||||
)
|
||||
|
||||
const svgHeight = computed(() =>
|
||||
MONTH_HEIGHT + 7 * CELL_TOTAL + LEGEND_HEIGHT
|
||||
)
|
||||
|
||||
interface Cell {
|
||||
date: Date
|
||||
count: number
|
||||
color: string
|
||||
week: number
|
||||
day: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
.title {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.legend-colors {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.legend-colors > div {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%);
|
||||
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
src/oj/ai/components/Overview.vue
Normal file
63
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>
|
||||
259
src/oj/ai/components/ProgressChart.vue
Normal file
259
src/oj/ai/components/ProgressChart.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small" v-if="show">
|
||||
<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(() => {
|
||||
const durationMap: Record<string, string> = {
|
||||
"hours:1": "一节课内",
|
||||
"hours:2": "两节课内",
|
||||
"days:1": "一天内",
|
||||
"weeks:1": "一周内",
|
||||
"months:1": "一个月内",
|
||||
"months:2": "两个月内",
|
||||
"months:6": "半年内",
|
||||
"years:1": "一年内",
|
||||
}
|
||||
const label = durationMap[aiStore.duration] || ""
|
||||
return label ? `${label}做题的进步曲线` : "做题的进步曲线"
|
||||
})
|
||||
|
||||
// 判断是否有数据
|
||||
const show = computed(() => {
|
||||
return aiStore.detailsData.solved.length > 0
|
||||
})
|
||||
|
||||
// 按时间排序的题目列表
|
||||
const sortedProblems = computed(() => {
|
||||
return [...aiStore.detailsData.solved].sort(
|
||||
(a, b) => new Date(a.ac_time).getTime() - new Date(b.ac_time).getTime(),
|
||||
)
|
||||
})
|
||||
|
||||
// 计算累计题目数量和等级趋势
|
||||
const progressData = computed(() => {
|
||||
const problems = sortedProblems.value
|
||||
let cumulativeCount = 0
|
||||
const gradeValues: { [key: string]: number } = { C: 0, B: 0, A: 0, S: 0 }
|
||||
|
||||
return problems.map((problem) => {
|
||||
cumulativeCount++
|
||||
const grade = problem.grade || "C"
|
||||
gradeValues[grade]++
|
||||
|
||||
// 计算平均等级(加权平均)
|
||||
let totalWeight = 0
|
||||
let weightedSum = 0
|
||||
for (const [g, count] of Object.entries(gradeValues)) {
|
||||
totalWeight += count
|
||||
weightedSum += gradeOrder.indexOf(g as Grade) * count
|
||||
}
|
||||
const avgGrade = totalWeight > 0 ? weightedSum / totalWeight : 0
|
||||
|
||||
return {
|
||||
time: parseTime(problem.ac_time, "M/D"),
|
||||
fullTime: parseTime(problem.ac_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
count: cumulativeCount,
|
||||
grade: problem.grade || "C",
|
||||
avgGrade: avgGrade,
|
||||
problem: problem.problem,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 图表数据
|
||||
const data = computed<ChartData<"line">>(() => {
|
||||
const progress = progressData.value
|
||||
|
||||
return {
|
||||
labels: progress.map((p) => p.time),
|
||||
datasets: [
|
||||
{
|
||||
type: "line",
|
||||
label: "累计完成题目",
|
||||
data: progress.map((p) => p.count),
|
||||
borderColor: "#4CAF50",
|
||||
backgroundColor: "rgba(76, 175, 80, 0.1)",
|
||||
tension: 0.3,
|
||||
yAxisID: "y",
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "平均等级",
|
||||
data: progress.map((p) => p.avgGrade),
|
||||
borderColor: "#FF9800",
|
||||
backgroundColor: "rgba(255, 152, 0, 0.1)",
|
||||
tension: 0.3,
|
||||
yAxisID: "y1",
|
||||
fill: false,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
|
||||
pointBorderColor: progress.map((p) => gradeColors[p.grade]),
|
||||
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) => {
|
||||
const idx = Math.round(Number(v))
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (items: TooltipItem<"line">[]) => {
|
||||
if (items.length > 0) {
|
||||
const idx = items[0].dataIndex
|
||||
return progressData.value[idx]?.fullTime || ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
label: (ctx: TooltipItem<"line">) => {
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
const idx = ctx.dataIndex
|
||||
|
||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||
const progress = progressData.value[idx]
|
||||
if (progress) {
|
||||
const avgIdx = Math.round(Number(ctx.parsed.y))
|
||||
return [
|
||||
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
||||
`当前题目等级: ${progress.grade}`,
|
||||
`题目: ${progress.problem.title}`,
|
||||
]
|
||||
}
|
||||
} else {
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
}
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: "top",
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
68
src/oj/ai/components/SolvedTable.vue
Normal file
68
src/oj/ai/components/SolvedTable.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<n-data-table
|
||||
v-if="solvedProblems.length"
|
||||
striped
|
||||
:data="solvedProblems"
|
||||
:columns="columns"
|
||||
:max-height="isDesktop ? 2000 : 500"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import TagTitle from "./TagTitle.vue"
|
||||
import { SolvedProblem } from "utils/types"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
|
||||
const router = useRouter()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const solvedProblems = computed(() => aiStore.detailsData.solved)
|
||||
|
||||
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: "grade",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
]
|
||||
</script>
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="chart" v-if="show">
|
||||
<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"
|
||||
@@ -13,24 +15,27 @@ import {
|
||||
Legend,
|
||||
Colors,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
|
||||
// 仅注册饼图所需的 Chart.js 组件
|
||||
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
|
||||
|
||||
const props = defineProps<{
|
||||
tags: { [key: string]: number }
|
||||
}>()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const show = computed(() => {
|
||||
return Object.keys(props.tags).length > 0
|
||||
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(props.tags),
|
||||
labels: Object.keys(aiStore.detailsData.tags),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(props.tags),
|
||||
data: Object.values(aiStore.detailsData.tags),
|
||||
backgroundColor: [
|
||||
"#FF6384",
|
||||
"#36A2EB",
|
||||
@@ -49,15 +54,6 @@ const options = computed(() => {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
text: `题目的标签分布(前${Object.keys(props.tags).length}个)`,
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -257,8 +257,8 @@ export function getAIDetailData(start: string, end: string) {
|
||||
return http.get("ai/detail", { params: { start, end } })
|
||||
}
|
||||
|
||||
export function getAIWeeklyData(end: string, duration: string) {
|
||||
return http.get("ai/weekly", { params: { end, duration } })
|
||||
export function getAIDurationData(end: string, duration: string) {
|
||||
return http.get("ai/duration", { params: { end, duration } })
|
||||
}
|
||||
|
||||
export function getAIHeatmapData() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DetailsData, WeeklyData } from "utils/types"
|
||||
import { DetailsData, DurationData } from "utils/types"
|
||||
import { consumeJSONEventStream } from "utils/stream"
|
||||
import { getAIDetailData, getAIWeeklyData } from "../api"
|
||||
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api"
|
||||
import { getCSRFToken } from "utils/functions"
|
||||
|
||||
export const useAIStore = defineStore("ai", () => {
|
||||
const duration = ref("months:6")
|
||||
const weeklyData = ref<WeeklyData[]>([])
|
||||
const durationData = ref<DurationData[]>([])
|
||||
const detailsData = reactive<DetailsData>({
|
||||
start: "",
|
||||
end: "",
|
||||
@@ -16,17 +16,17 @@ export const useAIStore = defineStore("ai", () => {
|
||||
contest_count: 0,
|
||||
solved: [],
|
||||
})
|
||||
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||
|
||||
const loading = reactive({
|
||||
details: false,
|
||||
weekly: false,
|
||||
fetching: false, // 合并 details 和 duration 的 loading
|
||||
ai: false,
|
||||
heatmap: false,
|
||||
})
|
||||
|
||||
const mdContent = ref("")
|
||||
|
||||
async function fetchDetailsData(start: string, end: string) {
|
||||
loading.details = true
|
||||
const res = await getAIDetailData(start, end)
|
||||
detailsData.start = res.data.start
|
||||
detailsData.end = res.data.end
|
||||
@@ -36,14 +36,31 @@ export const useAIStore = defineStore("ai", () => {
|
||||
detailsData.tags = res.data.tags
|
||||
detailsData.difficulty = res.data.difficulty
|
||||
detailsData.contest_count = res.data.contest_count
|
||||
loading.details = false
|
||||
}
|
||||
|
||||
async function fetchWeeklyData(end: string, duration: string) {
|
||||
loading.weekly = true
|
||||
const res = await getAIWeeklyData(end, duration)
|
||||
weeklyData.value = res.data
|
||||
loading.weekly = false
|
||||
async function fetchDurationData(end: string, duration: string) {
|
||||
const res = await getAIDurationData(end, duration)
|
||||
durationData.value = res.data
|
||||
}
|
||||
|
||||
async function fetchHeatmapData() {
|
||||
loading.heatmap = true
|
||||
const res = await getAIHeatmapData()
|
||||
heatmapData.value = res.data
|
||||
loading.heatmap = false
|
||||
}
|
||||
|
||||
// 统一获取分析数据(details + duration)
|
||||
async function fetchAnalysisData(start: string, end: string, duration: string) {
|
||||
loading.fetching = true
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchDetailsData(start, end),
|
||||
fetchDurationData(end, duration),
|
||||
])
|
||||
} finally {
|
||||
loading.fetching = false
|
||||
}
|
||||
}
|
||||
|
||||
let aiController: AbortController | null = null
|
||||
@@ -72,7 +89,7 @@ export const useAIStore = defineStore("ai", () => {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
details: detailsData,
|
||||
weekly: weeklyData.value,
|
||||
duration: durationData.value,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -126,11 +143,12 @@ export const useAIStore = defineStore("ai", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
fetchWeeklyData,
|
||||
fetchDetailsData,
|
||||
fetchAnalysisData,
|
||||
fetchHeatmapData,
|
||||
fetchAIAnalysis,
|
||||
weeklyData,
|
||||
durationData,
|
||||
detailsData,
|
||||
heatmapData,
|
||||
duration,
|
||||
loading,
|
||||
mdContent,
|
||||
|
||||
@@ -126,16 +126,9 @@ onMounted(async () => {
|
||||
<template>
|
||||
<n-layout has-sider position="absolute">
|
||||
<!-- 侧边栏 -->
|
||||
<n-layout-sider
|
||||
bordered
|
||||
:width="100"
|
||||
:native-scrollbar="false"
|
||||
>
|
||||
<n-layout-sider bordered :width="100" :native-scrollbar="false">
|
||||
<!-- 菜单 -->
|
||||
<n-menu
|
||||
:options="options"
|
||||
:value="active"
|
||||
/>
|
||||
<n-menu :options="options" :value="active" />
|
||||
</n-layout-sider>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
|
||||
@@ -412,7 +412,7 @@ export interface Tutorial {
|
||||
created_at?: Date
|
||||
}
|
||||
|
||||
export interface WeeklyData {
|
||||
export interface DurationData {
|
||||
unit: string
|
||||
index: number
|
||||
start: string
|
||||
|
||||
Reference in New Issue
Block a user