重构 AI
This commit is contained in:
@@ -1,5 +1,19 @@
|
|||||||
# API接口对比分析
|
# API接口对比分析
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### 最近更新(2025-10)
|
||||||
|
- ✨ **AI分析功能增强**:完善了AI智能分析模块的文档说明
|
||||||
|
- 详细说明了4个AI相关接口的功能和参数
|
||||||
|
- 新增等级系统说明(S/A/B/C),包含特殊规则
|
||||||
|
- 补充了时间范围选择功能
|
||||||
|
- 说明了流式响应的实现方式
|
||||||
|
- 前端组件从 `WeeklyChart.vue` 升级为 `DurationChart.vue`(混合图表)
|
||||||
|
- 🔧 **数据缓存优化**:后端AI接口增加了缓存机制,提升性能
|
||||||
|
- 🐛 **修正等级系统说明**:更正了等级阈值(A级:前35%,B级:前75%),并补充了小规模参与惩罚规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 一、前端已使用的API接口
|
## 一、前端已使用的API接口
|
||||||
|
|
||||||
### 1. 用户认证相关(shared/api.ts)
|
### 1. 用户认证相关(shared/api.ts)
|
||||||
@@ -65,10 +79,23 @@
|
|||||||
- `GET /api/tutorials` - 获取教程列表
|
- `GET /api/tutorials` - 获取教程列表
|
||||||
|
|
||||||
#### 2.11 AI分析相关
|
#### 2.11 AI分析相关
|
||||||
- `GET /api/ai/detail` - 获取AI详细数据
|
- `GET /api/ai/detail` - 获取用户详细数据
|
||||||
- `GET /api/ai/weekly` - 获取AI周数据
|
- **参数**: start, end(时间范围)
|
||||||
- `GET /api/ai/heatmap` - 获取AI热力图数据
|
- **返回**: 用户等级(S/A/B/C)、已解决题目列表、标签统计、难度统计、参赛次数等
|
||||||
- `POST /api/ai/analysis` - AI分析生成(使用fetch直接调用,流式响应)
|
- **特点**: 包含班级排名对比,计算每道题的解题排名和等级
|
||||||
|
- `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. 管理员API(admin/api.ts)
|
||||||
#### 3.1 仪表板
|
#### 3.1 仪表板
|
||||||
@@ -278,8 +305,40 @@
|
|||||||
|
|
||||||
### 特殊说明
|
### 特殊说明
|
||||||
|
|
||||||
#### 1. 流式接口
|
#### 1. AI智能分析功能 ✨
|
||||||
`POST /api/ai/analysis` 接口使用了**流式响应(Server-Sent Events)**,因此没有在 `oj/api.ts` 中定义封装函数,而是在 `oj/store/ai.ts` 中直接使用 `fetch` API 调用,用于实时流式输出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 比赛辅助检查功能 ✨
|
#### 2. ACM 比赛辅助检查功能 ✨
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20">
|
<n-spin :show="aiStore.loading.fetching">
|
||||||
<n-gi :span="2">
|
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20" :y-gap="20">
|
||||||
<n-flex vertical size="large">
|
<n-gi :span="2">
|
||||||
<n-flex align="center" justify="space-between">
|
<n-flex vertical size="large">
|
||||||
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
<n-flex align="center" justify="space-between">
|
||||||
<n-select
|
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
||||||
style="width: 140px"
|
<n-select
|
||||||
:options="options"
|
style="width: 140px"
|
||||||
v-model:value="aiStore.duration"
|
:options="options"
|
||||||
/>
|
v-model:value="aiStore.duration"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
<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-flex>
|
||||||
<Details :start="start" :end="end" />
|
</n-gi>
|
||||||
</n-flex>
|
<n-gi :span="3">
|
||||||
</n-gi>
|
<n-flex vertical size="large">
|
||||||
<n-gi :span="3">
|
<Heatmap />
|
||||||
<n-flex vertical size="large">
|
<ProgressChart />
|
||||||
<Heatmap />
|
<DurationChart />
|
||||||
<WeeklyChart :end="end" />
|
<AI v-if="aiStore.detailsData.solved.length >= 10" />
|
||||||
<AI v-if="aiStore.detailsData.solved.length" />
|
</n-flex>
|
||||||
</n-flex>
|
</n-gi>
|
||||||
</n-gi>
|
<n-gi :span="5">
|
||||||
</n-grid>
|
<AI v-if="aiStore.detailsData.solved.length > 0 && aiStore.detailsData.solved.length < 10" />
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-spin>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { formatISO, sub, type Duration } from "date-fns"
|
import { formatISO, sub, type Duration } from "date-fns"
|
||||||
import WeeklyChart from "./components/WeeklyChart.vue"
|
import TagsChart from "./components/TagsChart.vue"
|
||||||
import Details from "./components/Details.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 Heatmap from "./components/Heatmap.vue"
|
||||||
|
import ProgressChart from "./components/ProgressChart.vue"
|
||||||
|
import DurationChart from "./components/DurationChart.vue"
|
||||||
import AI from "./components/AI.vue"
|
import AI from "./components/AI.vue"
|
||||||
|
import SolvedTable from "./components/SolvedTable.vue"
|
||||||
import { useAIStore } from "../store/ai"
|
import { useAIStore } from "../store/ai"
|
||||||
const aiStore = useAIStore()
|
|
||||||
|
|
||||||
const start = ref("")
|
const aiStore = useAIStore()
|
||||||
const end = ref("")
|
|
||||||
|
|
||||||
const options: SelectOption[] = [
|
const options: SelectOption[] = [
|
||||||
{ label: "一节课内", value: "hours:1" },
|
{ label: "一节课内", value: "hours:1" },
|
||||||
@@ -54,11 +75,25 @@ const subOptions = computed<Duration>(() => {
|
|||||||
return { [unit]: parseInt(n) } as Duration
|
return { [unit]: parseInt(n) } as Duration
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateRange() {
|
const start = computed(() => {
|
||||||
const current = new Date()
|
const current = new Date()
|
||||||
end.value = formatISO(current)
|
return formatISO(sub(current, subOptions.value))
|
||||||
start.value = 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>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-spin :show="aiStore.loading.ai">
|
<n-card title="AI 智能分析" size="small">
|
||||||
<div class="container">
|
<n-spin :show="aiStore.loading.ai">
|
||||||
<MdPreview :model-value="aiStore.mdContent" />
|
<div class="container">
|
||||||
</div>
|
<MdPreview :model-value="aiStore.mdContent" />
|
||||||
</n-spin>
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
@@ -12,9 +14,9 @@ import "md-editor-v3/lib/preview.css"
|
|||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
watch(
|
watch(
|
||||||
() => [aiStore.loading.details, aiStore.loading.weekly],
|
() => aiStore.loading.fetching,
|
||||||
(newVal) => {
|
(isLoading) => {
|
||||||
if (newVal.every((val) => val === false)) {
|
if (!isLoading) {
|
||||||
aiStore.fetchAIAnalysis()
|
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>
|
<template>
|
||||||
<div class="chart" v-if="show">
|
<n-card title="难度统计" size="small" v-if="show">
|
||||||
<Bar :data="data" :options="options" />
|
<Bar :data="data" :options="options" />
|
||||||
</div>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Bar } from "vue-chartjs"
|
import { Bar } from "vue-chartjs"
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
Colors,
|
Colors,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
|
||||||
// 仅注册柱状图所需的 Chart.js 组件
|
// 仅注册柱状图所需的 Chart.js 组件
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -27,20 +28,18 @@ ChartJS.register(
|
|||||||
Colors,
|
Colors,
|
||||||
)
|
)
|
||||||
|
|
||||||
const props = defineProps<{
|
const aiStore = useAIStore()
|
||||||
difficulty: { [key: string]: number }
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const show = computed(() => {
|
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(() => {
|
const data = computed(() => {
|
||||||
return {
|
return {
|
||||||
labels: Object.keys(props.difficulty),
|
labels: Object.keys(aiStore.detailsData.difficulty),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: Object.values(props.difficulty),
|
data: Object.values(aiStore.detailsData.difficulty),
|
||||||
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
|
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -52,9 +51,11 @@ const options = {
|
|||||||
intersect: false,
|
intersect: false,
|
||||||
},
|
},
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
y: {
|
scales: {
|
||||||
ticks: {
|
y: {
|
||||||
stepSize: 1,
|
ticks: {
|
||||||
|
stepSize: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -62,18 +63,8 @@ const options = {
|
|||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
text: "题目的难度统计",
|
display: false,
|
||||||
display: true,
|
|
||||||
font: {
|
|
||||||
size: 20,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
|
||||||
.chart {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-spin :show="aiStore.loading.weekly">
|
<n-card :title="title" size="small">
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<Chart type="bar" :data="data" :options="options" />
|
<Chart type="bar" :data="data" :options="options" />
|
||||||
</div>
|
</div>
|
||||||
</n-spin>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||||
@@ -38,56 +38,52 @@ ChartJS.register(
|
|||||||
LineController,
|
LineController,
|
||||||
)
|
)
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
end: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
|
||||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (aiStore.duration === "months:2") {
|
if (aiStore.duration === "months:2") {
|
||||||
return "过去两个月的每周综合情况一览图"
|
return "过去两个月的每周综合情况"
|
||||||
} else if (aiStore.duration === "months:6") {
|
} else if (aiStore.duration === "months:6") {
|
||||||
return "过去半年的每月综合情况一览图"
|
return "过去半年的每月综合情况"
|
||||||
} else if (aiStore.duration === "years:1") {
|
} else if (aiStore.duration === "years:1") {
|
||||||
return "过去一年的每月综合情况一览图"
|
return "过去一年的每月综合情况"
|
||||||
} else {
|
} else {
|
||||||
return "过去四周的综合情况一览图"
|
return "过去四周的综合情况"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = computed<ChartData<"bar" | "line">>(() => {
|
const data = computed<ChartData<"bar" | "line">>(() => {
|
||||||
return {
|
return {
|
||||||
labels: aiStore.weeklyData.map((weekly) => {
|
labels: aiStore.durationData.map((duration) => {
|
||||||
let prefix = "周"
|
let prefix = "周"
|
||||||
if (weekly.unit === "months") {
|
if (duration.unit === "months") {
|
||||||
prefix = "月"
|
prefix = "月"
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
parseTime(weekly.start, "M月D日"),
|
parseTime(duration.start, "M月D日"),
|
||||||
parseTime(weekly.end, "M月D日"),
|
parseTime(duration.end, "M月D日"),
|
||||||
].join("~")
|
].join("~")
|
||||||
}),
|
}),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
type: "bar",
|
type: "bar",
|
||||||
label: "完成题目数量",
|
label: "完成题目数量",
|
||||||
data: aiStore.weeklyData.map((weekly) => weekly.problem_count),
|
data: aiStore.durationData.map((duration) => duration.problem_count),
|
||||||
yAxisID: "y",
|
yAxisID: "y",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "bar",
|
type: "bar",
|
||||||
label: "总提交次数",
|
label: "总提交次数",
|
||||||
data: aiStore.weeklyData.map((weekly) => weekly.submission_count),
|
data: aiStore.durationData.map((duration) => duration.submission_count),
|
||||||
yAxisID: "y",
|
yAxisID: "y",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "line",
|
type: "line",
|
||||||
label: "等级",
|
label: "等级",
|
||||||
data: aiStore.weeklyData.map((weekly) =>
|
data: aiStore.durationData.map((duration) =>
|
||||||
gradeOrder.indexOf(weekly.grade || "C"),
|
gradeOrder.indexOf(duration.grade || "C"),
|
||||||
),
|
),
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
yAxisID: "y1",
|
yAxisID: "y1",
|
||||||
@@ -125,11 +121,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: {
|
||||||
text: title.value,
|
display: false,
|
||||||
display: true,
|
|
||||||
font: {
|
|
||||||
size: 20,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -147,17 +139,10 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
() => aiStore.duration,
|
|
||||||
() => {
|
|
||||||
aiStore.fetchWeeklyData(props.end, aiStore.duration)
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chart {
|
.chart {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
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>
|
<template>
|
||||||
<div class="chart">
|
<n-card title="过去一年的提交热力图" size="small">
|
||||||
<n-h1 class="title">过去一年的提交次数热力图</n-h1>
|
<n-spin :show="aiStore.loading.heatmap">
|
||||||
<n-heatmap
|
<div class="heatmap-container" ref="containerRef">
|
||||||
:loading="!data.length"
|
<svg
|
||||||
:color-theme="getRandomColorTheme()"
|
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||||
size="large"
|
preserveAspectRatio="xMinYMin meet"
|
||||||
:data="data"
|
class="heatmap-svg"
|
||||||
:tooltip="{ placement: 'top' }"
|
>
|
||||||
>
|
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
|
||||||
<template #tooltip="{ timestamp, value }">
|
<text :x="label.x" :y="10" class="label" font-size="10">
|
||||||
<div>{{ new Date(timestamp).toLocaleDateString() }}</div>
|
{{ label.text }}
|
||||||
<div>提交次数: {{ value }}</div>
|
</text>
|
||||||
</template>
|
</g>
|
||||||
</n-heatmap>
|
|
||||||
</div>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type HeatmapData } from "naive-ui"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { getAIHeatmapData } from "oj/api"
|
import { parseTime } from "utils/functions"
|
||||||
|
|
||||||
const data = ref<HeatmapData>([])
|
const aiStore = useAIStore()
|
||||||
|
const containerRef = ref<HTMLElement>()
|
||||||
|
|
||||||
function getRandomColorTheme() {
|
const CELL_SIZE = 12
|
||||||
const themes = ["green", "blue", "orange", "purple", "red"] as const
|
const CELL_GAP = 3
|
||||||
return themes[Math.floor(Math.random() * themes.length)]
|
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 getColor = (count: number) =>
|
||||||
const res = await getAIHeatmapData()
|
count === 0 ? COLORS[0] :
|
||||||
data.value = res.data
|
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>
|
const svgWidth = computed(() =>
|
||||||
.chart {
|
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING
|
||||||
margin: 0 auto;
|
)
|
||||||
|
|
||||||
|
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;
|
const tooltip = ref<{
|
||||||
font-size: 20px;
|
x: number
|
||||||
font-weight: bold;
|
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>
|
</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>
|
<template>
|
||||||
<div class="chart" v-if="show">
|
<n-card :title="title" size="small" v-if="show">
|
||||||
<Pie :data="data" :options="options" />
|
<div class="chart">
|
||||||
</div>
|
<Pie :data="data" :options="options" />
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Pie } from "vue-chartjs"
|
import { Pie } from "vue-chartjs"
|
||||||
@@ -13,24 +15,27 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
Colors,
|
Colors,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
|
||||||
// 仅注册饼图所需的 Chart.js 组件
|
// 仅注册饼图所需的 Chart.js 组件
|
||||||
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
|
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
|
||||||
|
|
||||||
const props = defineProps<{
|
const aiStore = useAIStore()
|
||||||
tags: { [key: string]: number }
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const show = computed(() => {
|
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(() => {
|
const data = computed(() => {
|
||||||
return {
|
return {
|
||||||
labels: Object.keys(props.tags),
|
labels: Object.keys(aiStore.detailsData.tags),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: Object.values(props.tags),
|
data: Object.values(aiStore.detailsData.tags),
|
||||||
backgroundColor: [
|
backgroundColor: [
|
||||||
"#FF6384",
|
"#FF6384",
|
||||||
"#36A2EB",
|
"#36A2EB",
|
||||||
@@ -49,15 +54,6 @@ const options = computed(() => {
|
|||||||
intersect: false,
|
intersect: false,
|
||||||
},
|
},
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
text: `题目的标签分布(前${Object.keys(props.tags).length}个)`,
|
|
||||||
display: true,
|
|
||||||
font: {
|
|
||||||
size: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ export function getAIDetailData(start: string, end: string) {
|
|||||||
return http.get("ai/detail", { params: { start, end } })
|
return http.get("ai/detail", { params: { start, end } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAIWeeklyData(end: string, duration: string) {
|
export function getAIDurationData(end: string, duration: string) {
|
||||||
return http.get("ai/weekly", { params: { end, duration } })
|
return http.get("ai/duration", { params: { end, duration } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAIHeatmapData() {
|
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 { consumeJSONEventStream } from "utils/stream"
|
||||||
import { getAIDetailData, getAIWeeklyData } from "../api"
|
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api"
|
||||||
import { getCSRFToken } from "utils/functions"
|
import { getCSRFToken } from "utils/functions"
|
||||||
|
|
||||||
export const useAIStore = defineStore("ai", () => {
|
export const useAIStore = defineStore("ai", () => {
|
||||||
const duration = ref("months:6")
|
const duration = ref("months:6")
|
||||||
const weeklyData = ref<WeeklyData[]>([])
|
const durationData = ref<DurationData[]>([])
|
||||||
const detailsData = reactive<DetailsData>({
|
const detailsData = reactive<DetailsData>({
|
||||||
start: "",
|
start: "",
|
||||||
end: "",
|
end: "",
|
||||||
@@ -16,17 +16,17 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
contest_count: 0,
|
contest_count: 0,
|
||||||
solved: [],
|
solved: [],
|
||||||
})
|
})
|
||||||
|
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||||
|
|
||||||
const loading = reactive({
|
const loading = reactive({
|
||||||
details: false,
|
fetching: false, // 合并 details 和 duration 的 loading
|
||||||
weekly: false,
|
|
||||||
ai: false,
|
ai: false,
|
||||||
|
heatmap: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mdContent = ref("")
|
const mdContent = ref("")
|
||||||
|
|
||||||
async function fetchDetailsData(start: string, end: string) {
|
async function fetchDetailsData(start: string, end: string) {
|
||||||
loading.details = true
|
|
||||||
const res = await getAIDetailData(start, end)
|
const res = await getAIDetailData(start, end)
|
||||||
detailsData.start = res.data.start
|
detailsData.start = res.data.start
|
||||||
detailsData.end = res.data.end
|
detailsData.end = res.data.end
|
||||||
@@ -36,14 +36,31 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
detailsData.tags = res.data.tags
|
detailsData.tags = res.data.tags
|
||||||
detailsData.difficulty = res.data.difficulty
|
detailsData.difficulty = res.data.difficulty
|
||||||
detailsData.contest_count = res.data.contest_count
|
detailsData.contest_count = res.data.contest_count
|
||||||
loading.details = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWeeklyData(end: string, duration: string) {
|
async function fetchDurationData(end: string, duration: string) {
|
||||||
loading.weekly = true
|
const res = await getAIDurationData(end, duration)
|
||||||
const res = await getAIWeeklyData(end, duration)
|
durationData.value = res.data
|
||||||
weeklyData.value = res.data
|
}
|
||||||
loading.weekly = false
|
|
||||||
|
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
|
let aiController: AbortController | null = null
|
||||||
@@ -72,7 +89,7 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
details: detailsData,
|
details: detailsData,
|
||||||
weekly: weeklyData.value,
|
duration: durationData.value,
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@@ -126,11 +143,12 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchWeeklyData,
|
fetchAnalysisData,
|
||||||
fetchDetailsData,
|
fetchHeatmapData,
|
||||||
fetchAIAnalysis,
|
fetchAIAnalysis,
|
||||||
weeklyData,
|
durationData,
|
||||||
detailsData,
|
detailsData,
|
||||||
|
heatmapData,
|
||||||
duration,
|
duration,
|
||||||
loading,
|
loading,
|
||||||
mdContent,
|
mdContent,
|
||||||
|
|||||||
@@ -126,16 +126,9 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<n-layout has-sider position="absolute">
|
<n-layout has-sider position="absolute">
|
||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
<n-layout-sider
|
<n-layout-sider bordered :width="100" :native-scrollbar="false">
|
||||||
bordered
|
|
||||||
:width="100"
|
|
||||||
:native-scrollbar="false"
|
|
||||||
>
|
|
||||||
<!-- 菜单 -->
|
<!-- 菜单 -->
|
||||||
<n-menu
|
<n-menu :options="options" :value="active" />
|
||||||
:options="options"
|
|
||||||
:value="active"
|
|
||||||
/>
|
|
||||||
</n-layout-sider>
|
</n-layout-sider>
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export interface Tutorial {
|
|||||||
created_at?: Date
|
created_at?: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeeklyData {
|
export interface DurationData {
|
||||||
unit: string
|
unit: string
|
||||||
index: number
|
index: number
|
||||||
start: string
|
start: string
|
||||||
|
|||||||
Reference in New Issue
Block a user