Compare commits
195 Commits
e38ac774d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71a903e28b | |||
| 2a14fe138f | |||
| 5fcbe4703b | |||
| 0e9b289f7c | |||
| f80b82e09d | |||
| ef38fa5d97 | |||
| 9888a8124b | |||
| 88b3a041c4 | |||
| 1cb96a1073 | |||
| f0f9c74973 | |||
| eb0122ffc2 | |||
| 91e2f8bf05 | |||
| 082d7843df | |||
| c77851aa97 | |||
| d7ef6abedf | |||
| bd414cfc3f | |||
| 9ff9c7ae54 | |||
| fe2411083f | |||
| d88da0739c | |||
| 37925c1be6 | |||
| b87cd85889 | |||
| d384d15aa6 | |||
| d766433068 | |||
| df24bf7f54 | |||
| 791828b9e1 | |||
| 67dce91dbe | |||
| 0a31cc3d2f | |||
| 42ce9ac63b | |||
| 9e7fbd8ff2 | |||
| ee59039968 | |||
| 9f07fcb0a0 | |||
| 9789b86920 | |||
| 6bc2140052 | |||
| 0bf265b5d9 | |||
| 3770627df8 | |||
| a2094dd659 | |||
| ca2ae54af5 | |||
| 63e58593d7 | |||
| d32af7e6e7 | |||
| 5127ded7d5 | |||
| 07c67d2694 | |||
| 43e0376fba | |||
| d1acd24cd0 | |||
| c6fefba081 | |||
| 41eabef923 | |||
| 943724d613 | |||
| c503745832 | |||
| 0bca7eccec | |||
| 9ecabf9eb9 | |||
| 7c21ce0e2e | |||
| b991688edd | |||
| ab4e12e0e5 | |||
| 04d77a3e9d | |||
| 88b7224b49 | |||
| 34413704c3 | |||
| 6f1720acd5 | |||
| 854b1f0769 | |||
| eda470cb78 | |||
| 41819b6d9b | |||
| 2c31acaba7 | |||
| 2aec0abca2 | |||
| 696f5ae2c9 | |||
| 87d2b828a1 | |||
| 4335ffb1e1 | |||
| f06b7313b0 | |||
| 1103789cd5 | |||
| 4a67717f42 | |||
| ead086c5ac | |||
| 234f149ee5 | |||
| 0651d7f9fb | |||
| 6c23634bb7 | |||
| a08f4a1935 | |||
| 6ee8fe02ba | |||
| 70306a8ef2 | |||
| d3694a8d28 | |||
| d7316d4681 | |||
| 33aca892f4 | |||
| df4bab8bd1 | |||
| cdaf85b89e | |||
| 5cec55acd6 | |||
| 76f8a03177 | |||
| b65bb3d53c | |||
| 0725e222dc | |||
| f45d07dbe7 | |||
| bf745be372 | |||
| 010beaaafb | |||
| 232e2d75e1 | |||
| ffcb436a82 | |||
| 93d4837c6d | |||
| 9e820f57f4 | |||
| 021e53ee59 | |||
| b14316b919 | |||
| b8c622dde1 | |||
| 6151f002bd | |||
| 1e0becb1f8 | |||
| 657047793d | |||
| 87e9c4120e | |||
| fa5cd00ab4 | |||
| 7938e02421 | |||
| 389393b70d | |||
| 437da9d588 | |||
| 6f345611eb | |||
| 97baf85611 | |||
| ed3cfaacd4 | |||
| 66b346bbb2 | |||
| 613ab6eae1 | |||
| 0b70c478b2 | |||
| d6820b70af | |||
| c1d5119a0a | |||
| 0c8e32aad4 | |||
| 0d19529632 | |||
| 96adf39cba | |||
| bc13976a78 | |||
| 76d5e0a78f | |||
| fccc3a361b | |||
| d2c031fbd9 | |||
| 06146d8895 | |||
| d28c22ddcb | |||
| 1bd25ba28f | |||
| 12dd9e1cfe | |||
| 73e1969d39 | |||
| 7529cb728d | |||
| 1ba042fae9 | |||
| 7e6d03ca1a | |||
| 2b93a9e849 | |||
| f593b34ce7 | |||
| 38a37d395d | |||
| 64ade2fe47 | |||
| 68a14aad28 | |||
| de21261b1b | |||
| 98785d6419 | |||
| be3b644531 | |||
| 70d4629e27 | |||
| 3be60ae50d | |||
| 94d2066a99 | |||
| ece84e15c7 | |||
| 04c6c49eaa | |||
| 719f315405 | |||
| 41d3246246 | |||
| aa8fcccf7d | |||
| 7b139d404e | |||
| fce96f2087 | |||
| dfa8d970f2 | |||
| c8751223db | |||
| 204bbf2d3c | |||
| ef0b0c5528 | |||
| a823cb7a13 | |||
| 68512d6d54 | |||
| 7f1ecec290 | |||
| 15d0e3cac6 | |||
| 8ede959fc2 | |||
| e462bd0864 | |||
| df72936d9f | |||
| 20996a8171 | |||
| b0229cb264 | |||
| c16059a2ee | |||
| 3dbc94aaf3 | |||
| acbe602b5a | |||
| ad7ea92769 | |||
| e291a194a9 | |||
| 8474cb83af | |||
| eff30e1a50 | |||
| 093acd68ea | |||
| d7bc25d086 | |||
| 25117b454d | |||
| 4ff5822ef8 | |||
| f7b8ef8978 | |||
| 59a81c7601 | |||
| aa4309b2fd | |||
| efbca0e802 | |||
| 4429e2f018 | |||
| 0b28dbeef8 | |||
| f428291698 | |||
| de47e761ec | |||
| 0dfd286455 | |||
| 0b652aa301 | |||
| 0a9d49b526 | |||
| de6ac4bd85 | |||
| 67a3b7fb26 | |||
| 7bd6f9170a | |||
| bffb6c6166 | |||
| 1686d68fca | |||
| e71611ff1c | |||
| b3091db7b2 | |||
| 43da7407d0 | |||
| 7d3cb2f070 | |||
| b02d7b99a6 | |||
| 7ee8bef20c | |||
| d209eb600e | |||
| d34183c639 | |||
| e2b5224a62 | |||
| 84d798c01f | |||
| 80552924df | |||
| ce0f7d5aa0 | |||
| 44be9336f1 |
11
.env
@@ -1,4 +1,7 @@
|
||||
VITE_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||
VITE_OJ_URL=http://localhost:8000
|
||||
VITE_CODE_URL=https://code.xuyue.cc
|
||||
VITE_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_ENV=dev
|
||||
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||
PUBLIC_OJ_URL=http://localhost:8000
|
||||
PUBLIC_CODE_URL=http://localhost:3000
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||
PUBLIC_WS_URL=ws://localhost:8001/ws
|
||||
@@ -1,4 +1,7 @@
|
||||
VITE_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||
VITE_OJ_URL=https://oj.xuyue.cc
|
||||
VITE_CODE_URL=https://code.xuyue.cc
|
||||
VITE_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_ENV=xuyue.cc
|
||||
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||
PUBLIC_OJ_URL=https://oj.xuyue.cc
|
||||
PUBLIC_CODE_URL=https://code.xuyue.cc
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
||||
PUBLIC_WS_URL=wss://oj.xuyue.cc/ws
|
||||
13
.env.staging
@@ -1,5 +1,8 @@
|
||||
VITE_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||
VITE_OJ_URL=http://10.13.114.114:81
|
||||
VITE_CODE_URL=http://10.13.114.114:82
|
||||
VITE_JUDGE0_URL=http://10.13.114.114:8082
|
||||
VITE_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_ENV=school
|
||||
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=dd37457027c40b39
|
||||
PUBLIC_OJ_URL=http://10.13.114.114:81
|
||||
PUBLIC_CODE_URL=http://10.13.114.114:82
|
||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||
PUBLIC_WS_URL=ws://10.13.114.114:81/ws
|
||||
8
.env.test
Normal file
@@ -0,0 +1,8 @@
|
||||
PUBLIC_ENV=test
|
||||
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=dd37457027c40b39
|
||||
PUBLIC_OJ_URL=http://10.13.114.114:93
|
||||
PUBLIC_CODE_URL=http://10.13.114.114:82
|
||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||
PUBLIC_WS_URL=ws://10.13.114.114:93/ws
|
||||
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
src/components.d.ts
|
||||
src/auto-imports.d.ts
|
||||
127
README.md
@@ -1 +1,126 @@
|
||||
OJ next
|
||||
# OJ Next
|
||||
|
||||
基于 Vue 3 + TypeScript 的在线判题系统前端项目。
|
||||
|
||||
## 项目特性
|
||||
|
||||
- 🎯 **现代化技术栈** - Vue 3 + TypeScript + Vite
|
||||
- 📊 **数据可视化** - 丰富的图表和统计功能
|
||||
- 🎨 **流程图编辑器** - 内置 FlowchartEditor 组件
|
||||
- 📱 **响应式设计** - 支持多端适配
|
||||
- ⚡ **高性能** - 优化的组件和状态管理
|
||||
|
||||
## 核心组件
|
||||
|
||||
### FlowchartEditor 流程图编辑器
|
||||
|
||||
一个功能完整的流程图编辑器组件,支持:
|
||||
|
||||
- 🎯 **拖拽创建节点** - 从工具栏拖拽节点到画布
|
||||
- 🔗 **节点连接** - 支持节点间的连线操作
|
||||
- ✏️ **节点编辑** - 双击节点进行文本编辑
|
||||
- 📋 **节点操作** - 复制、删除、批量操作
|
||||
- ↩️ **撤销重做** - 完整的历史记录管理
|
||||
- ⌨️ **键盘快捷键** - Ctrl+Z/Ctrl+Y 撤销重做,Delete 删除
|
||||
- 💾 **自动保存** - 本地缓存,防止数据丢失
|
||||
- 🎨 **多种节点类型** - 开始、输入、处理、判断、循环、输出、结束
|
||||
- 🖱️ **交互优化** - 流畅的拖拽体验和视觉反馈
|
||||
|
||||
**核心特性**:
|
||||
- 基于 Vue Flow 构建,性能优异
|
||||
- 模块化设计,易于扩展
|
||||
- 响应式布局,支持多端适配
|
||||
- 完整的 TypeScript 支持
|
||||
|
||||
详细文档:[FlowchartEditor 文档](./docs/FlowchartEditor.md)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Vue 3** - 渐进式 JavaScript 框架
|
||||
- **TypeScript** - JavaScript 的超集
|
||||
- **Rsbuild** - 基于 Rspack 的构建工具
|
||||
- **Vue Flow** - 流程图组件库
|
||||
- **Composition API** - Vue 3 组合式 API
|
||||
- **Naive UI** - Vue 3 组件库
|
||||
- **Chart.js** - 图表可视化库
|
||||
- **CodeMirror** - 代码编辑器
|
||||
- **Yjs** - 实时协作框架
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── shared/ # 共享组件和工具
|
||||
│ ├── components/
|
||||
│ │ └── FlowchartEditor/ # 流程图编辑器
|
||||
│ │ ├── index.vue # 主组件
|
||||
│ │ ├── CustomNode.vue # 自定义节点
|
||||
│ │ ├── Toolbar.vue # 工具栏
|
||||
│ │ ├── NodeHandles.vue # 节点操作
|
||||
│ │ ├── NodeActions.vue # 节点动作
|
||||
│ │ ├── useCache.ts # 缓存管理
|
||||
│ │ ├── useDnD.ts # 拖拽处理
|
||||
│ │ ├── useFlowOperations.ts # 流程操作
|
||||
│ │ ├── useHistory.ts # 历史记录
|
||||
│ │ └── useNodeStyles.ts # 节点样式
|
||||
│ ├── composables/ # 组合式函数
|
||||
│ ├── layout/ # 布局组件
|
||||
│ ├── store/ # 状态管理
|
||||
│ └── themes/ # 主题配置
|
||||
├── oj/ # 主要业务组件
|
||||
│ ├── ai/ # AI分析模块
|
||||
│ ├── problem/ # 题目相关
|
||||
│ ├── contest/ # 竞赛相关
|
||||
│ ├── submission/ # 提交相关
|
||||
│ └── store/ # 业务状态管理
|
||||
├── admin/ # 管理后台组件
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm start
|
||||
# 或
|
||||
npm run start
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 构建测试环境版本
|
||||
|
||||
```bash
|
||||
npm run build:test
|
||||
```
|
||||
|
||||
### 构建预发布版本
|
||||
|
||||
```bash
|
||||
npm run build:staging
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [FlowchartEditor 流程图编辑器](./docs/FlowchartEditor.md)
|
||||
- [图表组件说明](./docs/图表.md)
|
||||
- [API 接口对比分析](./docs/API接口对比分析.md)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
548
docs/API接口对比分析.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# API接口对比分析
|
||||
|
||||
## 更新日志
|
||||
|
||||
### 最近更新(2025-01-15)
|
||||
- ✨ **FlowchartEditor 流程图编辑器**:新增完整的流程图编辑功能
|
||||
- 基于 Vue Flow 构建的流程图编辑器组件
|
||||
- 支持7种节点类型:开始、输入、处理、判断、循环、输出、结束
|
||||
- 完整的拖拽创建、节点连接、编辑功能
|
||||
- 撤销重做、自动保存、键盘快捷键支持
|
||||
- 模块化设计,包含9个独立的功能模块
|
||||
- 🎨 **前端组件优化**:完善了组件文档和项目结构说明
|
||||
- 更新了 README.md 中的技术栈和项目结构
|
||||
- 创建了详细的 FlowchartEditor 使用文档
|
||||
- 优化了开发指南和构建流程说明
|
||||
- 🔧 **构建工具升级**:从 Vite 迁移到 Rsbuild
|
||||
- 使用 Rsbuild 作为新的构建工具
|
||||
- 支持多环境构建(test、staging、production)
|
||||
- 优化了构建性能和开发体验
|
||||
|
||||
### 历史更新(2025-10)
|
||||
- ✨ **AI分析功能增强**:完善了AI智能分析模块的文档说明
|
||||
- 详细说明了4个AI相关接口的功能和参数
|
||||
- 新增等级系统说明(S/A/B/C),包含特殊规则
|
||||
- 补充了时间范围选择功能
|
||||
- 说明了流式响应的实现方式
|
||||
- 前端组件从 `WeeklyChart.vue` 升级为 `DurationChart.vue`(混合图表)
|
||||
- 🔧 **数据缓存优化**:后端AI接口增加了缓存机制,提升性能
|
||||
- 🐛 **修正等级系统说明**:更正了等级阈值(A级:前35%,B级:前75%),并补充了小规模参与惩罚规则
|
||||
|
||||
---
|
||||
|
||||
## 一、前端已使用的API接口
|
||||
|
||||
### 1. 用户认证相关(shared/api.ts)
|
||||
- `POST /api/login` - 用户登录
|
||||
- `POST /api/register` - 用户注册
|
||||
- `GET /api/logout` - 用户登出
|
||||
- `GET /api/profile` - 获取用户资料
|
||||
- `GET /api/captcha` - 获取验证码
|
||||
|
||||
### 2. OJ普通用户API(oj/api.ts)
|
||||
#### 2.1 网站配置
|
||||
- `GET /api/website` - 获取网站配置
|
||||
- `GET /api/hitokoto` - 获取一言
|
||||
|
||||
#### 2.2 题目相关
|
||||
- `GET /api/problem` - 获取题目列表
|
||||
- `GET /api/problem` - 获取单个题目
|
||||
- `GET /api/problem/tags` - 获取题目标签列表
|
||||
- `GET /api/problem/author` - 获取题目作者列表
|
||||
- `GET /api/problem/beat_count` - 获取题目击败率
|
||||
- `GET /api/pickone` - 随机获取题目
|
||||
- `GET /api/contest/problem` - 获取竞赛题目
|
||||
|
||||
#### 2.3 提交相关
|
||||
- `GET /api/submission` - 获取单个提交
|
||||
- `POST /api/submission` - 提交代码
|
||||
- `GET /api/submissions` - 获取提交列表
|
||||
- `GET /api/submissions/today_count` - 获取今日提交数
|
||||
- `GET /api/contest_submissions` - 获取竞赛提交列表
|
||||
|
||||
#### 2.4 排名相关
|
||||
- `GET /api/user_rank` - 获取用户排名
|
||||
- `GET /api/user_activity_rank` - 获取活跃度排名
|
||||
- `GET /api/user_problem_rank` - 获取题目排名
|
||||
- `GET /api/contest_rank` - 获取竞赛排名
|
||||
|
||||
#### 2.5 竞赛相关
|
||||
- `GET /api/contests` - 获取竞赛列表
|
||||
- `GET /api/contest` - 获取单个竞赛
|
||||
- `GET /api/contest/access` - 获取竞赛访问权限
|
||||
- `POST /api/contest/password` - 验证竞赛密码
|
||||
|
||||
#### 2.6 公告相关
|
||||
- `GET /api/announcement` - 获取公告列表/单个公告
|
||||
|
||||
#### 2.7 消息相关
|
||||
- `POST /api/message` - 创建消息
|
||||
- `GET /api/message` - 获取消息列表
|
||||
|
||||
#### 2.8 评论相关
|
||||
- `POST /api/comment` - 创建评论
|
||||
- `GET /api/comment` - 获取评论
|
||||
- `GET /api/comment/statistics` - 获取评论统计
|
||||
|
||||
#### 2.9 用户相关
|
||||
- `POST /api/upload_avatar` - 上传头像
|
||||
- `PUT /api/profile` - 更新用户资料
|
||||
- `GET /api/profile/fresh_display_id` - 刷新用户题目显示ID
|
||||
- `GET /api/metrics` - 获取用户统计数据
|
||||
|
||||
#### 2.10 教程相关
|
||||
- `GET /api/tutorial` - 获取单个教程
|
||||
- `GET /api/tutorials` - 获取教程列表
|
||||
|
||||
#### 2.11 AI分析相关
|
||||
- `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 仪表板
|
||||
- `GET /api/admin/dashboard_info` - 获取仪表板信息
|
||||
- `GET /api/admin/random_user` - 随机获取用户
|
||||
|
||||
#### 3.2 题目管理
|
||||
- `GET /api/admin/problem` - 获取题目列表/单个题目
|
||||
- `POST /api/admin/problem` - 创建题目
|
||||
- `PUT /api/admin/problem` - 编辑题目
|
||||
- `PUT /api/admin/problem/visible` - 切换题目可见性
|
||||
- `DELETE /api/admin/problem` - 删除题目
|
||||
- `GET /api/admin/contest/problem` - 获取竞赛题目
|
||||
- `POST /api/admin/contest/problem` - 创建竞赛题目
|
||||
- `PUT /api/admin/contest/problem` - 编辑竞赛题目
|
||||
- `DELETE /api/admin/contest/problem` - 删除竞赛题目
|
||||
- `POST /api/admin/contest/add_problem_from_public` - 从公开题库添加题目到竞赛
|
||||
|
||||
#### 3.3 用户管理
|
||||
- `GET /api/admin/user` - 获取用户列表
|
||||
- `POST /api/admin/user` - 导入用户
|
||||
- `PUT /api/admin/user` - 编辑用户
|
||||
- `DELETE /api/admin/user` - 删除用户
|
||||
- `POST /api/admin/reset_password` - 重置用户密码
|
||||
|
||||
#### 3.4 竞赛管理
|
||||
- `GET /api/admin/contest` - 获取竞赛列表/单个竞赛
|
||||
- `POST /api/admin/contest` - 创建竞赛
|
||||
- `PUT /api/admin/contest` - 编辑竞赛
|
||||
- `GET /api/admin/contest/acm_helper` - 获取ACM比赛辅助检查列表
|
||||
- `PUT /api/admin/contest/acm_helper` - 更新ACM比赛辅助检查状态
|
||||
|
||||
#### 3.5 测试用例管理
|
||||
- `POST /api/admin/test_case` - 上传测试用例
|
||||
- `GET /api/admin/prune_test_case` - 列出无效测试用例
|
||||
- `DELETE /api/admin/prune_test_case` - 清理无效测试用例
|
||||
|
||||
#### 3.6 题目管理扩展
|
||||
- `POST /api/admin/contest_problem/make_public` - 将竞赛题目转为公开题目
|
||||
|
||||
#### 3.7 判题服务器管理
|
||||
- `GET /api/admin/judge_server` - 获取判题服务器列表
|
||||
- `DELETE /api/admin/judge_server` - 删除判题服务器
|
||||
|
||||
#### 3.8 公告管理
|
||||
- `GET /api/admin/announcement` - 获取公告列表/单个公告
|
||||
- `POST /api/admin/announcement` - 创建公告
|
||||
- `PUT /api/admin/announcement` - 编辑公告
|
||||
- `DELETE /api/admin/announcement` - 删除公告
|
||||
|
||||
#### 3.9 评论管理
|
||||
- `GET /api/admin/comment` - 获取评论列表
|
||||
- `DELETE /api/admin/comment` - 删除评论
|
||||
|
||||
#### 3.10 网站配置
|
||||
- `GET /api/admin/website` - 获取网站配置
|
||||
- `POST /api/admin/website` - 更新网站配置
|
||||
|
||||
#### 3.11 文件上传
|
||||
- `POST /api/admin/upload_image` - 上传图片(富文本编辑器、Markdown编辑器使用)
|
||||
|
||||
#### 3.12 提交管理
|
||||
- `GET /api/admin/submission/rejudge` - 重新判题
|
||||
- `GET /api/admin/submission/statistics` - 获取提交统计
|
||||
|
||||
#### 3.13 教程管理
|
||||
- `GET /api/admin/tutorial` - 获取教程列表/单个教程
|
||||
- `POST /api/admin/tutorial` - 创建教程
|
||||
- `PUT /api/admin/tutorial` - 更新教程
|
||||
- `DELETE /api/admin/tutorial` - 删除教程
|
||||
- `PUT /api/admin/tutorial/visibility` - 设置教程可见性
|
||||
|
||||
---
|
||||
|
||||
## 二、后端提供但前端未使用的API接口
|
||||
|
||||
### 1. 用户认证相关(account)
|
||||
- `POST /api/change_password` - 修改密码
|
||||
- `POST /api/change_email` - 修改邮箱
|
||||
- `POST /api/apply_reset_password` - 申请重置密码
|
||||
- `POST /api/reset_password` - 重置密码
|
||||
- `GET /api/check_username_or_email` - 检查用户名或邮箱是否存在
|
||||
- `GET /api/tfa_required` - 检查是否需要双因素认证
|
||||
- `POST /api/two_factor_auth` - 双因素认证
|
||||
- `GET /api/sessions` - 会话管理
|
||||
- `GET /api/open_api_appkey` - OpenAPI密钥管理
|
||||
- `GET /api/sso` - 单点登录
|
||||
|
||||
### 2. 用户管理(admin)
|
||||
- `GET /api/admin/generate_user` - 生成用户
|
||||
|
||||
### 3. 网站配置相关
|
||||
- `GET /api/languages` - 获取支持的编程语言列表
|
||||
|
||||
### 4. 判题服务器内部接口(不需要前端实现)
|
||||
- ❌ `POST /api/judge_server_heartbeat/` - 判题服务器心跳
|
||||
- **标记为不需要**
|
||||
- **原因**: 此接口由 JudgeServer Docker 容器调用,用于向后端报告服务器状态
|
||||
- **使用方**: 判题服务器(非前端)
|
||||
- **数据内容**: hostname, judger_version, CPU/内存使用率等
|
||||
- **认证方式**: 使用特殊的 judge_server_token,非用户认证
|
||||
|
||||
### 5. 管理员配置相关
|
||||
- `GET /api/admin/smtp` - SMTP配置
|
||||
- `POST /api/admin/smtp_test` - SMTP测试
|
||||
- `GET /api/admin/versions` - 版本信息
|
||||
|
||||
### 6. 题目管理相关(admin)
|
||||
- `POST /api/admin/export_problem` - 导出题目
|
||||
- `POST /api/admin/import_problem` - 导入题目
|
||||
- `POST /api/admin/import_fps` - 导入FPS格式题目
|
||||
|
||||
### 7. 竞赛相关(admin)
|
||||
- `GET /api/contest/announcement` - 获取竞赛公告列表(OJ端)
|
||||
- `GET /api/admin/contest/announcement` - 获取竞赛公告(管理端)
|
||||
- `GET /api/admin/download_submissions` - 下载竞赛提交
|
||||
|
||||
### 8. 提交相关(不必要的接口)
|
||||
- ❌ `GET /api/submission_exists` - 检查提交是否存在
|
||||
- **标记为不需要**
|
||||
- **原因**: 题目接口返回的 `my_status` 字段已完整包含此信息
|
||||
- **替代方案**: 直接判断 `my_status` 的值(0=已通过,非零=已尝试未通过,null=未尝试)
|
||||
- **优势**: 零额外请求,性能更优
|
||||
|
||||
### 9. 文件上传相关(admin)
|
||||
- `POST /api/admin/upload_file` - 上传文件(任意格式)
|
||||
- **说明**: 前端已使用 `upload_image`(仅图片),`upload_file` 可上传任意文件
|
||||
- **当前状态**: 未使用(前端暂无上传非图片文件的需求)
|
||||
- **潜在场景**: 题目附件、教程资料、作业提交等
|
||||
|
||||
---
|
||||
|
||||
## 三、统计总结
|
||||
|
||||
### 前端已使用接口统计
|
||||
- **OJ普通用户接口**: 38个(包括1个直接用fetch调用的流式接口)
|
||||
- **管理员接口**: 35个
|
||||
- **共享接口**: 5个
|
||||
- **总计**: 78个API调用
|
||||
|
||||
### 后端未被使用接口统计
|
||||
- **用户认证相关**: 10个
|
||||
- **网站配置相关**: 2个(languages)
|
||||
- **题目管理相关**: 3个
|
||||
- **竞赛管理相关**: 3个
|
||||
- **其他**: 1个
|
||||
- **标记为不需要**: 2个
|
||||
- submission_exists(数据冗余)
|
||||
- judge_server_heartbeat(内部接口)
|
||||
- **总计**: 21个API端点(其中2个不需要前端实现)
|
||||
|
||||
### 未使用接口占比
|
||||
约 **21%** 的后端API接口前端尚未使用
|
||||
- **需要考虑实现**: 19个接口
|
||||
- **不必要实现**: 2个接口(submission_exists, judge_server_heartbeat)
|
||||
|
||||
**备注**: 本次更新新增了 ACM 比赛辅助检查功能(2个接口),用于赛后人工审核代码。
|
||||
|
||||
---
|
||||
|
||||
## 四、建议
|
||||
|
||||
### 1. 高优先级需要实现的功能
|
||||
- **密码管理**: change_password, apply_reset_password, reset_password
|
||||
- **邮箱管理**: change_email
|
||||
- **用户名/邮箱检查**: check_username_or_email(注册时实时验证)
|
||||
- **编程语言列表**: languages(显示支持的编程语言)
|
||||
- **题目导入导出**: export_problem, import_problem(方便题库管理)
|
||||
|
||||
### 2. 中等优先级功能
|
||||
- **双因素认证**: tfa_required, two_factor_auth(增强安全性)
|
||||
- **会话管理**: sessions(多设备登录管理)
|
||||
- **竞赛公告**: contest/announcement(OJ端,增强竞赛体验)
|
||||
|
||||
### 3. 低优先级功能
|
||||
- **SSO单点登录**: sso(如需要集成其他系统)
|
||||
- **OpenAPI**: open_api_appkey(如需要开放API)
|
||||
- **SMTP配置**: smtp, smtp_test(管理员配置)
|
||||
- **版本信息**: versions(显示系统版本)
|
||||
- **FPS导入**: import_fps(特定格式题目导入)
|
||||
- **文件上传**: upload_file(目前只有图片上传)
|
||||
|
||||
### 4. 可选功能
|
||||
- **生成用户**: generate_user(批量生成测试用户)
|
||||
- **下载提交**: download_submissions(下载竞赛所有提交)
|
||||
|
||||
---
|
||||
|
||||
## 五、接口使用率分析
|
||||
|
||||
| 模块 | 后端提供 | 前端使用 | 使用率 |
|
||||
|------|---------|---------|--------|
|
||||
| 用户认证 | 17 | 7 | 41% |
|
||||
| 题目管理 | 14 | 11 | 79% |
|
||||
| 提交管理 | 7 | 6 | 86% |
|
||||
| 竞赛管理 | 12 | 9 | 75% |
|
||||
| 公告管理 | 4 | 4 | 100% |
|
||||
| 评论管理 | 4 | 4 | 100% |
|
||||
| 消息管理 | 1 | 1 | 100% |
|
||||
| 教程管理 | 4 | 4 | 100% |
|
||||
| AI分析 | 4 | 4 | 100% ✅ |
|
||||
| 配置管理 | 9 | 3 | 33% ⚠️(1个为内部接口) |
|
||||
|
||||
**总体使用率约为 79%**(已实现ACM比赛辅助检查功能,竞赛管理使用率提升至75%)
|
||||
|
||||
### 特殊说明
|
||||
|
||||
#### 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 比赛辅助检查功能 ✨
|
||||
|
||||
**功能说明**: 用于赛后人工审核 ACM 模式比赛的代码,检查是否存在抄袭、作弊等行为。
|
||||
|
||||
**涉及接口**:
|
||||
- `GET /api/admin/contest/acm_helper` - 获取比赛中所有 AC 的提交记录
|
||||
- **返回数据**: 用户名、题目ID、AC时间、错误次数、检查状态等
|
||||
- `PUT /api/admin/contest/acm_helper` - 更新提交的检查状态
|
||||
- **参数**: contest_id, rank_id, problem_id, checked
|
||||
|
||||
**使用场景**:
|
||||
1. 管理员进入比赛详情页,点击"审核"按钮进入辅助检查页面
|
||||
2. 系统展示所有 AC 提交的列表(按用户和题目分组)
|
||||
3. 管理员可以:
|
||||
- 查看每个提交的代码详情
|
||||
- 标记已检查的提交
|
||||
- 批量标记所有提交为已检查
|
||||
- 按用户名、题目、检查状态筛选
|
||||
4. 实时显示检查进度统计(总计/已检查/未检查)
|
||||
|
||||
**实现位置**:
|
||||
- 页面: `src/admin/contest/helper.vue`
|
||||
- 路由: `/admin/contest/:contestID/helper`
|
||||
- API: `src/admin/api.ts` (getACMHelperList, updateACMHelperChecked)
|
||||
|
||||
#### 3. 前端不需要的接口 ❌
|
||||
|
||||
##### 3.1 数据冗余接口
|
||||
**`GET /api/submission_exists`** - 此接口**无需实现**
|
||||
|
||||
**分析结论**:
|
||||
- 后端题目接口(`GET /api/problem`)已返回 `my_status` 字段
|
||||
- `my_status` 完整记录了用户的做题状态:
|
||||
- `0` (JudgeStatus.ACCEPTED) = 已通过 ✅
|
||||
- `-1` (JudgeStatus.WRONG_ANSWER) = 答案错误 ❌
|
||||
- `-2` (JudgeStatus.COMPILE_ERROR) = 编译错误 ❌
|
||||
- `1` (JudgeStatus.TIME_LIMIT_EXCEEDED) = 超时 ❌
|
||||
- 其他非零值 = 其他失败原因 ❌
|
||||
- `null/undefined` = 从未提交 ⭕
|
||||
|
||||
**前端实现**:
|
||||
```typescript
|
||||
// 仅需一个计算属性即可判断
|
||||
const hasTriedButNotPassed = computed(() => {
|
||||
return problem.value?.my_status !== undefined &&
|
||||
problem.value?.my_status !== null &&
|
||||
problem.value?.my_status !== 0
|
||||
})
|
||||
```
|
||||
|
||||
**优势对比**:
|
||||
| 方案 | API请求 | 代码复杂度 | 性能 |
|
||||
|------|---------|-----------|------|
|
||||
| ❌ 使用 submission_exists | +1 | 高(需异步+状态管理) | 慢(额外网络请求) |
|
||||
| ✅ 使用 my_status | 0 | 低(3行计算属性) | 快(本地计算) |
|
||||
|
||||
**经验教训**:
|
||||
- 实现新功能前应充分了解后端现有数据结构
|
||||
- 避免创建冗余接口,优先利用现有数据
|
||||
- 简单的解决方案往往是最好的
|
||||
|
||||
---
|
||||
|
||||
##### 3.2 内部系统接口
|
||||
**`POST /api/judge_server_heartbeat/`** - 此接口**无需前端实现**
|
||||
|
||||
**接口说明**:
|
||||
- **用途**: 判题服务器(JudgeServer)向后端报告健康状态
|
||||
- **调用方**: JudgeServer Docker 容器(非前端)
|
||||
- **调用频率**: 每隔几秒自动调用一次
|
||||
- **认证方式**: 使用 `judge_server_token`(特殊令牌,非用户认证)
|
||||
|
||||
**报告的数据**:
|
||||
```python
|
||||
{
|
||||
"hostname": "judge-server-1",
|
||||
"judger_version": "2.0.4",
|
||||
"cpu_core": 4,
|
||||
"cpu": 45.2, # CPU使用率
|
||||
"memory": 60.5, # 内存使用率
|
||||
"service_url": "http://judger:8080"
|
||||
}
|
||||
```
|
||||
|
||||
**后端使用场景**:
|
||||
- 监控判题服务器在线状态
|
||||
- 显示服务器资源使用情况(仅在管理后台显示)
|
||||
- 负载均衡分配判题任务
|
||||
|
||||
**前端已有接口**:
|
||||
前端查看判题服务器状态使用的是:
|
||||
- `GET /api/admin/judge_server` - 获取所有判题服务器列表及状态
|
||||
|
||||
**结论**:
|
||||
此接口是系统内部通信接口,前端完全不需要调用。类似的内部接口还可能存在于分布式系统的其他服务间通信中。
|
||||
|
||||
---
|
||||
|
||||
## 六、新增功能模块
|
||||
|
||||
### FlowchartEditor 流程图编辑器 ✨
|
||||
|
||||
**功能概述**: 基于 Vue Flow 构建的完整流程图编辑器,支持拖拽创建、节点连接、编辑等核心功能。
|
||||
|
||||
**技术实现**:
|
||||
- **核心库**: Vue Flow (@vue-flow/core)
|
||||
- **组件架构**: 模块化设计,9个独立功能模块
|
||||
- **状态管理**: 基于 Vue 3 Composition API
|
||||
- **数据持久化**: localStorage 自动缓存
|
||||
- **交互体验**: 拖拽、键盘快捷键、撤销重做
|
||||
|
||||
**组件结构**:
|
||||
```
|
||||
FlowchartEditor/
|
||||
├── index.vue # 主组件 - 整合所有功能
|
||||
├── CustomNode.vue # 自定义节点 - 7种节点类型
|
||||
├── Toolbar.vue # 工具栏 - 节点创建和操作
|
||||
├── NodeHandles.vue # 节点操作手柄 - 连接点管理
|
||||
├── NodeActions.vue # 节点动作 - 删除、编辑按钮
|
||||
├── useCache.ts # 缓存管理 - 自动保存/恢复
|
||||
├── useDnD.ts # 拖拽处理 - 节点创建逻辑
|
||||
├── useFlowOperations.ts # 流程操作 - 增删改查
|
||||
├── useHistory.ts # 历史记录 - 撤销重做
|
||||
└── useNodeStyles.ts # 节点样式 - 类型配置
|
||||
```
|
||||
|
||||
**节点类型支持**:
|
||||
- **开始节点** (start) - 流程开始,绿色主题
|
||||
- **输入节点** (input) - 数据输入,蓝色主题
|
||||
- **处理节点** (default) - 数据处理,紫色主题
|
||||
- **判断节点** (decision) - 条件判断,橙色主题
|
||||
- **循环节点** (loop) - 循环处理,青色主题
|
||||
- **输出节点** (output) - 数据输出,粉色主题
|
||||
- **结束节点** (end) - 流程结束,红色主题
|
||||
|
||||
**核心功能**:
|
||||
1. **拖拽创建**: 从工具栏拖拽节点到画布
|
||||
2. **节点连接**: 支持节点间的连线操作
|
||||
3. **节点编辑**: 双击节点进行文本编辑
|
||||
4. **撤销重做**: 完整的历史记录管理
|
||||
5. **自动保存**: 本地缓存,防止数据丢失
|
||||
6. **键盘快捷键**: Ctrl+Z/Ctrl+Y 撤销重做,Delete 删除
|
||||
7. **批量操作**: 支持多选和批量删除
|
||||
|
||||
**数据格式**:
|
||||
```typescript
|
||||
// 节点数据
|
||||
interface Node {
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number, y: number }
|
||||
data: {
|
||||
customLabel: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
// 边数据
|
||||
interface Edge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
type?: string
|
||||
}
|
||||
```
|
||||
|
||||
**使用场景**:
|
||||
- 算法流程图绘制
|
||||
- 业务流程设计
|
||||
- 教学演示工具
|
||||
- 问题分析图表
|
||||
|
||||
**性能优化**:
|
||||
- 基于 Vue Flow 的高性能渲染
|
||||
- 模块化设计,按需加载
|
||||
- 本地缓存减少重复计算
|
||||
- 响应式状态管理
|
||||
|
||||
**扩展性**:
|
||||
- 支持自定义节点类型
|
||||
- 可扩展工具栏功能
|
||||
- 支持主题定制
|
||||
- 支持数据导入导出
|
||||
|
||||
**文档支持**:
|
||||
- 详细的使用文档: `docs/FlowchartEditor.md`
|
||||
- 完整的 API 说明
|
||||
- 最佳实践指南
|
||||
- 故障排除手册
|
||||
|
||||
311
docs/FlowchartEditor.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# FlowchartEditor 流程图编辑器
|
||||
|
||||
一个基于 Vue 3 + Vue Flow 构建的功能完整的流程图编辑器组件。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 🎯 核心功能
|
||||
- **拖拽创建节点** - 从工具栏拖拽节点到画布
|
||||
- **节点连接** - 支持节点间的连线操作
|
||||
- **节点编辑** - 双击节点进行文本编辑
|
||||
- **撤销重做** - 完整的历史记录管理
|
||||
- **自动保存** - 本地缓存,防止数据丢失
|
||||
- **键盘快捷键** - 支持常用快捷键操作
|
||||
|
||||
### 🎨 节点类型
|
||||
- **开始节点** (start) - 流程开始
|
||||
- **输入节点** (input) - 数据输入
|
||||
- **处理节点** (default) - 数据处理
|
||||
- **判断节点** (decision) - 条件判断
|
||||
- **循环节点** (loop) - 循环处理
|
||||
- **输出节点** (output) - 数据输出
|
||||
- **结束节点** (end) - 流程结束
|
||||
|
||||
### ⌨️ 快捷键
|
||||
- `Ctrl+Z` / `Cmd+Z` - 撤销
|
||||
- `Ctrl+Y` / `Cmd+Shift+Z` - 重做
|
||||
- `Delete` / `Backspace` - 删除选中节点
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
FlowchartEditor/
|
||||
├── index.vue # 主组件
|
||||
├── CustomNode.vue # 自定义节点组件
|
||||
├── Toolbar.vue # 工具栏组件
|
||||
├── NodeHandles.vue # 节点操作手柄
|
||||
├── NodeActions.vue # 节点动作按钮
|
||||
├── useCache.ts # 缓存管理
|
||||
├── useDnD.ts # 拖拽处理
|
||||
├── useFlowOperations.ts # 流程操作
|
||||
├── useHistory.ts # 历史记录
|
||||
└── useNodeStyles.ts # 节点样式
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基本用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<FlowchartEditor ref="flowchartRef" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import FlowchartEditor from '@/shared/components/FlowchartEditor/index.vue'
|
||||
|
||||
const flowchartRef = ref()
|
||||
|
||||
// 获取流程图数据
|
||||
const getFlowchartData = () => {
|
||||
return flowchartRef.value?.getFlowchartData()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 获取流程图数据
|
||||
|
||||
```javascript
|
||||
// 获取当前流程图数据
|
||||
const data = flowchartRef.value.getFlowchartData()
|
||||
console.log(data.nodes) // 节点数组
|
||||
console.log(data.edges) // 边数组
|
||||
```
|
||||
|
||||
## 组件详解
|
||||
|
||||
### 主组件 (index.vue)
|
||||
|
||||
主组件负责整合所有功能模块,提供完整的流程图编辑体验。
|
||||
|
||||
**主要功能**:
|
||||
- 整合 Vue Flow 核心功能
|
||||
- 管理节点和边的状态
|
||||
- 处理用户交互事件
|
||||
- 提供数据暴露接口
|
||||
|
||||
**暴露的方法**:
|
||||
- `getFlowchartData()` - 获取当前流程图数据
|
||||
|
||||
### 自定义节点 (CustomNode.vue)
|
||||
|
||||
基于 Vue Flow 的自定义节点组件,支持多种节点类型。
|
||||
|
||||
**节点类型配置**:
|
||||
- 每种节点类型都有独特的样式和图标
|
||||
- 支持自定义标签文本
|
||||
- 提供删除和编辑功能
|
||||
|
||||
### 工具栏 (Toolbar.vue)
|
||||
|
||||
提供节点创建和操作的工具集合。
|
||||
|
||||
**功能**:
|
||||
- 节点类型选择
|
||||
- 撤销/重做操作
|
||||
- 清空画布
|
||||
- 保存状态显示
|
||||
|
||||
### 缓存管理 (useCache.ts)
|
||||
|
||||
提供本地存储功能,防止数据丢失。
|
||||
|
||||
**功能**:
|
||||
- 自动保存到 localStorage
|
||||
- 页面刷新后恢复数据
|
||||
- 保存状态提示
|
||||
- 清空缓存功能
|
||||
|
||||
### 拖拽处理 (useDnD.ts)
|
||||
|
||||
处理节点拖拽创建的逻辑。
|
||||
|
||||
**功能**:
|
||||
- 拖拽开始处理
|
||||
- 拖拽悬停效果
|
||||
- 拖拽放置处理
|
||||
- 节点位置计算
|
||||
|
||||
### 流程操作 (useFlowOperations.ts)
|
||||
|
||||
处理流程图的增删改操作。
|
||||
|
||||
**功能**:
|
||||
- 节点连接处理
|
||||
- 边删除处理
|
||||
- 节点删除处理
|
||||
- 节点更新处理
|
||||
- 清空画布
|
||||
|
||||
### 历史记录 (useHistory.ts)
|
||||
|
||||
提供撤销重做功能。
|
||||
|
||||
**功能**:
|
||||
- 状态快照保存
|
||||
- 撤销操作
|
||||
- 重做操作
|
||||
- 历史记录管理
|
||||
|
||||
### 节点样式 (useNodeStyles.ts)
|
||||
|
||||
定义各种节点类型的样式配置。
|
||||
|
||||
**功能**:
|
||||
- 节点类型配置
|
||||
- 样式定义
|
||||
- 图标配置
|
||||
- 颜色主题
|
||||
|
||||
## 样式定制
|
||||
|
||||
### 节点样式
|
||||
|
||||
可以通过修改 `useNodeStyles.ts` 来自定义节点样式:
|
||||
|
||||
```typescript
|
||||
export function getNodeTypeConfig(type: string) {
|
||||
const configs = {
|
||||
start: {
|
||||
label: '开始',
|
||||
icon: 'play-circle',
|
||||
color: '#10b981',
|
||||
// 自定义样式
|
||||
},
|
||||
// 其他节点类型...
|
||||
}
|
||||
|
||||
return configs[type] || configs.default
|
||||
}
|
||||
```
|
||||
|
||||
### 边样式
|
||||
|
||||
边的样式在主组件中定义:
|
||||
|
||||
```vue
|
||||
:default-edge-options="{
|
||||
type: 'step',
|
||||
style: {
|
||||
stroke: '#6366f1',
|
||||
strokeWidth: 2.5,
|
||||
cursor: 'pointer',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))'
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: '#6366f1',
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
}"
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
|
||||
### 节点数据格式
|
||||
|
||||
```typescript
|
||||
interface Node {
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number, y: number }
|
||||
data: {
|
||||
customLabel: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 边数据格式
|
||||
|
||||
```typescript
|
||||
interface Edge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
type?: string
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 性能优化
|
||||
- 大量节点时考虑虚拟化
|
||||
- 合理使用缓存机制
|
||||
- 避免频繁的状态更新
|
||||
|
||||
### 2. 用户体验
|
||||
- 提供清晰的操作反馈
|
||||
- 支持键盘快捷键
|
||||
- 保持操作的直观性
|
||||
|
||||
### 3. 数据管理
|
||||
- 定期保存到服务器
|
||||
- 提供数据导入导出功能
|
||||
- 支持版本控制机制
|
||||
|
||||
## 扩展开发
|
||||
|
||||
### 添加新节点类型
|
||||
|
||||
1. 在 `useNodeStyles.ts` 中添加新类型配置
|
||||
2. 在 `Toolbar.vue` 中添加新节点按钮
|
||||
3. 在 `CustomNode.vue` 中添加新节点渲染逻辑
|
||||
|
||||
### 添加新功能
|
||||
|
||||
1. 创建新的 composable 函数
|
||||
2. 在主组件中集成新功能
|
||||
3. 更新工具栏和用户界面
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **节点无法拖拽**
|
||||
- 检查拖拽事件处理
|
||||
- 确认节点类型配置正确
|
||||
|
||||
2. **撤销重做不工作**
|
||||
- 检查历史记录状态
|
||||
- 确认状态保存时机
|
||||
|
||||
3. **数据丢失**
|
||||
- 检查缓存配置
|
||||
- 确认 localStorage 可用
|
||||
|
||||
### 调试技巧
|
||||
|
||||
1. 使用 Vue DevTools 查看组件状态
|
||||
2. 检查浏览器控制台错误
|
||||
3. 验证数据格式正确性
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-01-15)
|
||||
- ✨ 初始版本发布
|
||||
- 🎯 基础流程图编辑功能
|
||||
- 🔧 拖拽创建节点
|
||||
- 🔗 节点连接功能
|
||||
- ↩️ 撤销重做支持
|
||||
- 💾 本地缓存功能
|
||||
|
||||
### v1.1.0 (2024-01-20)
|
||||
- 🎨 新增多种节点类型
|
||||
- ⌨️ 键盘快捷键支持
|
||||
- 🖱️ 优化拖拽体验
|
||||
- 📱 响应式布局改进
|
||||
|
||||
### v1.2.0 (2024-01-25)
|
||||
- 🔧 模块化重构
|
||||
- 📦 组合式函数优化
|
||||
- 🎯 性能提升
|
||||
- 🐛 修复已知问题
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
216
docs/图表.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 图表组件说明
|
||||
|
||||
基于 Chart.js 和 Vue-ChartJS 构建的数据可视化组件库,为 OJ Next 项目提供丰富的图表展示功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Chart.js** - 强大的图表库,支持多种图表类型
|
||||
- **Vue-ChartJS** - Chart.js 的 Vue 3 封装
|
||||
- **Vue 3 Composition API** - 现代化的组件开发方式
|
||||
- **TypeScript** - 完整的类型支持
|
||||
|
||||
## 现有图表组件
|
||||
|
||||
### 1. DurationChart 混合图表
|
||||
**文件位置**: `src/oj/ai/components/DurationChart.vue`
|
||||
|
||||
**功能描述**: 展示用户学习进度的时间趋势,结合柱状图和折线图。
|
||||
|
||||
**数据来源**: `durationData` API 接口
|
||||
- 每周/每月的综合情况
|
||||
- 题目数、提交数、等级变化
|
||||
|
||||
**图表类型**:
|
||||
- 柱状图:完成题目数
|
||||
- 折线图:提交次数、等级变化
|
||||
|
||||
**使用场景**: AI分析页面的主要图表
|
||||
|
||||
### 2. Heatmap 热力图
|
||||
**文件位置**: `src/oj/ai/components/Heatmap.vue`
|
||||
|
||||
**功能描述**: 展示用户的提交活跃度分布。
|
||||
|
||||
**数据来源**: `heatmapData` API 接口
|
||||
- 按日期统计提交数
|
||||
- 可视化用户活跃度
|
||||
|
||||
**图表类型**: 热力图矩阵
|
||||
|
||||
**使用场景**: 展示学习习惯和时间规律
|
||||
|
||||
### 3. Details 详细数据
|
||||
**文件位置**: `src/oj/ai/components/Details.vue`
|
||||
|
||||
**功能描述**: 展示用户详细的学习数据。
|
||||
|
||||
**数据来源**: `detailsData` API 接口
|
||||
- 用户等级、已解决题目列表
|
||||
- 标签统计、难度统计
|
||||
- 参赛次数等
|
||||
|
||||
**展示方式**: 数据表格和统计卡片
|
||||
|
||||
## 可扩展的图表类型
|
||||
|
||||
### 1. 提交效率趋势图 (折线图)
|
||||
**数据来源**: `durationData` 中的 `submission_count / problem_count`
|
||||
**展示内容**: 每个时间段的提交效率(提交次数/完成题目数)
|
||||
**价值**: 反映刷题质量的提升,值越接近1说明一次AC率越高
|
||||
|
||||
### 2. 排名分布图 (直方图/箱线图)
|
||||
**数据来源**: `solved` 数组中每道题的 `rank` 和 `ac_count`
|
||||
**展示内容**: 用户解题排名的分布情况(如:前10%、10-30%、30-50%等区间的题目数量)
|
||||
**价值**: 了解解题速度和竞争力
|
||||
|
||||
### 3. 等级分布饼图/环形图
|
||||
**数据来源**: `solved` 数组中每道题的 `grade`
|
||||
**展示内容**: S/A/B/C 各等级题目的数量和占比
|
||||
**价值**: 直观看出题目质量分布
|
||||
|
||||
### 4. 标签雷达图
|
||||
**数据来源**: `tags` 对象
|
||||
**展示内容**: 多维度展示各类标签的掌握程度(可以归一化处理)
|
||||
**价值**: 可视化知识点覆盖面
|
||||
|
||||
### 5. 时间活跃度分析 (热力矩阵)
|
||||
**数据来源**: `solved` 数组中的 `ac_time`
|
||||
**展示内容**: 按星期几和时间段统计做题分布(如:工作日vs周末,早中晚时段)
|
||||
**价值**: 了解学习习惯和时间规律
|
||||
|
||||
### 6. 难度-等级关联散点图
|
||||
**数据来源**: `solved` 数组中的难度信息和 `grade`
|
||||
**展示内容**: X轴为难度,Y轴为等级,每个点代表一道题
|
||||
**价值**: 分析在不同难度下的表现
|
||||
|
||||
### 7. 做题加速度图
|
||||
**数据来源**: `durationData`
|
||||
**展示内容**: 每个时间段完成题目数的变化率
|
||||
**价值**: 看出学习动力的变化趋势
|
||||
|
||||
### 8. 竞赛题目占比
|
||||
**数据来源**: `solved` 数组中的 `contest_id` 和 `contest_count`
|
||||
**展示内容**: 竞赛题 vs 常规题的数量对比
|
||||
**价值**: 了解竞赛参与情况
|
||||
|
||||
### 9. 连续做题天数统计
|
||||
**数据来源**: `heatmapData`
|
||||
**展示内容**: 最长连续做题天数、当前连续天数等
|
||||
**价值**: 激励持续学习
|
||||
|
||||
### 10. 月度对比雷达图
|
||||
**数据来源**: `durationData`
|
||||
**展示内容**: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
|
||||
**价值**: 全面评估进步情况
|
||||
|
||||
## 组件开发指南
|
||||
|
||||
### 创建新图表组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="chart-container">
|
||||
<Line
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
|
||||
// 计算图表数据
|
||||
const chartData = computed(() => ({
|
||||
labels: props.data.map(item => item.label),
|
||||
datasets: [{
|
||||
label: '数据',
|
||||
data: props.data.map(item => item.value),
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
}))
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 图表样式定制
|
||||
|
||||
```typescript
|
||||
// 主题配置
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: '#6366f1',
|
||||
secondary: '#8b5cf6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444'
|
||||
},
|
||||
gradients: {
|
||||
primary: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
success: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 1. 数据懒加载
|
||||
- 按需加载图表数据
|
||||
- 使用虚拟滚动处理大量数据
|
||||
|
||||
### 2. 图表缓存
|
||||
- 缓存计算结果
|
||||
- 避免重复渲染
|
||||
|
||||
### 3. 响应式设计
|
||||
- 自适应容器大小
|
||||
- 移动端优化
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 数据预处理
|
||||
- 在组件外部处理数据
|
||||
- 使用 computed 属性缓存计算结果
|
||||
|
||||
### 2. 错误处理
|
||||
- 添加数据验证
|
||||
- 提供降级方案
|
||||
|
||||
### 3. 用户体验
|
||||
- 添加加载状态
|
||||
- 提供交互反馈
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-01-15)
|
||||
- ✨ 初始版本发布
|
||||
- 📊 基础图表组件
|
||||
- 🎨 主题样式支持
|
||||
- 📱 响应式设计
|
||||
|
||||
### v1.1.0 (2024-01-20)
|
||||
- 🔧 性能优化
|
||||
- 📈 新增混合图表
|
||||
- 🎯 交互体验改进
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -2,21 +2,23 @@
|
||||
<html lang="zh-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="/public/website/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/noto--dog-face.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>判题狗</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<script>
|
||||
window.localStorage.setItem("maxkbMaskTip", true)
|
||||
</script>
|
||||
|
||||
<% if (process.env.PUBLIC_MAXKB_URL) { %>
|
||||
<script
|
||||
async
|
||||
defer
|
||||
src="%VITE_MAXKB_URL%"
|
||||
src="<%= process.env.PUBLIC_MAXKB_URL %>"
|
||||
></script>
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
5287
package-lock.json
generated
62
package.json
@@ -1,46 +1,58 @@
|
||||
{
|
||||
"name": "oj-next",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build:staging": "vue-tsc && vite build --mode=staging",
|
||||
"preview": "vite preview",
|
||||
"start": "rsbuild dev",
|
||||
"build": "rsbuild build",
|
||||
"build:staging": "rsbuild build --env-mode=staging",
|
||||
"build:test": "rsbuild build --env-mode=test",
|
||||
"fmt": "prettier --write src *.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@vueuse/core": "^13.7.0",
|
||||
"@wangeditor-next/editor": "^5.6.43",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.1",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@vue-flow/node-resizer": "^1.5.0",
|
||||
"@vue-flow/node-toolbar": "^1.1.1",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/router": "^14.1.0",
|
||||
"@wangeditor-next/editor": "^5.6.49",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"axios": "^1.11.0",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"chart.js": "^4.5.0",
|
||||
"axios": "^1.13.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-text-to-clipboard": "^3.2.0",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"md-editor-v3": "^5.8.4",
|
||||
"naive-ui": "^2.42.0",
|
||||
"md-editor-v3": "^6.2.1",
|
||||
"mermaid": "^11.12.2",
|
||||
"naive-ui": "^2.43.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.19",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.6.4",
|
||||
"y-codemirror.next": "^0.3.5",
|
||||
"y-webrtc": "^10.3.0",
|
||||
"yjs": "^13.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@rsbuild/core": "^1.6.15",
|
||||
"@rsbuild/plugin-vue": "^1.2.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.2",
|
||||
"unplugin-auto-import": "^20.0.0",
|
||||
"unplugin-vue-components": "^29.0.0",
|
||||
"vite": "^7.1.3",
|
||||
"vue-tsc": "^3.0.6"
|
||||
"@types/node": "^25.0.3",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/A.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/B.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28.45" height="32" viewBox="0 0 256 288">
|
||||
<path fill="#649AD2"
|
||||
d="M255.987 84.59c-.002-4.837-1.037-9.112-3.13-12.781c-2.054-3.608-5.133-6.632-9.261-9.023c-34.08-19.651-68.195-39.242-102.264-58.913c-9.185-5.303-18.09-5.11-27.208.27c-13.565 8-81.48 46.91-101.719 58.632C4.071 67.6.015 74.984.013 84.58C0 124.101.013 163.62 0 203.141c0 4.73.993 8.923 2.993 12.537c2.056 3.717 5.177 6.824 9.401 9.269c20.24 11.722 88.164 50.63 101.726 58.631c9.121 5.382 18.027 5.575 27.215.27c34.07-19.672 68.186-39.262 102.272-58.913c4.224-2.444 7.345-5.553 9.401-9.267c1.997-3.614 2.992-7.806 2.992-12.539c0 0 0-79.018-.013-118.539" />
|
||||
<path fill="#004482"
|
||||
d="m128.392 143.476l-125.4 72.202c2.057 3.717 5.178 6.824 9.402 9.269c20.24 11.722 88.164 50.63 101.726 58.631c9.121 5.382 18.027 5.575 27.215.27c34.07-19.672 68.186-39.262 102.272-58.913c4.224-2.444 7.345-5.553 9.401-9.267l-124.616-72.192" />
|
||||
<path fill="#1A4674"
|
||||
d="M91.25 164.863c7.297 12.738 21.014 21.33 36.75 21.33c15.833 0 29.628-8.7 36.888-21.576l-36.496-21.141l-37.142 21.387" />
|
||||
<path fill="#01589C"
|
||||
d="M255.987 84.59c-.002-4.837-1.037-9.112-3.13-12.781l-124.465 71.667l124.616 72.192c1.997-3.614 2.99-7.806 2.992-12.539c0 0 0-79.018-.013-118.539" />
|
||||
<path fill="#FFF"
|
||||
d="M249.135 148.636h-9.738v9.74h-9.74v-9.74h-9.737V138.9h9.737v-9.738h9.74v9.738h9.738v9.737ZM128 58.847c31.135 0 58.358 16.74 73.17 41.709l.444.759l-37.001 21.307c-7.333-12.609-20.978-21.094-36.613-21.094c-23.38 0-42.333 18.953-42.333 42.332a42.13 42.13 0 0 0 5.583 21.003c7.297 12.738 21.014 21.33 36.75 21.33c15.659 0 29.325-8.51 36.647-21.153l.241-.423l36.947 21.406c-14.65 25.597-42.228 42.851-73.835 42.851c-31.549 0-59.084-17.185-73.754-42.707c-7.162-12.459-11.26-26.904-11.26-42.307c0-46.95 38.061-85.013 85.014-85.013Zm75.865 70.314v9.738h9.737v9.737h-9.737v9.74h-9.738v-9.74h-9.738V138.9h9.738v-9.738h9.738Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
BIN
public/C.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28.45" height="32" viewBox="0 0 256 288">
|
||||
<path fill="#A9B9CB"
|
||||
d="M255.987 85.672c-.002-4.843-1.037-9.122-3.129-12.794c-2.055-3.612-5.134-6.638-9.262-9.032c-34.081-19.67-68.195-39.28-102.264-58.97c-9.185-5.307-18.091-5.114-27.208.27c-13.565 8.008-81.481 46.956-101.719 58.689C4.071 68.665.015 76.056.013 85.663C0 125.221.013 164.777 0 204.336c.002 4.736.993 8.932 2.993 12.55c2.056 3.72 5.177 6.83 9.401 9.278c20.239 11.733 88.164 50.678 101.726 58.688c9.121 5.387 18.027 5.579 27.215.27c34.07-19.691 68.186-39.3 102.272-58.97c4.224-2.447 7.345-5.559 9.401-9.276c1.997-3.618 2.99-7.814 2.992-12.551c0 0 0-79.094-.013-118.653" />
|
||||
<path fill="#7F8B99"
|
||||
d="M141.101 5.134c-9.17-5.294-18.061-5.101-27.163.269C100.395 13.39 32.59 52.237 12.385 63.94C4.064 68.757.015 76.129.013 85.711C0 125.166.013 164.62 0 204.076c.002 4.724.991 8.909 2.988 12.517c2.053 3.711 5.169 6.813 9.386 9.254a9008.51 9008.51 0 0 0 20.159 11.62L219.625 50.375c-26.178-15.074-52.363-30.136-78.524-45.241" />
|
||||
<path fill="#FFF"
|
||||
d="m154.456 126.968l39.839.281c0-16.599-16.802-57.249-64.973-57.249c-30.691 0-71.951 19.512-71.951 75.61c0 56.097 40.447 74.39 71.951 74.39c51.017 0 63.21-35.302 63.21-55.252l-38.007-2.173s1.017 23.075-25.406 23.075c-24.39 0-28.46-29.878-28.46-40.04c0-15.447 5.493-40.244 28.46-40.244c22.968 0 25.337 21.602 25.337 21.602" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="85.34" height="32" viewBox="0 0 512 192">
|
||||
<path fill="#00ACD7"
|
||||
d="m292.533 13.295l1.124.75c13.212 8.725 22.685 20.691 28.917 35.15c1.496 2.243.499 3.49-2.493 4.237l-5.063 1.296c-11.447 2.949-20.53 5.429-31.827 8.378l-6.443 1.678c-2.32.574-2.96.333-5.428-2.477l-.348-.399c-3.519-3.988-6.155-6.652-10.817-9.03l-.899-.443c-15.705-7.727-30.911-5.484-45.12 3.74c-16.952 10.968-25.677 27.172-25.428 47.364c.25 19.942 13.96 36.395 33.654 39.137c16.951 2.244 31.16-3.739 42.378-16.452c2.244-2.743 4.238-5.734 6.73-9.224h-48.11c-5.235 0-6.481-3.24-4.736-7.478l.864-2.035c3.204-7.454 8.173-18.168 11.4-24.294l.704-1.319c.862-1.494 2.612-3.513 5.977-3.513h80.224c3.603-11.415 9.449-22.201 17.246-32.407c18.198-23.931 40.135-36.396 69.8-41.63c25.427-4.488 49.359-1.995 71.046 12.713c19.694 13.461 31.909 31.66 35.15 55.59c4.237 33.654-5.485 61.075-28.668 84.508c-16.453 16.702-36.645 27.172-59.829 31.908c-6.73 1.247-13.461 1.496-19.942 2.244c-22.685-.499-43.376-6.98-60.826-21.937c-12.273-10.61-20.727-23.648-24.928-38.828a104.937 104.937 0 0 1-10.47 16.89c-17.949 23.683-41.381 38.39-71.046 42.38c-24.43 3.24-47.115-1.497-67.058-16.454c-18.447-13.96-28.917-32.407-31.66-55.34c-3.24-27.173 4.737-51.603 21.19-73.041c17.7-23.184 41.132-37.891 69.8-43.126c22.999-4.16 45.037-1.595 64.936 11.464ZM411.12 49.017l-.798.178c-23.183 5.235-38.14 19.942-43.624 43.375c-4.488 19.444 4.985 39.138 22.934 47.115c13.71 5.983 27.421 5.235 40.633-1.496c19.694-10.22 30.413-26.175 31.66-47.613c-.25-3.24-.25-5.734-.749-8.227c-4.436-24.401-26.664-38.324-50.056-33.332ZM116.416 94.564c.997 0 1.496.748 1.496 1.745l-.499 5.983c0 .997-.997 1.745-1.745 1.745l-54.344-.249c-.997 0-1.246-.748-.748-1.496l3.49-6.232c.499-.748 1.496-1.496 2.493-1.496h49.857ZM121.9 71.63c.997 0 1.496.748 1.247 1.496l-1.995 5.983c-.249.997-1.246 1.495-2.243 1.495l-117.912.25c-.997 0-1.246-.499-.748-1.247l5.235-6.73c.499-.748 1.745-1.247 2.742-1.247H121.9Zm12.963-22.934c.997 0 1.246.748.748 1.496l-4.238 6.481c-.499.748-1.745 1.496-2.493 1.496l-90.24-.25c-.998 0-1.247-.498-.749-1.246l5.235-6.73c.499-.748 1.745-1.247 2.742-1.247h88.995Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="23.68" height="32" viewBox="0 0 256 346">
|
||||
<path fill="#5382A1"
|
||||
d="M82.554 267.473s-13.198 7.675 9.393 10.272c27.369 3.122 41.356 2.675 71.517-3.034c0 0 7.93 4.972 19.003 9.279c-67.611 28.977-153.019-1.679-99.913-16.517m-8.262-37.814s-14.803 10.958 7.805 13.296c29.236 3.016 52.324 3.263 92.276-4.43c0 0 5.526 5.602 14.215 8.666c-81.747 23.904-172.798 1.885-114.296-17.532" />
|
||||
<path fill="#E76F00"
|
||||
d="M143.942 165.515c16.66 19.18-4.377 36.44-4.377 36.44s42.301-21.837 22.874-49.183c-18.144-25.5-32.059-38.172 43.268-81.858c0 0-118.238 29.53-61.765 94.6" />
|
||||
<path fill="#5382A1"
|
||||
d="M233.364 295.442s9.767 8.047-10.757 14.273c-39.026 11.823-162.432 15.393-196.714.471c-12.323-5.36 10.787-12.8 18.056-14.362c7.581-1.644 11.914-1.337 11.914-1.337c-13.705-9.655-88.583 18.957-38.034 27.15c137.853 22.356 251.292-10.066 215.535-26.195M88.9 190.48s-62.771 14.91-22.228 20.323c17.118 2.292 51.243 1.774 83.03-.89c25.978-2.19 52.063-6.85 52.063-6.85s-9.16 3.923-15.787 8.448c-63.744 16.765-186.886 8.966-151.435-8.183c29.981-14.492 54.358-12.848 54.358-12.848m112.605 62.942c64.8-33.672 34.839-66.03 13.927-61.67c-5.126 1.066-7.411 1.99-7.411 1.99s1.903-2.98 5.537-4.27c41.37-14.545 73.187 42.897-13.355 65.647c0 .001 1.003-.895 1.302-1.697" />
|
||||
<path fill="#E76F00"
|
||||
d="M162.439.371s35.887 35.9-34.037 91.101c-56.071 44.282-12.786 69.53-.023 98.377c-32.73-29.53-56.75-55.526-40.635-79.72C111.395 74.612 176.918 57.393 162.439.37" />
|
||||
<path fill="#5382A1"
|
||||
d="M95.268 344.665c62.199 3.982 157.712-2.209 159.974-31.64c0 0-4.348 11.158-51.404 20.018c-53.088 9.99-118.564 8.824-157.399 2.421c.001 0 7.95 6.58 48.83 9.201" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256">
|
||||
<path fill="#F7DF1E" d="M0 0h256v256H0V0Z" />
|
||||
<path
|
||||
d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 868 B |
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32.13" height="32" viewBox="0 0 256 255">
|
||||
<defs>
|
||||
<linearGradient id="svgIDa" x1="12.959%" x2="79.639%" y1="12.039%" y2="78.201%">
|
||||
<stop offset="0%" stop-color="#387EB8" />
|
||||
<stop offset="100%" stop-color="#366994" />
|
||||
</linearGradient>
|
||||
<linearGradient id="svgIDb" x1="19.128%" x2="90.742%" y1="20.579%" y2="88.429%">
|
||||
<stop offset="0%" stop-color="#FFE052" />
|
||||
<stop offset="100%" stop-color="#FFC331" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#svgIDa)"
|
||||
d="M126.916.072c-64.832 0-60.784 28.115-60.784 28.115l.072 29.128h61.868v8.745H41.631S.145 61.355.145 126.77c0 65.417 36.21 63.097 36.21 63.097h21.61v-30.356s-1.165-36.21 35.632-36.21h61.362s34.475.557 34.475-33.319V33.97S194.67.072 126.916.072ZM92.802 19.66a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.13Z" />
|
||||
<path fill="url(#svgIDb)"
|
||||
d="M128.757 254.126c64.832 0 60.784-28.115 60.784-28.115l-.072-29.127H127.6v-8.745h86.441s41.486 4.705 41.486-60.712c0-65.416-36.21-63.096-36.21-63.096h-21.61v30.355s1.165 36.21-35.632 36.21h-61.362s-34.475-.557-34.475 33.32v56.013s-5.235 33.897 62.518 33.897Zm34.114-19.586a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.131a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32.13" height="32" viewBox="0 0 256 255">
|
||||
<defs>
|
||||
<linearGradient id="svgIDa" x1="12.959%" x2="79.639%" y1="12.039%" y2="78.201%">
|
||||
<stop offset="0%" stop-color="#387EB8" />
|
||||
<stop offset="100%" stop-color="#366994" />
|
||||
</linearGradient>
|
||||
<linearGradient id="svgIDb" x1="19.128%" x2="90.742%" y1="20.579%" y2="88.429%">
|
||||
<stop offset="0%" stop-color="#FFE052" />
|
||||
<stop offset="100%" stop-color="#FFC331" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#svgIDa)"
|
||||
d="M126.916.072c-64.832 0-60.784 28.115-60.784 28.115l.072 29.128h61.868v8.745H41.631S.145 61.355.145 126.77c0 65.417 36.21 63.097 36.21 63.097h21.61v-30.356s-1.165-36.21 35.632-36.21h61.362s34.475.557 34.475-33.319V33.97S194.67.072 126.916.072ZM92.802 19.66a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.13Z" />
|
||||
<path fill="url(#svgIDb)"
|
||||
d="M128.757 254.126c64.832 0 60.784-28.115 60.784-28.115l-.072-29.127H127.6v-8.745h86.441s41.486 4.705 41.486-60.712c0-65.416-36.21-63.096-36.21-63.096h-21.61v30.355s1.165 36.21-35.632 36.21h-61.362s-34.475-.557-34.475 33.32v56.013s-5.235 33.897 62.518 33.897Zm34.114-19.586a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.131a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
public/S.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/badge-1.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/badge-2.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/badge-3.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/badge-4.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/badge-5.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/badge-6.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
329
public/flowchart-data.html
Normal file
@@ -0,0 +1,329 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Python流程图作业 - 学情分析看板</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2.1.0/dist/echarts-wordcloud.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #f0f2f5;
|
||||
--card-bg: #ffffff;
|
||||
--primary: #1890ff;
|
||||
--text-main: #333;
|
||||
--text-secondary: #666;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header h1 { color: var(--text-main); margin: 0; }
|
||||
.header p { color: var(--text-secondary); margin-top: 5px; }
|
||||
|
||||
/* 顶部概览卡片 */
|
||||
.overview-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
flex: 1;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.card:hover { transform: translateY(-5px); }
|
||||
|
||||
.stat-title { font-size: 14px; color: var(--text-secondary); }
|
||||
.stat-value { font-size: 28px; font-weight: bold; color: var(--text-main); margin-top: 10px; }
|
||||
.stat-sub { font-size: 12px; color: #52c41a; margin-top: 5px; }
|
||||
|
||||
/* 图表布局 */
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border-left: 4px solid var(--primary);
|
||||
padding-left: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* 学生列表 */
|
||||
.student-list {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
|
||||
th { background-color: #fafafa; color: var(--text-secondary); }
|
||||
|
||||
.tag {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tag-S { background: #fff7e6; color: #fa8c16; }
|
||||
.tag-A { background: #e6f7ff; color: #1890ff; }
|
||||
.tag-B { background: #f6ffed; color: #52c41a; }
|
||||
.tag-C { background: #fff1f0; color: #f5222d; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="overview-container">
|
||||
<div class="card">
|
||||
<div class="stat-title">班级平均分</div>
|
||||
<div class="stat-value" id="avgScore">0</div>
|
||||
<div class="stat-sub">↑ 比上周For循环 +2.5分</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-title">S+A 级别(卓越+优秀)人数</div>
|
||||
<div class="stat-value" id="countA">0</div>
|
||||
<div class="stat-sub">占比 35.7%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-title">未掌握核心难点</div>
|
||||
<div class="stat-value" style="color: #f5222d; font-size: 24px;">循环条件</div>
|
||||
<div class="stat-sub">需重点讲解 a<100 边界</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-box">
|
||||
<div class="chart-title">作业评级分布</div>
|
||||
<div id="pieChart" style="width: 100%; height: 340px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="chart-box">
|
||||
<div class="chart-title">薄弱知识点词云 (AI分析)</div>
|
||||
<div id="wordCloud" style="width: 100%; height: 340px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-box" style="grid-column: span 2;">
|
||||
<div class="chart-title">全班分数段统计</div>
|
||||
<div id="barChart" style="width: 100%; height: 340px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- 1. 模拟数据生成 (40位同学) ---
|
||||
const totalStudents = 40;
|
||||
const students = [];
|
||||
const familyNames = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨";
|
||||
|
||||
// 精确分配等级人数:S 10% (4人), A 15% (6人), B 50% (20人), C 15% (6人), D 10% (4人)
|
||||
const gradeDistribution = {
|
||||
S: 4, // 10% = 4人
|
||||
A: 6, // 15% = 6人
|
||||
B: 20, // 50% = 20人
|
||||
C: 6, // 15% = 6人
|
||||
D: 4 // 10% = 4人
|
||||
};
|
||||
|
||||
let gradeCounts = { S: 0, A: 0, B: 0, C: 0, D: 0 };
|
||||
let totalScore = 0;
|
||||
|
||||
// 创建等级数组,确保精确分配
|
||||
let levelQueue = [];
|
||||
for (let level in gradeDistribution) {
|
||||
for (let i = 0; i < gradeDistribution[level]; i++) {
|
||||
levelQueue.push(level);
|
||||
}
|
||||
}
|
||||
// 打乱顺序
|
||||
for (let i = levelQueue.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[levelQueue[i], levelQueue[j]] = [levelQueue[j], levelQueue[i]];
|
||||
}
|
||||
|
||||
for (let i = 1; i <= totalStudents; i++) {
|
||||
let score;
|
||||
let level = levelQueue[i - 1];
|
||||
|
||||
// 根据等级生成对应分数范围
|
||||
if (level === 'S') {
|
||||
// S级:95-100
|
||||
score = Math.floor(Math.random() * (100 - 95 + 1) + 95);
|
||||
} else if (level === 'A') {
|
||||
// A级:85-94
|
||||
score = Math.floor(Math.random() * (95 - 85) + 85);
|
||||
} else if (level === 'B') {
|
||||
// B级:70-84
|
||||
score = Math.floor(Math.random() * (85 - 70) + 70);
|
||||
} else if (level === 'C') {
|
||||
// C级:60-69
|
||||
score = Math.floor(Math.random() * (70 - 60) + 60);
|
||||
} else {
|
||||
// D级:50-59
|
||||
score = Math.floor(Math.random() * (60 - 50) + 50);
|
||||
}
|
||||
|
||||
gradeCounts[level]++;
|
||||
totalScore += score;
|
||||
|
||||
let comment = "";
|
||||
if (level === 'S') comment = "完美!逻辑清晰,变量初始化正确,闭环完美,代码规范。";
|
||||
else if (level === 'A') comment = "逻辑清晰,变量初始化正确,闭环完美。";
|
||||
else if (level === 'B') comment = "整体逻辑正确,但部分连线方向有误。";
|
||||
else if (level === 'C') comment = "循环条件判断错误,导致死循环或无法进入。";
|
||||
else comment = "基础概念理解不足,需要重新学习。";
|
||||
|
||||
students.push({
|
||||
id: 2025000 + i,
|
||||
name: familyNames[i % familyNames.length] + "同学",
|
||||
score: score,
|
||||
level: level,
|
||||
comment: comment
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2. 填充顶部数据 ---
|
||||
document.getElementById('avgScore').innerText = (totalScore / totalStudents).toFixed(1);
|
||||
const excellentCount = gradeCounts.S + gradeCounts.A; // S级和A级合计
|
||||
document.getElementById('countA').innerText = excellentCount;
|
||||
// 更新占比显示
|
||||
const excellentPercent = ((excellentCount / totalStudents) * 100).toFixed(1);
|
||||
const countACard = document.getElementById('countA').parentElement;
|
||||
countACard.querySelector('.stat-sub').innerText = `占比 ${excellentPercent}%`;
|
||||
|
||||
// --- 3. 初始化图表 ---
|
||||
|
||||
// A. 饼图 - 等级分布
|
||||
const pieChart = echarts.init(document.getElementById('pieChart'));
|
||||
pieChart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { top: '5%', left: 'center' },
|
||||
color: ['#fa8c16', '#1890ff', '#52c41a', '#faad14', '#f5222d'],
|
||||
series: [{
|
||||
name: '评级占比',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false, position: 'center' },
|
||||
emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } },
|
||||
data: [
|
||||
{ value: gradeCounts.S, name: 'S级 (卓越)' },
|
||||
{ value: gradeCounts.A, name: 'A级 (优秀)' },
|
||||
{ value: gradeCounts.B, name: 'B级 (良好)' },
|
||||
{ value: gradeCounts.C, name: 'C级 (待改进)' }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
// B. 词云图 - 知识点掌握情况
|
||||
// 这里重点突出“循环条件”
|
||||
const wordChart = echarts.init(document.getElementById('wordCloud'));
|
||||
wordChart.setOption({
|
||||
tooltip: {},
|
||||
series: [{
|
||||
type: 'wordCloud',
|
||||
gridSize: 2,
|
||||
sizeRange: [12, 60], // 字体大小范围
|
||||
rotationRange: [-45, 45],
|
||||
shape: 'circle',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textStyle: {
|
||||
fontFamily: 'sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: function () {
|
||||
return 'rgb(' + [
|
||||
Math.round(Math.random() * 160),
|
||||
Math.round(Math.random() * 160),
|
||||
Math.round(Math.random() * 160)
|
||||
].join(',') + ')';
|
||||
}
|
||||
},
|
||||
data: [
|
||||
{ name: '循环条件', value: 150, textStyle: { color: 'red' } }, // 核心痛点
|
||||
{ name: '变量初始化', value: 80 },
|
||||
{ name: 'i=i+1', value: 70 },
|
||||
{ name: 'a<100', value: 65 },
|
||||
{ name: '死循环', value: 60 },
|
||||
{ name: '连线方向', value: 50 },
|
||||
{ name: '退出逻辑', value: 45 },
|
||||
{ name: 'While语法', value: 40 },
|
||||
{ name: 'Print缩进', value: 35 },
|
||||
{ name: 'Yes/No分支', value: 30 },
|
||||
{ name: '流程结束符', value: 25 },
|
||||
{ name: '变量定义', value: 20 }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
// C. 柱状图 - 分数段
|
||||
const barChart = echarts.init(document.getElementById('barChart'));
|
||||
// 简单的分段统计
|
||||
let ranges = { '90-100': 0, '80-89': 0, '70-79': 0, '60-69': 0, '<60': 0 };
|
||||
students.forEach(s => {
|
||||
if (s.score >= 90) ranges['90-100']++;
|
||||
else if (s.score >= 80) ranges['80-89']++;
|
||||
else if (s.score >= 70) ranges['70-79']++;
|
||||
else if (s.score >= 60) ranges['60-69']++;
|
||||
else ranges['<60']++;
|
||||
});
|
||||
|
||||
barChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: Object.keys(ranges) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{
|
||||
name: '人数',
|
||||
type: 'bar',
|
||||
barWidth: '50%',
|
||||
data: Object.values(ranges),
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#83bff6' },
|
||||
{ offset: 0.5, color: '#188df0' },
|
||||
{ offset: 1, color: '#188df0' }
|
||||
])
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// 窗口缩放适配
|
||||
window.addEventListener('resize', function() {
|
||||
pieChart.resize();
|
||||
wordChart.resize();
|
||||
barChart.resize();
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/noto--dog-face.ico
Normal file
|
After Width: | Height: | Size: 188 KiB |
@@ -2,3 +2,15 @@
|
||||
font-family: "Monaco";
|
||||
src: url(/Monaco.ttf);
|
||||
}
|
||||
|
||||
.md-editor-preview .md-editor-code .md-editor-code-head {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
.md-editor-preview h1 {
|
||||
font-size: 1.6rem !important;
|
||||
}
|
||||
|
||||
.md-editor-preview h2 {
|
||||
font-size: 1.4rem !important;
|
||||
}
|
||||
|
||||
BIN
public/备案图标.png
|
Before Width: | Height: | Size: 1.4 KiB |
98
rsbuild.config.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { defineConfig, loadEnv } from "@rsbuild/core"
|
||||
import { pluginVue } from "@rsbuild/plugin-vue"
|
||||
import AutoImport from "unplugin-auto-import/rspack"
|
||||
import Components from "unplugin-vue-components/rspack"
|
||||
import { NaiveUiResolver } from "unplugin-vue-components/resolvers"
|
||||
|
||||
export default defineConfig(({ envMode }) => {
|
||||
const { publicVars, rawPublicVars } = loadEnv({
|
||||
cwd: process.cwd(),
|
||||
mode: envMode,
|
||||
})
|
||||
|
||||
const proxyConfig = {
|
||||
target: rawPublicVars["PUBLIC_OJ_URL"],
|
||||
changeOrigin: true,
|
||||
}
|
||||
|
||||
const wsProxyConfig = {
|
||||
target: rawPublicVars["PUBLIC_WS_URL"],
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
}
|
||||
return {
|
||||
plugins: [pluginVue()],
|
||||
tools: {
|
||||
rspack: {
|
||||
plugins: [
|
||||
AutoImport({
|
||||
imports: [
|
||||
"vue",
|
||||
"vue-router",
|
||||
"@vueuse/core",
|
||||
"pinia",
|
||||
{
|
||||
"naive-ui": [
|
||||
"useDialog",
|
||||
"useMessage",
|
||||
"useNotification",
|
||||
"useLoadingBar",
|
||||
],
|
||||
},
|
||||
{
|
||||
from: "naive-ui",
|
||||
imports: [
|
||||
"DataTableColumn",
|
||||
"FormRules",
|
||||
"FormItemRule",
|
||||
"SelectOption",
|
||||
"UploadCustomRequestOptions",
|
||||
"UploadFileInfo",
|
||||
"MenuOption",
|
||||
"DropdownDividerOption",
|
||||
"DropdownOption",
|
||||
],
|
||||
type: true,
|
||||
},
|
||||
],
|
||||
dts: "./src/auto-imports.d.ts",
|
||||
}),
|
||||
Components({
|
||||
resolvers: [NaiveUiResolver()],
|
||||
dts: "./src/components.d.ts",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
html: {
|
||||
template: "./index.html",
|
||||
},
|
||||
source: {
|
||||
entry: {
|
||||
index: "./src/main.ts",
|
||||
},
|
||||
define: publicVars,
|
||||
},
|
||||
performance: {
|
||||
chunkSplit: {
|
||||
strategy: "split-by-module",
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
utils: "./src/utils",
|
||||
oj: "./src/oj",
|
||||
admin: "./src/admin",
|
||||
shared: "./src/shared",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": proxyConfig,
|
||||
"/public": proxyConfig,
|
||||
"/ws": wsProxyConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
49
src/App.vue
@@ -1,15 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import hljs from "highlight.js/lib/core"
|
||||
import c from "highlight.js/lib/languages/c"
|
||||
import python from "highlight.js/lib/languages/python"
|
||||
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||
import "normalize.css"
|
||||
import "./index.css"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useConfigUpdate } from "shared/composables/configUpdate"
|
||||
import { useMaxKB } from "shared/composables/maxkb"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const isDark = useDark()
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 初始化配置和实时更新
|
||||
onMounted(() => {
|
||||
configStore.getConfig()
|
||||
userStore.getMyProfile()
|
||||
})
|
||||
|
||||
// 使用配置更新和 MaxKB 功能
|
||||
useConfigUpdate()
|
||||
useMaxKB()
|
||||
|
||||
// 延迟加载 highlight.js,避免阻塞首屏
|
||||
const hljsInstance = ref<any>(null)
|
||||
const loadHighlightJS = async () => {
|
||||
if (hljsInstance.value) return hljsInstance.value
|
||||
|
||||
const hljs = (await import("highlight.js/lib/core")).default
|
||||
const c = (await import("highlight.js/lib/languages/c")).default
|
||||
const cpp = (await import("highlight.js/lib/languages/cpp")).default
|
||||
const python = (await import("highlight.js/lib/languages/python")).default
|
||||
|
||||
hljs.registerLanguage("c", c)
|
||||
hljs.registerLanguage("python", python)
|
||||
hljs.registerLanguage("cpp", cpp)
|
||||
|
||||
const isDark = useDark()
|
||||
hljsInstance.value = hljs
|
||||
return hljs
|
||||
}
|
||||
|
||||
// 在空闲时预加载
|
||||
onMounted(() => {
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => loadHighlightJS())
|
||||
} else {
|
||||
setTimeout(() => loadHighlightJS(), 1000)
|
||||
}
|
||||
})
|
||||
|
||||
provide("hljs", hljsInstance)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -17,7 +56,7 @@ const isDark = useDark()
|
||||
:theme="isDark ? darkTheme : null"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:hljs="hljs"
|
||||
:hljs="hljsInstance"
|
||||
>
|
||||
<n-message-provider>
|
||||
<router-view></router-view>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteAnnouncement } from "~/admin/api"
|
||||
import { deleteAnnouncement } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
announcementID: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { AnnouncementEdit } from "~/utils/types"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { AnnouncementEdit } from "utils/types"
|
||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Announcement } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Announcement } from "utils/types"
|
||||
import { editAnnouncement, getAnnouncementList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
|
||||
186
src/admin/api.ts
@@ -11,7 +11,7 @@ import {
|
||||
Tutorial,
|
||||
User,
|
||||
WebsiteConfig,
|
||||
} from "~/utils/types"
|
||||
} from "utils/types"
|
||||
|
||||
export function getBaseInfo() {
|
||||
return http.get("admin/dashboard_info")
|
||||
@@ -25,8 +25,8 @@ export async function getProblemList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword: string,
|
||||
author?: string,
|
||||
contestID?: string,
|
||||
ruleType?: "ACM" | "OI",
|
||||
) {
|
||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||
const res = await http.get(endpoint, {
|
||||
@@ -35,8 +35,8 @@ export async function getProblemList(
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
rule_type: ruleType,
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -84,11 +84,12 @@ export function getContestProblem(id: number) {
|
||||
export function getUserList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
admin = "0",
|
||||
type = "",
|
||||
keyword: string,
|
||||
orderBy = "",
|
||||
) {
|
||||
return http.get("admin/user", {
|
||||
params: { paging: true, offset, limit, keyword, admin },
|
||||
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,7 +134,6 @@ export async function uploadImage(file: File): Promise<string> {
|
||||
export function uploadTestcases(file: File) {
|
||||
const form = new window.FormData()
|
||||
form.append("file", file)
|
||||
form.append("spj", "false")
|
||||
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
@@ -256,3 +256,177 @@ export function deleteTutorial(id: number) {
|
||||
export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||
return http.put("admin/tutorial/visibility", { id, is_public })
|
||||
}
|
||||
|
||||
// 将竞赛题目转为公开题目
|
||||
export function makeProblemPublic(id: number, display_id: string) {
|
||||
return http.post("admin/contest_problem/make_public", {
|
||||
id,
|
||||
display_id,
|
||||
})
|
||||
}
|
||||
|
||||
// 比赛辅助检查
|
||||
export function getACMHelperList(contest_id: number) {
|
||||
return http.get("admin/contest/acm_helper", {
|
||||
params: { contest_id },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateACMHelperChecked(
|
||||
contest_id: number,
|
||||
rank_id: number,
|
||||
problem_id: string,
|
||||
checked: boolean,
|
||||
) {
|
||||
return http.put("admin/contest/acm_helper", {
|
||||
contest_id,
|
||||
rank_id,
|
||||
problem_id,
|
||||
checked,
|
||||
})
|
||||
}
|
||||
|
||||
// 题单管理 API
|
||||
export function getProblemSetList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword = "",
|
||||
difficulty = "",
|
||||
status = "",
|
||||
) {
|
||||
return http.get("admin/problemset", {
|
||||
params: {
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
difficulty,
|
||||
status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getProblemSetDetail(id: number) {
|
||||
return http.get(`admin/problemset/${id}`)
|
||||
}
|
||||
|
||||
export function createProblemSet(data: {
|
||||
title: string
|
||||
description: string
|
||||
difficulty: string
|
||||
status: string
|
||||
}) {
|
||||
return http.post("admin/problemset", data)
|
||||
}
|
||||
|
||||
export function editProblemSet(data: {
|
||||
id: number
|
||||
title?: string
|
||||
description?: string
|
||||
difficulty?: string
|
||||
status?: string
|
||||
visible?: boolean
|
||||
}) {
|
||||
return http.put("admin/problemset", data)
|
||||
}
|
||||
|
||||
export function deleteProblemSet(id: number) {
|
||||
return http.delete("admin/problemset", { params: { id } })
|
||||
}
|
||||
|
||||
export function toggleProblemSetVisible(id: number) {
|
||||
return http.put("admin/problemset/visible", { id })
|
||||
}
|
||||
|
||||
export function updateProblemSetStatus(id: number, status: string) {
|
||||
return http.put("admin/problemset/status", { id, status })
|
||||
}
|
||||
|
||||
// 题单题目管理 API
|
||||
export function getProblemSetProblems(problemSetId: number) {
|
||||
return http.get(`admin/problemset/${problemSetId}/problems`)
|
||||
}
|
||||
|
||||
export function addProblemToSet(
|
||||
problemSetId: number,
|
||||
data: {
|
||||
problem_id: string
|
||||
order?: number
|
||||
is_required?: boolean
|
||||
score?: number
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return http.post(`admin/problemset/${problemSetId}/problems`, data)
|
||||
}
|
||||
|
||||
export function editProblemInSet(
|
||||
problemSetId: number,
|
||||
problemSetProblemId: number,
|
||||
data: {
|
||||
order?: number
|
||||
is_required?: boolean
|
||||
score?: number
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return http.put(
|
||||
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
export function removeProblemFromSet(
|
||||
problemSetId: number,
|
||||
problemSetProblemId: number,
|
||||
) {
|
||||
return http.delete(
|
||||
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 题单奖章管理 API
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
return http.get(`admin/problemset/${problemSetId}/badges`)
|
||||
}
|
||||
|
||||
export function createProblemSetBadge(
|
||||
problemSetId: number,
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
condition_type: string
|
||||
condition_value: number
|
||||
level?: number
|
||||
},
|
||||
) {
|
||||
return http.post(`admin/problemset/${problemSetId}/badges`, data)
|
||||
}
|
||||
|
||||
export function editProblemSetBadge(
|
||||
problemSetId: number,
|
||||
badgeId: number,
|
||||
data: {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
condition_type?: string
|
||||
condition_value?: number
|
||||
level?: number
|
||||
},
|
||||
) {
|
||||
return http.put(`admin/problemset/${problemSetId}/badges/${badgeId}`, data)
|
||||
}
|
||||
|
||||
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||
return http.delete(`admin/problemset/${problemSetId}/badges/${badgeId}`)
|
||||
}
|
||||
|
||||
// 题单进度管理 API
|
||||
export function getProblemSetProgress(problemSetId: number) {
|
||||
return http.get(`admin/problemset/${problemSetId}/progress`)
|
||||
}
|
||||
|
||||
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
||||
return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Comment } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Comment } from "utils/types"
|
||||
import { getCommentList } from "../api"
|
||||
import CommentActions from "./components/CommentActions.vue"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteComment } from "~/admin/api"
|
||||
import { deleteComment } from "admin/api"
|
||||
|
||||
const props = defineProps<{ commentID: number }>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Contest } from "~/utils/types"
|
||||
import { Contest } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
contest: Contest
|
||||
@@ -20,12 +20,30 @@ function goEditProblems() {
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
function goACMHelper() {
|
||||
router.push({
|
||||
name: "admin contest helper",
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
const isACM = computed(() => props.contest.rule_type === "ACM")
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
||||
题目
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="isACM"
|
||||
size="small"
|
||||
type="warning"
|
||||
secondary
|
||||
@click="goACMHelper"
|
||||
>
|
||||
审核
|
||||
</n-button>
|
||||
<n-button size="small" type="info" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { formatISO } from "date-fns"
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { BlankContest } from "~/utils/types"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { BlankContest } from "utils/types"
|
||||
import { createContest, editContest, getContest } from "../api"
|
||||
|
||||
interface Props {
|
||||
|
||||
325
src/admin/contest/helper.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
||||
import { getSubmission, getSubmissions } from "oj/api"
|
||||
import SubmissionDetail from "oj/submission/detail.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
|
||||
interface Props {
|
||||
contestID: string
|
||||
}
|
||||
|
||||
interface HelperItem {
|
||||
id: number
|
||||
username: string
|
||||
real_name: string
|
||||
problem_id: string
|
||||
problem_display_id: string
|
||||
ac_info: {
|
||||
is_ac: boolean
|
||||
ac_time: number
|
||||
error_number: number
|
||||
checked?: boolean
|
||||
}
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const message = useMessage()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const submissions = ref<HelperItem[]>([])
|
||||
const contestStartTime = ref<Date | null>(null)
|
||||
const query = reactive({
|
||||
username: "",
|
||||
problemId: "",
|
||||
checked: "all",
|
||||
})
|
||||
|
||||
// 检查状态选项
|
||||
const checkedOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "已检查", value: "checked" },
|
||||
{ label: "未检查", value: "unchecked" },
|
||||
]
|
||||
|
||||
// 代码查看模态框
|
||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||
const currentSubmission = ref<any>(null)
|
||||
|
||||
// 格式化 AC 时间(ac_time 是相对于比赛开始的秒数)
|
||||
function formatACTime(relativeSeconds: number) {
|
||||
if (!contestStartTime.value) return "-"
|
||||
const acTime = new Date(
|
||||
contestStartTime.value.getTime() + relativeSeconds * 1000,
|
||||
)
|
||||
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
|
||||
}
|
||||
|
||||
// 切换检查状态
|
||||
async function toggleChecked(item: HelperItem) {
|
||||
const newChecked = !item.checked
|
||||
try {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problem_id,
|
||||
newChecked,
|
||||
)
|
||||
// 更新本地状态
|
||||
item.checked = newChecked
|
||||
item.ac_info.checked = newChecked
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 批量标记为已检查
|
||||
async function markAllAsChecked() {
|
||||
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
|
||||
if (unchecked.length === 0) {
|
||||
message.info("没有需要标记的提交")
|
||||
return
|
||||
}
|
||||
|
||||
const loadingMsg = message.loading("正在标记...", { duration: 0 })
|
||||
try {
|
||||
for (const item of unchecked) {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problem_id,
|
||||
true,
|
||||
)
|
||||
item.checked = true
|
||||
item.ac_info.checked = true
|
||||
}
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
loadingMsg.destroy()
|
||||
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
||||
} catch (err: any) {
|
||||
loadingMsg.destroy()
|
||||
message.error(err.data || "批量操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤后的提交列表
|
||||
const filteredSubmissions = computed(() => {
|
||||
return submissions.value.filter((item) => {
|
||||
if (query.username && !item.username.includes(query.username)) return false
|
||||
if (query.problemId && !item.problem_display_id.includes(query.problemId))
|
||||
return false
|
||||
if (query.checked === "checked" && !item.checked) return false
|
||||
if (query.checked === "unchecked" && item.checked) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 统计信息
|
||||
const stats = computed(() => {
|
||||
const total = submissions.value.length
|
||||
const checked = submissions.value.filter((item) => item.checked).length
|
||||
const unchecked = total - checked
|
||||
return { total, checked, unchecked }
|
||||
})
|
||||
|
||||
// 查看代码 - 获取该用户在该题目的 AC 提交
|
||||
async function viewSubmission(item: HelperItem) {
|
||||
try {
|
||||
// 查询该用户在该竞赛该题目的 AC 提交
|
||||
const res = await getSubmissions({
|
||||
username: item.username,
|
||||
problem_id: item.problem_display_id,
|
||||
contest_id: props.contestID,
|
||||
result: "0", // ACCEPTED
|
||||
language: "",
|
||||
page: 1,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (res.data.results.length === 0) {
|
||||
message.warning("未找到该用户的 AC 提交")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取提交详情
|
||||
const submissionListItem = res.data.results[0]
|
||||
const detailRes = await getSubmission(submissionListItem.id)
|
||||
|
||||
// 手动添加 contest 字段(ACM模式下后端不返回此字段)
|
||||
currentSubmission.value = {
|
||||
...detailRes.data,
|
||||
contest: Number(props.contestID),
|
||||
problem_display_id: item.problem_display_id,
|
||||
}
|
||||
|
||||
toggleCodePanel(true)
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "加载提交失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
try {
|
||||
// 先获取比赛信息,获取开始时间
|
||||
const contestRes = await getContest(props.contestID)
|
||||
contestStartTime.value = new Date(contestRes.data.start_time)
|
||||
|
||||
// 再获取 AC 提交列表
|
||||
const { data } = await getACMHelperList(Number(props.contestID))
|
||||
submissions.value = data
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "加载失败")
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<HelperItem>[] = [
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "题目",
|
||||
key: "problem_display_id",
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { type: "info" }, () => row.problem_display_id),
|
||||
},
|
||||
{
|
||||
title: "AC时间",
|
||||
key: "ac_time",
|
||||
width: 180,
|
||||
render: (row) => formatACTime(row.ac_info.ac_time),
|
||||
},
|
||||
{
|
||||
title: "错误次数",
|
||||
key: "error_number",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: row.ac_info.error_number > 0 ? "warning" : "success",
|
||||
size: "small",
|
||||
},
|
||||
() => row.ac_info.error_number,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "已检查",
|
||||
key: "checked",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NCheckbox, {
|
||||
checked: row.checked,
|
||||
onUpdateChecked: () => toggleChecked(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "primary",
|
||||
secondary: true,
|
||||
onClick: () => viewSubmission(row),
|
||||
},
|
||||
() => "查看代码",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-flex align="center">
|
||||
<h2 style="margin: 0">比赛辅助检查</h2>
|
||||
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag>
|
||||
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag>
|
||||
<n-tag type="warning" size="large">
|
||||
未检查: {{ stats.unchecked }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="stats.unchecked === 0"
|
||||
@click="markAllAsChecked"
|
||||
>
|
||||
标记全部为已检查
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-alert type="info" style="margin-bottom: 16px">
|
||||
<template #header>使用说明</template>
|
||||
此工具用于赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选"已检查"。
|
||||
</n-alert>
|
||||
|
||||
<n-flex align="center" style="margin-bottom: 16px">
|
||||
<n-input
|
||||
v-model:value="query.username"
|
||||
placeholder="筛选用户名"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="query.problemId"
|
||||
placeholder="筛选题目"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.checked"
|
||||
:options="checkedOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="filteredSubmissions"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
<n-modal
|
||||
v-model:show="codePanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="代码详情"
|
||||
>
|
||||
<SubmissionDetail
|
||||
v-if="currentSubmission"
|
||||
:submission="currentSubmission"
|
||||
:problemID="currentSubmission.problem_display_id"
|
||||
:submissionID="currentSubmission.id"
|
||||
hideList
|
||||
/>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-data-table) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch, NTag } from "naive-ui"
|
||||
import ContestTitle from "~/shared/components/ContestTitle.vue"
|
||||
import ContestType from "~/shared/components/ContestType.vue"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "~/utils/constants"
|
||||
import { Contest } from "~/utils/types"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import ContestType from "shared/components/ContestType.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "utils/constants"
|
||||
import { Contest } from "utils/types"
|
||||
import { editContest, getContestList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
@@ -66,7 +66,7 @@ const columns: DataTableColumn<Contest>[] = [
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
width: 220,
|
||||
render: (row) => h(Actions, { contest: row }),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteContestProblem, deleteProblem } from "~/admin/api"
|
||||
import download from "~/utils/download"
|
||||
import {
|
||||
deleteContestProblem,
|
||||
deleteProblem,
|
||||
makeProblemPublic,
|
||||
} from "admin/api"
|
||||
import download from "utils/download"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
problemDisplayID: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
const emit = defineEmits(["updated"])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const isContestProblem = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
|
||||
const showMakePublicModal = ref(false)
|
||||
const newDisplayID = ref("")
|
||||
|
||||
async function handleDeleteProblem() {
|
||||
try {
|
||||
if (route.name === "admin contest problem list") {
|
||||
@@ -21,7 +32,7 @@ async function handleDeleteProblem() {
|
||||
await deleteProblem(props.problemID)
|
||||
}
|
||||
message.success("删除成功")
|
||||
emit("deleted")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Can't delete the problem as it has submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
@@ -53,6 +64,33 @@ function goCheck() {
|
||||
}
|
||||
window.open(data.href, "_blank")
|
||||
}
|
||||
|
||||
function openMakePublicModal() {
|
||||
newDisplayID.value = ""
|
||||
showMakePublicModal.value = true
|
||||
}
|
||||
|
||||
async function handleMakePublic() {
|
||||
if (!newDisplayID.value.trim()) {
|
||||
message.error("请输入新的题目编号")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
|
||||
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display ID") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (err.data === "Already be a public problem") {
|
||||
message.error("该题目已经是公开题目")
|
||||
} else {
|
||||
message.error("转换失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
@@ -62,6 +100,19 @@ function goCheck() {
|
||||
<n-button size="small" secondary type="info" @click="goCheck">
|
||||
查看
|
||||
</n-button>
|
||||
<n-tooltip v-if="isContestProblem">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
type="warning"
|
||||
@click="openMakePublicModal"
|
||||
>
|
||||
公开
|
||||
</n-button>
|
||||
</template>
|
||||
将此竞赛题目转为公开题目
|
||||
</n-tooltip>
|
||||
<n-popconfirm @positive-click="handleDeleteProblem">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="error">删除</n-button>
|
||||
@@ -75,4 +126,35 @@ function goCheck() {
|
||||
下载测试用例
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showMakePublicModal"
|
||||
preset="card"
|
||||
title="转为公开题目"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>
|
||||
将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。
|
||||
</p>
|
||||
<n-form>
|
||||
<n-form-item label="新的题目编号" required>
|
||||
<n-input
|
||||
v-model:value="newDisplayID"
|
||||
placeholder="例如: 1001"
|
||||
clearable
|
||||
@keyup.enter="handleMakePublic"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
|
||||
</n-alert>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showMakePublicModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleMakePublic">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { addProblemForContest } from "~/admin/api"
|
||||
import { addProblemForContest } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { getProblemList } from "~/admin/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { AdminProblemFiltered } from "~/utils/types"
|
||||
import { getProblemList } from "admin/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { AdminProblemFiltered } from "utils/types"
|
||||
import AddButton from "./AddButton.vue"
|
||||
|
||||
interface Props {
|
||||
@@ -42,13 +42,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
|
||||
async function getList() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
"",
|
||||
"ACM",
|
||||
)
|
||||
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
|
||||
total.value = res.total
|
||||
problems.value = res.results
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { getProblemTagList } from "~/shared/api"
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import {
|
||||
CODE_TEMPLATES,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
STORAGE_KEY,
|
||||
} from "~/utils/constants"
|
||||
import download from "~/utils/download"
|
||||
import { unique } from "~/utils/functions"
|
||||
import { BlankProblem, LANGUAGE, Tag } from "~/utils/types"
|
||||
} from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { unique } from "utils/functions"
|
||||
import { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
} from "../api"
|
||||
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("~/shared/components/CodeEditor.vue"),
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
|
||||
const MermaidEditor = defineAsyncComponent(
|
||||
() => import("shared/components/MermaidEditor.vue"),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
@@ -50,31 +54,35 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
||||
output_description: "",
|
||||
time_limit: 1000,
|
||||
memory_limit: 64,
|
||||
difficulty: "Low",
|
||||
difficulty: "Low" as "Low" | "Mid" | "High",
|
||||
visible: false,
|
||||
share_submission: false,
|
||||
tags: [],
|
||||
languages: ["C", "Python3"],
|
||||
template: {},
|
||||
languages: ["Python3", "C"] as LANGUAGE[],
|
||||
template: {} as { [key in LANGUAGE]?: string },
|
||||
samples: [
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
],
|
||||
spj: false,
|
||||
spj_language: "",
|
||||
spj_code: "",
|
||||
spj_compile_ok: false,
|
||||
test_case_id: "",
|
||||
test_case_score: [],
|
||||
test_case_score: [] as Testcase[],
|
||||
rule_type: "ACM",
|
||||
hint: "",
|
||||
source: "",
|
||||
prompt: "",
|
||||
answers: [] as { language: LANGUAGE; code: string }[],
|
||||
io_mode: {
|
||||
io_mode: "Standard IO",
|
||||
input: "input.txt",
|
||||
output: "output.txt",
|
||||
},
|
||||
contest_id: "",
|
||||
allow_flowchart: false,
|
||||
mermaid_code: "",
|
||||
flowchart_data: {},
|
||||
flowchart_hint: "",
|
||||
show_flowchart: false,
|
||||
})
|
||||
|
||||
// 从服务器来的tag列表
|
||||
@@ -90,14 +98,18 @@ const tags = useLocalStorage<Tags>(STORAGE_KEY.ADMIN_PROBLEM_TAGS, {
|
||||
upload: [],
|
||||
})
|
||||
|
||||
// 这三个用的少,就不缓存本地了
|
||||
// 这几个用的少,就不缓存本地了
|
||||
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
||||
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
|
||||
const currentActiveTemplate = ref<LANGUAGE>("C")
|
||||
const currentActiveTemplate = ref<LANGUAGE>("Python3")
|
||||
const currentActiveAnswer = ref<LANGUAGE>("Python3")
|
||||
|
||||
// 给 TextEditor 用
|
||||
const [ready, toggleReady] = useToggle(false)
|
||||
|
||||
// Mermaid 渲染状态
|
||||
const mermaidRenderSuccess = ref(false)
|
||||
|
||||
const difficultyOptions: SelectOption[] = [
|
||||
{ label: "简单", value: "Low" },
|
||||
{ label: "中等", value: "Mid" },
|
||||
@@ -105,8 +117,9 @@ const difficultyOptions: SelectOption[] = [
|
||||
]
|
||||
|
||||
const languageOptions = [
|
||||
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
|
||||
]
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
@@ -118,6 +131,7 @@ async function getProblemDetail() {
|
||||
toggleReady(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { data } = await getProblem(props.problemID)
|
||||
toggleReady(true)
|
||||
problem.value.id = data.id
|
||||
@@ -137,15 +151,26 @@ async function getProblemDetail() {
|
||||
problem.value.template = data.template
|
||||
problem.value.samples = data.samples
|
||||
problem.value.samples = data.samples
|
||||
problem.value.spj = data.spj
|
||||
problem.value.spj_language = data.spj_language
|
||||
problem.value.spj_code = data.spj_code
|
||||
problem.value.spj_compile_ok = data.spj_compile_ok
|
||||
problem.value.test_case_id = data.test_case_id
|
||||
problem.value.test_case_score = data.test_case_score
|
||||
problem.value.rule_type = data.rule_type
|
||||
problem.value.hint = data.hint
|
||||
problem.value.source = data.source
|
||||
problem.value.prompt = data.prompt
|
||||
// 流程图相关字段
|
||||
problem.value.allow_flowchart = data.allow_flowchart
|
||||
problem.value.show_flowchart = data.show_flowchart
|
||||
problem.value.mermaid_code = data.mermaid_code ?? ""
|
||||
problem.value.flowchart_hint = data.flowchart_hint ?? ""
|
||||
problem.value.flowchart_data = data.flowchart_data
|
||||
if (data.answers && data.answers.length) {
|
||||
problem.value.answers = data.answers
|
||||
} else {
|
||||
problem.value.answers = data.languages.map((lang: LANGUAGE) => ({
|
||||
language: lang,
|
||||
code: "",
|
||||
}))
|
||||
}
|
||||
problem.value.io_mode = data.io_mode
|
||||
if (problem.value.contest_id) {
|
||||
problem.value.contest_id = problem.value.contest_id
|
||||
@@ -161,6 +186,10 @@ async function getProblemDetail() {
|
||||
})
|
||||
// 标签
|
||||
tags.value.select = data.tags
|
||||
} catch (error) {
|
||||
message.error("获取题目失败")
|
||||
router.push({ name: "admin problem list" })
|
||||
}
|
||||
}
|
||||
|
||||
async function getTagList() {
|
||||
@@ -207,9 +236,6 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
||||
const testcases = res.data.info
|
||||
for (let file of testcases) {
|
||||
file.score = (100 / testcases.length).toFixed(0)
|
||||
if (!file.output_name && problem.value.spj) {
|
||||
file.output_name = "-"
|
||||
}
|
||||
}
|
||||
problem.value.test_case_score = testcases
|
||||
problem.value.test_case_id = res.data.id
|
||||
@@ -222,18 +248,23 @@ function downloadTestcases() {
|
||||
download("test_case?problem_id=" + problem.value.id)
|
||||
}
|
||||
|
||||
// Mermaid 渲染事件处理
|
||||
function onMermaidRenderSuccess() {
|
||||
mermaidRenderSuccess.value = true
|
||||
}
|
||||
|
||||
// 题目是否有漏写的
|
||||
function detectProblemCompletion() {
|
||||
let flag = false
|
||||
async function validateProblem() {
|
||||
let hasErrors = false
|
||||
// 标题
|
||||
if (!problem.value._id || !problem.value.title) {
|
||||
message.error("编号或标题没有填写")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 标签
|
||||
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) {
|
||||
message.error("标签没有填写")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 题目
|
||||
else if (
|
||||
@@ -242,12 +273,12 @@ function detectProblemCompletion() {
|
||||
!problem.value.output_description
|
||||
) {
|
||||
message.error("题目或输入或输出没有填写")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 样例
|
||||
else if (problem.value.samples.length == 0) {
|
||||
message.error("样例没有填写")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 样例是空的
|
||||
else if (
|
||||
@@ -256,21 +287,34 @@ function detectProblemCompletion() {
|
||||
)
|
||||
) {
|
||||
message.error("空样例没有删干净")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 测试用例
|
||||
else if (problem.value.test_case_score.length === 0) {
|
||||
message.error("测试用例没有上传")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
} else if (problem.value.languages.length === 0) {
|
||||
message.error("编程语言没有选择")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 流程图验证
|
||||
else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
|
||||
if (
|
||||
!problem.value.mermaid_code ||
|
||||
problem.value.mermaid_code.trim() === ""
|
||||
) {
|
||||
message.error("启用了流程图功能,但流程图代码为空")
|
||||
hasErrors = true
|
||||
} else if (!mermaidRenderSuccess.value) {
|
||||
message.error("Mermaid 代码尚未成功渲染,请检查代码语法")
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
// 通过了
|
||||
else {
|
||||
flag = false
|
||||
hasErrors = false
|
||||
}
|
||||
return flag
|
||||
return hasErrors
|
||||
}
|
||||
|
||||
function getTemplate() {
|
||||
@@ -294,11 +338,18 @@ function filterHint() {
|
||||
}
|
||||
}
|
||||
|
||||
function filterAnswers() {
|
||||
problem.value.answers = problem.value.answers.filter(
|
||||
(ans) => ans.code.trim() !== "",
|
||||
)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const notCompleted = detectProblemCompletion()
|
||||
if (notCompleted) return
|
||||
const hasValidationErrors = await validateProblem()
|
||||
if (hasValidationErrors) return
|
||||
filterHint()
|
||||
getTemplate()
|
||||
filterAnswers()
|
||||
const api = {
|
||||
"admin problem create": createProblem,
|
||||
"admin problem edit": editProblem,
|
||||
@@ -366,6 +417,19 @@ watch(
|
||||
problem.value.tags = uniqueTags
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => problem.value.languages,
|
||||
(langs) => {
|
||||
const answers = langs.map((lang) => {
|
||||
const existing = problem.value.answers.find(
|
||||
(ans) => ans.language === lang,
|
||||
)
|
||||
return existing || { language: lang, code: "" }
|
||||
})
|
||||
problem.value.answers = answers
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -453,21 +517,89 @@ watch(
|
||||
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
||||
添加用例
|
||||
</n-button>
|
||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示" />
|
||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
|
||||
<n-form>
|
||||
<n-form-item label="题目的来源">
|
||||
<n-form-item label="题目的来源(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.source"
|
||||
placeholder="比如来自某道题的改编等,或者网上的资料"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
|
||||
<n-input
|
||||
v-model:value="problem.prompt"
|
||||
placeholder="比如考察选择、循环、算法等知识点"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form v-if="needTemplate">
|
||||
<n-form-item label="编写代码模板">
|
||||
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">代码区域</h2>
|
||||
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="编程语言">
|
||||
<n-checkbox-group v-model:value="problem.languages">
|
||||
<n-flex align="center">
|
||||
<n-checkbox
|
||||
v-for="(language, index) in languageOptions"
|
||||
:key="index"
|
||||
:value="language.value"
|
||||
:label="language.label"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-checkbox
|
||||
v-model:checked="needTemplate"
|
||||
label="预制代码(显示在编辑器中,帮助快速上手)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button
|
||||
v-if="needTemplate"
|
||||
size="small"
|
||||
tertiary
|
||||
type="warning"
|
||||
@click="resetTemplate(currentActiveTemplate)"
|
||||
>
|
||||
重置 {{ LANGUAGE_SHOW_VALUE[currentActiveTemplate] }} 的预制代码
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-grid :cols="2" x-gap="20">
|
||||
<n-gi>
|
||||
<n-form>
|
||||
<n-form-item label="本题参考答案(选填,用于 AI 分析,不会泄露)">
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="C"
|
||||
class="template box"
|
||||
default-value="Python3"
|
||||
v-model:value="currentActiveAnswer"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="(answer, index) in problem.answers"
|
||||
:key="index"
|
||||
:name="answer.language"
|
||||
>
|
||||
<CodeEditor
|
||||
v-model:value="answer.code"
|
||||
:language="answer.language"
|
||||
:font-size="16"
|
||||
height="300px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form v-if="needTemplate">
|
||||
<n-form-item label="编写预制代码">
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="Python3"
|
||||
v-model:value="currentActiveTemplate"
|
||||
>
|
||||
<n-tab-pane
|
||||
@@ -479,13 +611,44 @@ watch(
|
||||
v-model:value="template[lang]"
|
||||
:language="lang"
|
||||
:font-size="16"
|
||||
height="200px"
|
||||
height="300px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">流程图区域</h2>
|
||||
|
||||
<!-- 流程图相关设置 -->
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="允许提交流程图">
|
||||
<n-switch v-model:value="problem.allow_flowchart" />
|
||||
</n-form-item>
|
||||
<n-form-item label="显示标准流程图">
|
||||
<n-switch v-model:value="problem.show_flowchart" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-form>
|
||||
<n-form-item label="流程图">
|
||||
<MermaidEditor
|
||||
v-model="problem.mermaid_code"
|
||||
@render-success="onMermaidRenderSuccess"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="流程图提示信息(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.flowchart_hint"
|
||||
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-divider />
|
||||
<n-alert
|
||||
class="box"
|
||||
v-if="problem.test_case_score.length"
|
||||
@@ -511,44 +674,12 @@ watch(
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-space style="margin-bottom: 100px" justify="space-between">
|
||||
<n-form inline label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="语言">
|
||||
<n-checkbox-group v-model:value="problem.languages">
|
||||
<n-flex align="center">
|
||||
<n-checkbox
|
||||
v-for="(language, index) in languageOptions"
|
||||
:key="index"
|
||||
:value="language.value"
|
||||
:label="language.label"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-checkbox
|
||||
v-model:checked="needTemplate"
|
||||
label="代码模板(一般用不到)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button
|
||||
v-if="needTemplate"
|
||||
size="small"
|
||||
tertiary
|
||||
type="warning"
|
||||
@click="resetTemplate(currentActiveTemplate)"
|
||||
>
|
||||
重置 {{ LANGUAGE_SHOW_VALUE[currentActiveTemplate] }} 代码模板
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex align="center">
|
||||
<n-flex style="margin-bottom: 120px" align="center" justify="end">
|
||||
<n-tooltip placement="left">
|
||||
<template #trigger>
|
||||
<n-button text>温馨提醒</n-button>
|
||||
</template>
|
||||
【测试用例】最好要有10个,要考虑边界情况,不要跟【测试样例】一模一样
|
||||
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||
</n-tooltip>
|
||||
<div>
|
||||
<n-upload
|
||||
@@ -561,7 +692,6 @@ watch(
|
||||
</div>
|
||||
<n-button type="primary" @click="submit">提交</n-button>
|
||||
</n-flex>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -588,8 +718,4 @@ watch(
|
||||
.addSamples {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template {
|
||||
width: 60%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime, filterEmptyValue } from "~/utils/functions"
|
||||
import { AdminProblemFiltered } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { AdminProblemFiltered } from "utils/types"
|
||||
import { getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Modal from "./components/Modal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
|
||||
interface Props {
|
||||
contestID?: string
|
||||
@@ -30,10 +33,16 @@ const [show, toggleShow] = useToggle()
|
||||
const { count, inc } = useCounter(0)
|
||||
const total = ref(0)
|
||||
const problems = ref<AdminProblemFiltered[]>([])
|
||||
const query = reactive({
|
||||
limit: parseInt(<string>route.query.limit) || 10,
|
||||
page: parseInt(<string>route.query.page) || 1,
|
||||
keyword: <string>route.query.keyword ?? "",
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
author: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
@@ -50,6 +59,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
minWidth: 80,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
@@ -61,34 +71,24 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 260,
|
||||
width: 330,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemID: row.id,
|
||||
problemDisplayID: row._id,
|
||||
onDeleted: listProblems,
|
||||
onUpdated: listProblems,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
function routerPush() {
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: filterEmptyValue(query),
|
||||
})
|
||||
}
|
||||
|
||||
async function listProblems() {
|
||||
query.keyword = <string>route.query.keyword ?? ""
|
||||
query.page = parseInt(<string>route.query.page) || 1
|
||||
query.limit = parseInt(<string>route.query.limit) || 10
|
||||
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.author,
|
||||
props.contestID,
|
||||
)
|
||||
total.value = res.total
|
||||
@@ -118,28 +118,15 @@ async function selectProblems() {
|
||||
}
|
||||
|
||||
onMounted(listProblems)
|
||||
watch(() => query.page, routerPush)
|
||||
watch(
|
||||
() => [query.limit, query.keyword],
|
||||
() => {
|
||||
query.page = 1
|
||||
routerPush()
|
||||
},
|
||||
)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
() => {
|
||||
query.page = 1
|
||||
routerPush()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watch(
|
||||
() => route.name === "admin problem list" && route.query,
|
||||
(newVal) => {
|
||||
if (newVal) listProblems()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listProblems, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,10 +150,19 @@ watch(
|
||||
type="primary"
|
||||
@click="selectProblems"
|
||||
>
|
||||
从题库中选择
|
||||
从题目中选择
|
||||
</n-button>
|
||||
<n-flex align="center" v-if="!props.contestID">
|
||||
<span>出题人</span>
|
||||
<AuthorSelect v-model:value="query.author" all />
|
||||
</n-flex>
|
||||
<div>
|
||||
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" />
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
105
src/admin/problemset/components/Actions.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
problemSetId: number
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["updated"])
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showStatusModal = ref(false)
|
||||
const newStatus = ref<"active" | "archived" | "draft">("active")
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
async function handleDeleteProblemSet() {
|
||||
try {
|
||||
await deleteProblemSet(props.problemSetId)
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
message.error("删除失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin problemset edit",
|
||||
params: { problemSetId: props.problemSetId },
|
||||
})
|
||||
}
|
||||
|
||||
function goDetail() {
|
||||
router.push({
|
||||
name: "admin problemset detail",
|
||||
params: { problemSetId: props.problemSetId },
|
||||
})
|
||||
}
|
||||
|
||||
function openStatusModal() {
|
||||
showStatusModal.value = true
|
||||
}
|
||||
|
||||
async function handleUpdateStatus() {
|
||||
try {
|
||||
await updateProblemSetStatus(props.problemSetId, newStatus.value)
|
||||
message.success("状态更新成功")
|
||||
showStatusModal.value = false
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
message.error("状态更新失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" secondary type="primary" @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="small" secondary type="info" @click="goDetail">
|
||||
详情
|
||||
</n-button>
|
||||
<n-button size="small" secondary type="warning" @click="openStatusModal">
|
||||
状态
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="handleDeleteProblemSet">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="error">删除</n-button>
|
||||
</template>
|
||||
确定删除这个题单吗?删除后题单将不可见。
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showStatusModal"
|
||||
preset="card"
|
||||
title="更新题单状态"
|
||||
style="width: 400px"
|
||||
>
|
||||
<n-space vertical>
|
||||
<n-form>
|
||||
<n-form-item label="状态" required>
|
||||
<n-select
|
||||
v-model:value="newStatus"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showStatusModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
162
src/admin/problemset/components/AddBadgeModal.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
condition_type: "all_problems" | "problem_count" | "score"
|
||||
condition_value?: number
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const newBadgeName = ref("")
|
||||
const newBadgeDescription = ref("")
|
||||
const newBadgeIcon = ref("")
|
||||
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||
"all_problems",
|
||||
)
|
||||
const newBadgeConditionValue = ref(1)
|
||||
|
||||
const BADGE_LEN = 6
|
||||
const badgeIconOptions = []
|
||||
for (let i = 1; i <= BADGE_LEN; i++) {
|
||||
badgeIconOptions.push({
|
||||
label: `奖章${i}`,
|
||||
value: `/badge-${i}.png`,
|
||||
icon: `/badge-${i}.png`,
|
||||
})
|
||||
}
|
||||
|
||||
const conditionTypeOptions = [
|
||||
{ label: "完成所有题目", value: "all_problems" },
|
||||
{ label: "完成指定数量题目", value: "problem_count" },
|
||||
{ label: "达到指定分数", value: "score" },
|
||||
]
|
||||
|
||||
function handleConfirm() {
|
||||
const data: any = {
|
||||
name: newBadgeName.value,
|
||||
description: newBadgeDescription.value,
|
||||
icon: newBadgeIcon.value,
|
||||
condition_type: newBadgeConditionType.value,
|
||||
}
|
||||
|
||||
// 只有非"完成所有题目"时才添加条件值
|
||||
if (newBadgeConditionType.value !== "all_problems") {
|
||||
data.condition_value = newBadgeConditionValue.value
|
||||
}
|
||||
|
||||
emit("confirm", data)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
newBadgeName.value = ""
|
||||
newBadgeDescription.value = ""
|
||||
newBadgeIcon.value = ""
|
||||
newBadgeConditionType.value = "all_problems"
|
||||
newBadgeConditionValue.value = 1
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="添加奖章"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="奖章名称" required>
|
||||
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="newBadgeDescription"
|
||||
type="textarea"
|
||||
placeholder="奖章描述"
|
||||
required
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="图标" required>
|
||||
<n-flex align="center" gap="small">
|
||||
<div
|
||||
v-for="option in badgeIconOptions"
|
||||
:key="option.value"
|
||||
@click="newBadgeIcon = option.value"
|
||||
:style="{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border:
|
||||
newBadgeIcon === option.value
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor:
|
||||
newBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
}"
|
||||
>
|
||||
<n-image
|
||||
:src="option.icon"
|
||||
width="50"
|
||||
height="50"
|
||||
object-fit="cover"
|
||||
preview-disabled
|
||||
style="border-radius: 2px"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-flex align="center">
|
||||
<n-form-item label="获得条件">
|
||||
<n-select
|
||||
style="width: 200px"
|
||||
v-model:value="newBadgeConditionType"
|
||||
:options="conditionTypeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="条件值"
|
||||
v-if="newBadgeConditionType !== 'all_problems'"
|
||||
>
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
v-model:value="newBadgeConditionValue"
|
||||
placeholder="条件值"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
103
src/admin/problemset/components/AddProblemModal.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
problem_id: string
|
||||
order: number
|
||||
is_required: boolean
|
||||
score: number
|
||||
hint: string
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const newProblemId = ref("")
|
||||
const newProblemOrder = ref(0)
|
||||
const newProblemRequired = ref(true)
|
||||
const newProblemScore = ref(0)
|
||||
const newProblemHint = ref("")
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm", {
|
||||
problem_id: newProblemId.value,
|
||||
order: newProblemOrder.value,
|
||||
is_required: newProblemRequired.value,
|
||||
score: newProblemScore.value,
|
||||
hint: newProblemHint.value,
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
newProblemId.value = ""
|
||||
newProblemOrder.value = 0
|
||||
newProblemRequired.value = true
|
||||
newProblemScore.value = 0
|
||||
newProblemHint.value = ""
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="添加题目"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="题目ID" required>
|
||||
<n-input
|
||||
v-model:value="newProblemId"
|
||||
placeholder="请输入题目的显示ID(如:1001)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="newProblemOrder"
|
||||
placeholder="题目在题单中的顺序"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="newProblemRequired" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分数">
|
||||
<n-input-number
|
||||
v-model:value="newProblemScore"
|
||||
placeholder="题目分数"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="提示">
|
||||
<n-input
|
||||
v-model:value="newProblemHint"
|
||||
type="textarea"
|
||||
placeholder="题目提示"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
96
src/admin/problemset/components/BadgeManagement.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { ProblemSetBadge } from "utils/types"
|
||||
import { NButton, NImage } from "naive-ui"
|
||||
|
||||
interface Props {
|
||||
badges: ProblemSetBadge[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "add-badge"): void
|
||||
(e: "edit-badge", badge: ProblemSetBadge): void
|
||||
(e: "delete-badge", badgeId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<h3>奖章列表</h3>
|
||||
<n-button type="primary" @click="$emit('add-badge')"> 添加奖章 </n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{
|
||||
title: '图标',
|
||||
key: 'icon',
|
||||
render: (row) =>
|
||||
h(NImage, {
|
||||
src: row.icon,
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: 'cover',
|
||||
previewDisabled: true,
|
||||
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
|
||||
}),
|
||||
},
|
||||
{ title: '名称', key: 'name' },
|
||||
{
|
||||
title: '条件类型',
|
||||
key: 'condition_type',
|
||||
render: (row) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
all_problems: '完成所有题目',
|
||||
problem_count: '完成指定数量题目',
|
||||
score: '达到指定分数',
|
||||
}
|
||||
return typeMap[row.condition_type] || row.condition_type
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '条件值',
|
||||
key: 'condition_value',
|
||||
render: (row) => {
|
||||
return row.condition_type === 'all_problems'
|
||||
? '-'
|
||||
: row.condition_value
|
||||
},
|
||||
},
|
||||
{ title: '描述', key: 'description' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) =>
|
||||
h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
secondary: true,
|
||||
onClick: () => $emit('edit-badge', row),
|
||||
},
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => $emit('delete-badge', row.id),
|
||||
},
|
||||
{ default: () => '删除' },
|
||||
),
|
||||
]),
|
||||
},
|
||||
]"
|
||||
:data="badges"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
167
src/admin/problemset/components/EditBadgeModal.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { ProblemSetBadge } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
badge: ProblemSetBadge | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
condition_type: "all_problems" | "problem_count" | "score"
|
||||
condition_value?: number
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const editBadgeName = ref("")
|
||||
const editBadgeDescription = ref("")
|
||||
const editBadgeIcon = ref("")
|
||||
const editBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||
"all_problems",
|
||||
)
|
||||
const editBadgeConditionValue = ref(1)
|
||||
|
||||
// 预设奖章图标选项
|
||||
const BADGE_LEN = 6
|
||||
const badgeIconOptions = []
|
||||
for (let i = 1; i <= BADGE_LEN; i++) {
|
||||
badgeIconOptions.push({
|
||||
label: `奖章${i}`,
|
||||
value: `/badge-${i}.png`,
|
||||
icon: `/badge-${i}.png`,
|
||||
})
|
||||
}
|
||||
|
||||
const conditionTypeOptions = [
|
||||
{ label: "完成所有题目", value: "all_problems" },
|
||||
{ label: "完成指定数量题目", value: "problem_count" },
|
||||
{ label: "达到指定分数", value: "score" },
|
||||
]
|
||||
|
||||
function handleConfirm() {
|
||||
const data: any = {
|
||||
name: editBadgeName.value,
|
||||
description: editBadgeDescription.value,
|
||||
icon: editBadgeIcon.value,
|
||||
condition_type: editBadgeConditionType.value,
|
||||
}
|
||||
|
||||
// 只有非"完成所有题目"时才添加条件值
|
||||
if (editBadgeConditionType.value !== "all_problems") {
|
||||
data.condition_value = editBadgeConditionValue.value
|
||||
}
|
||||
|
||||
emit("confirm", data)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 当奖章数据变化时,更新表单数据
|
||||
watch(
|
||||
() => props.badge,
|
||||
(newBadge) => {
|
||||
if (newBadge) {
|
||||
editBadgeName.value = newBadge.name
|
||||
editBadgeDescription.value = newBadge.description
|
||||
editBadgeIcon.value = newBadge.icon
|
||||
editBadgeConditionType.value = newBadge.condition_type
|
||||
editBadgeConditionValue.value = newBadge.condition_value
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="编辑奖章"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form v-if="badge">
|
||||
<n-form-item label="奖章名称" required>
|
||||
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="editBadgeDescription"
|
||||
type="textarea"
|
||||
placeholder="奖章描述"
|
||||
required
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="图标" required>
|
||||
<n-flex align="center" gap="small">
|
||||
<div
|
||||
v-for="option in badgeIconOptions"
|
||||
:key="option.value"
|
||||
@click="editBadgeIcon = option.value"
|
||||
:style="{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border:
|
||||
editBadgeIcon === option.value
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor:
|
||||
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
}"
|
||||
>
|
||||
<n-image
|
||||
:src="option.icon"
|
||||
width="50"
|
||||
height="50"
|
||||
object-fit="cover"
|
||||
style="border-radius: 2px"
|
||||
preview-disabled
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-flex align="center">
|
||||
<n-form-item label="获得条件">
|
||||
<n-select
|
||||
style="width: 200px"
|
||||
v-model:value="editBadgeConditionType"
|
||||
:options="conditionTypeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="条件值"
|
||||
v-if="editBadgeConditionType !== 'all_problems'"
|
||||
>
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
v-model:value="editBadgeConditionValue"
|
||||
placeholder="条件值"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
100
src/admin/problemset/components/EditProblemModal.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ProblemSetProblem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
problem: ProblemSetProblem | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
order: number
|
||||
is_required: boolean
|
||||
score: number
|
||||
hint: string
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const editProblemOrder = ref(0)
|
||||
const editProblemRequired = ref(true)
|
||||
const editProblemScore = ref(0)
|
||||
const editProblemHint = ref("")
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm", {
|
||||
order: editProblemOrder.value,
|
||||
is_required: editProblemRequired.value,
|
||||
score: editProblemScore.value,
|
||||
hint: editProblemHint.value || "",
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 当问题数据变化时,更新表单数据
|
||||
watch(
|
||||
() => props.problem,
|
||||
(newProblem) => {
|
||||
if (newProblem) {
|
||||
editProblemOrder.value = newProblem.order
|
||||
editProblemRequired.value = newProblem.is_required
|
||||
editProblemScore.value = newProblem.score
|
||||
editProblemHint.value = newProblem.hint || ""
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="编辑题目"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form v-if="problem">
|
||||
<n-form-item label="题目标题">
|
||||
<n-input :value="problem.problem.title" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="editProblemOrder"
|
||||
placeholder="题目在题单中的顺序"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="editProblemRequired" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分数">
|
||||
<n-input-number
|
||||
v-model:value="editProblemScore"
|
||||
placeholder="题目分数"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="提示">
|
||||
<n-input
|
||||
v-model:value="editProblemHint"
|
||||
type="textarea"
|
||||
placeholder="题目提示"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
73
src/admin/problemset/components/ProblemManagement.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||
import { ProblemSetProblem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
problems: ProblemSetProblem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "add-problem"): void
|
||||
(e: "edit-problem", problem: ProblemSetProblem): void
|
||||
(e: "remove-problem", problemSetProblemId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<h3>题目列表</h3>
|
||||
<n-button type="primary" @click="$emit('add-problem')">
|
||||
添加题目
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{ title: '题目ID', key: 'problem._id', width: 80 },
|
||||
{ title: '题目标题', key: 'problem.title', minWidth: 200 },
|
||||
{ title: '顺序', key: 'order', width: 80 },
|
||||
{
|
||||
title: '必做',
|
||||
key: 'is_required',
|
||||
width: 80,
|
||||
render: (row) => (row.is_required ? '是' : '否'),
|
||||
},
|
||||
{ title: '分数', key: 'score', width: 80 },
|
||||
{ title: '提示', key: 'hint', minWidth: 200 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) =>
|
||||
h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
secondary: true,
|
||||
onClick: () => $emit('edit-problem', row),
|
||||
},
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => $emit('remove-problem', row.id),
|
||||
},
|
||||
{ default: () => '移除' },
|
||||
),
|
||||
]),
|
||||
},
|
||||
]"
|
||||
:data="problems"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
70
src/admin/problemset/components/ProblemSetInfo.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { parseTime } from "utils/functions"
|
||||
import { ProblemSet } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
problemSet: ProblemSet
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card title="题单信息" style="margin-bottom: 16px">
|
||||
<n-descriptions :column="4" bordered>
|
||||
<n-descriptions-item label="描述">
|
||||
{{ problemSet.description }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建者">
|
||||
{{ problemSet.created_by.username }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="难度">
|
||||
<n-tag
|
||||
:type="
|
||||
problemSet.difficulty === 'Easy'
|
||||
? 'success'
|
||||
: problemSet.difficulty === 'Medium'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
>
|
||||
{{
|
||||
problemSet.difficulty === "Easy"
|
||||
? "简单"
|
||||
: problemSet.difficulty === "Medium"
|
||||
? "中等"
|
||||
: "困难"
|
||||
}}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag
|
||||
:type="
|
||||
problemSet.status === 'active'
|
||||
? 'success'
|
||||
: problemSet.status === 'archived'
|
||||
? 'default'
|
||||
: 'info'
|
||||
"
|
||||
>
|
||||
{{
|
||||
problemSet.status === "active"
|
||||
? "活跃"
|
||||
: problemSet.status === "archived"
|
||||
? "已归档"
|
||||
: "草稿"
|
||||
}}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="可见">
|
||||
{{ problemSet.visible ? "是" : "否" }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="题目数量">
|
||||
{{ problemSet.problems_count }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss") }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</template>
|
||||
69
src/admin/problemset/components/ProgressManagement.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { ProblemSetProgress } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
progress: ProblemSetProgress[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "remove-user", userId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 定义表格列
|
||||
const progressColumns = [
|
||||
{ title: "用户", key: "user.username", width: 120 },
|
||||
{
|
||||
title: "加入时间",
|
||||
key: "join_time",
|
||||
width: 180,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{ title: "已完成", key: "completed_problems_count", width: 100 },
|
||||
{ title: "总题目", key: "total_problems_count", width: 100 },
|
||||
{
|
||||
title: "进度",
|
||||
key: "progress_percentage",
|
||||
width: 100,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
`${row.progress_percentage.toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
title: "是否完成",
|
||||
key: "is_completed",
|
||||
width: 100,
|
||||
render: (row: ProblemSetProgress) => (row.is_completed ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "error",
|
||||
secondary: true,
|
||||
onClick: () => emit("remove-user", row.user.id),
|
||||
},
|
||||
{ default: () => "移除" },
|
||||
),
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<h3>用户进度</h3>
|
||||
</n-flex>
|
||||
<n-data-table :columns="progressColumns" :data="progress" />
|
||||
</div>
|
||||
</template>
|
||||
270
src/admin/problemset/detail.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
|
||||
import {
|
||||
ProblemSet,
|
||||
ProblemSetProblem,
|
||||
ProblemSetBadge,
|
||||
ProblemSetProgress,
|
||||
} from "utils/types"
|
||||
import {
|
||||
getProblemSetDetail,
|
||||
getProblemSetProblems,
|
||||
getProblemSetBadges,
|
||||
getProblemSetProgress,
|
||||
addProblemToSet,
|
||||
editProblemInSet,
|
||||
removeProblemFromSet,
|
||||
createProblemSetBadge,
|
||||
editProblemSetBadge,
|
||||
deleteProblemSetBadge,
|
||||
removeUserFromProblemSet,
|
||||
} from "../api"
|
||||
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
|
||||
import ProblemManagement from "./components/ProblemManagement.vue"
|
||||
import BadgeManagement from "./components/BadgeManagement.vue"
|
||||
import ProgressManagement from "./components/ProgressManagement.vue"
|
||||
import AddProblemModal from "./components/AddProblemModal.vue"
|
||||
import EditProblemModal from "./components/EditProblemModal.vue"
|
||||
import AddBadgeModal from "./components/AddBadgeModal.vue"
|
||||
import EditBadgeModal from "./components/EditBadgeModal.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||
|
||||
const problemSet = ref<ProblemSet | null>(null)
|
||||
const problems = ref<ProblemSetProblem[]>([])
|
||||
const badges = ref<ProblemSetBadge[]>([])
|
||||
const progress = ref<ProblemSetProgress[]>([])
|
||||
|
||||
// 模态框状态
|
||||
const showAddProblemModal = ref(false)
|
||||
const showEditProblemModal = ref(false)
|
||||
const showAddBadgeModal = ref(false)
|
||||
const showEditBadgeModal = ref(false)
|
||||
|
||||
// 编辑数据
|
||||
const editingProblem = ref<ProblemSetProblem | null>(null)
|
||||
const editingBadge = ref<ProblemSetBadge | null>(null)
|
||||
|
||||
async function loadProblemSetDetail() {
|
||||
try {
|
||||
const res = await getProblemSetDetail(problemSetId.value)
|
||||
problemSet.value = res.data
|
||||
} catch (err: any) {
|
||||
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProblems() {
|
||||
try {
|
||||
const res = await getProblemSetProblems(problemSetId.value)
|
||||
problems.value = res.data
|
||||
} catch (err: any) {
|
||||
message.error("加载题目列表失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBadges() {
|
||||
try {
|
||||
const res = await getProblemSetBadges(problemSetId.value)
|
||||
badges.value = res.data
|
||||
} catch (err: any) {
|
||||
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
try {
|
||||
const res = await getProblemSetProgress(problemSetId.value)
|
||||
progress.value = res.data
|
||||
} catch (err: any) {
|
||||
message.error("加载进度列表失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddProblem(data: any) {
|
||||
try {
|
||||
await addProblemToSet(problemSetId.value, data)
|
||||
message.success("题目添加成功")
|
||||
showAddProblemModal.value = false
|
||||
loadProblems()
|
||||
loadProblemSetDetail() // 刷新题目数量
|
||||
} catch (err: any) {
|
||||
message.error("添加题目失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveProblem(problemSetProblemId: number) {
|
||||
try {
|
||||
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
|
||||
message.success("题目移除成功")
|
||||
loadProblems()
|
||||
loadProblemSetDetail() // 刷新题目数量
|
||||
} catch (err: any) {
|
||||
message.error("移除题目失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditProblem(data: any) {
|
||||
if (!editingProblem.value) return
|
||||
|
||||
try {
|
||||
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
|
||||
message.success("题目编辑成功")
|
||||
showEditProblemModal.value = false
|
||||
loadProblems()
|
||||
} catch (err: any) {
|
||||
message.error("编辑题目失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddBadge(data: any) {
|
||||
try {
|
||||
await createProblemSetBadge(problemSetId.value, data)
|
||||
message.success("奖章创建成功")
|
||||
showAddBadgeModal.value = false
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("创建奖章失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBadge(badgeId: number) {
|
||||
try {
|
||||
await deleteProblemSetBadge(problemSetId.value, badgeId)
|
||||
message.success("奖章删除成功")
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("删除奖章失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditBadge(data: any) {
|
||||
if (!editingBadge.value) return
|
||||
|
||||
try {
|
||||
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
|
||||
message.success("奖章编辑成功")
|
||||
showEditBadgeModal.value = false
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("编辑奖章失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveUser(userId: number) {
|
||||
try {
|
||||
await removeUserFromProblemSet(problemSetId.value, userId)
|
||||
message.success("用户移除成功")
|
||||
loadProgress()
|
||||
} catch (err: any) {
|
||||
message.error("移除用户失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
function openAddProblemModal() {
|
||||
showAddProblemModal.value = true
|
||||
}
|
||||
|
||||
function openAddBadgeModal() {
|
||||
showAddBadgeModal.value = true
|
||||
}
|
||||
|
||||
function openEditProblemModal(problem: ProblemSetProblem) {
|
||||
editingProblem.value = problem
|
||||
showEditProblemModal.value = true
|
||||
}
|
||||
|
||||
function openEditBadgeModal(badge: ProblemSetBadge) {
|
||||
editingBadge.value = badge
|
||||
showEditBadgeModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProblemSetDetail()
|
||||
loadProblems()
|
||||
loadBadges()
|
||||
loadProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="problemSet">
|
||||
<n-flex class="titleWrapper" justify="space-between" align="center">
|
||||
<h2 class="title">{{ problemSet.title }}</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'admin problemset edit',
|
||||
params: { problemSetId },
|
||||
})
|
||||
"
|
||||
>
|
||||
编辑题单
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<ProblemSetInfo :problem-set="problemSet" />
|
||||
|
||||
<n-tabs type="line">
|
||||
<n-tab-pane name="problems" tab="题目管理">
|
||||
<ProblemManagement
|
||||
:problems="problems"
|
||||
@add-problem="openAddProblemModal"
|
||||
@edit-problem="openEditProblemModal"
|
||||
@remove-problem="handleRemoveProblem"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="badges" tab="奖章管理">
|
||||
<BadgeManagement
|
||||
:badges="badges"
|
||||
@add-badge="openAddBadgeModal"
|
||||
@edit-badge="openEditBadgeModal"
|
||||
@delete-badge="handleDeleteBadge"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="progress" tab="进度管理">
|
||||
<ProgressManagement
|
||||
:progress="progress"
|
||||
@remove-user="handleRemoveUser"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
<AddProblemModal
|
||||
v-model:show="showAddProblemModal"
|
||||
@confirm="handleAddProblem"
|
||||
/>
|
||||
|
||||
<EditProblemModal
|
||||
v-model:show="showEditProblemModal"
|
||||
:problem="editingProblem"
|
||||
@confirm="handleEditProblem"
|
||||
/>
|
||||
|
||||
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
|
||||
|
||||
<EditBadgeModal
|
||||
v-model:show="showEditBadgeModal"
|
||||
:badge="editingBadge"
|
||||
@confirm="handleEditBadge"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
149
src/admin/problemset/edit.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { CreateProblemSetData, EditProblemSetData } from "utils/types"
|
||||
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||
const isEdit = computed(() => !!problemSetId.value)
|
||||
|
||||
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
|
||||
title: "",
|
||||
description: "",
|
||||
difficulty: "Easy",
|
||||
status: "draft",
|
||||
visible: false,
|
||||
})
|
||||
|
||||
const difficultyOptions = [
|
||||
{ label: "简单", value: "Easy" },
|
||||
{ label: "中等", value: "Medium" },
|
||||
{ label: "困难", value: "Hard" },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadProblemSetDetail() {
|
||||
if (!isEdit.value) return
|
||||
|
||||
try {
|
||||
const res = await getProblemSetDetail(problemSetId.value)
|
||||
const data = res.data
|
||||
formData.value = {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
difficulty: data.difficulty,
|
||||
status: data.status,
|
||||
visible: data.visible,
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.title?.trim()) {
|
||||
message.error("请输入题单标题")
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.description?.trim()) {
|
||||
message.error("请输入题单描述")
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await editProblemSet(formData.value as EditProblemSetData)
|
||||
message.success("题单更新成功")
|
||||
} else {
|
||||
await createProblemSet(formData.value as CreateProblemSetData)
|
||||
message.success("题单创建成功")
|
||||
}
|
||||
router.push({ name: "admin problemset list" })
|
||||
} catch (err: any) {
|
||||
message.error(
|
||||
(isEdit.value ? "更新" : "创建") +
|
||||
"题单失败:" +
|
||||
(err.data || "未知错误"),
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit.value) {
|
||||
loadProblemSetDetail()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="title">{{ isEdit ? "编辑题单" : "创建题单" }}</h2>
|
||||
|
||||
<n-form :model="formData" label-placement="top">
|
||||
<n-flex>
|
||||
<n-form-item label="题单标题" required>
|
||||
<n-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入题单标题"
|
||||
maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="难度">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
v-model:value="formData.difficulty"
|
||||
:options="difficultyOptions"
|
||||
placeholder="选择难度"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
v-model:value="formData.status"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="isEdit" label="是否可见">
|
||||
<n-switch v-model:value="formData.visible" />
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
|
||||
<n-form-item label="题单描述" required>
|
||||
<n-input
|
||||
v-model:value="formData.description"
|
||||
type="textarea"
|
||||
placeholder="请输入题单描述"
|
||||
:rows="4"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
{{ isEdit ? "更新" : "创建" }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
</style>
|
||||
209
src/admin/problemset/list.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { ProblemSetList } from "utils/types"
|
||||
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import { NTag, NSwitch } from "naive-ui"
|
||||
|
||||
const total = ref(0)
|
||||
const problemSets = ref<ProblemSetList[]>([])
|
||||
|
||||
interface ProblemSetQuery {
|
||||
keyword: string
|
||||
difficulty: string
|
||||
status: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemSetQuery>({
|
||||
keyword: "",
|
||||
difficulty: "",
|
||||
status: "",
|
||||
})
|
||||
|
||||
const difficultyOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "简单", value: "Easy" },
|
||||
{ label: "中等", value: "Medium" },
|
||||
{ label: "困难", value: "Hard" },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<ProblemSetList>[] = [
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
|
||||
{
|
||||
title: "创建者",
|
||||
key: "created_by",
|
||||
width: 120,
|
||||
render: (row) => row.created_by.username,
|
||||
},
|
||||
{
|
||||
title: "难度",
|
||||
key: "difficulty",
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const difficultyMap = {
|
||||
Easy: { type: "success" as const, text: "简单" },
|
||||
Medium: { type: "warning" as const, text: "中等" },
|
||||
Hard: { type: "error" as const, text: "困难" },
|
||||
}
|
||||
const config = difficultyMap[row.difficulty]
|
||||
return h(NTag, { type: config.type }, { default: () => config.text })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const statusMap = {
|
||||
active: { type: "success" as const, text: "活跃" },
|
||||
archived: { type: "default" as const, text: "已归档" },
|
||||
draft: { type: "info" as const, text: "草稿" },
|
||||
}
|
||||
const config = statusMap[row.status]
|
||||
return h(NTag, { type: config.type }, { default: () => config.text })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "题目数量",
|
||||
key: "problems_count",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 300,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemSetId: row.id,
|
||||
onUpdated: listProblemSets,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
async function listProblemSets() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemSetList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.difficulty,
|
||||
query.status,
|
||||
)
|
||||
total.value = res.data.total
|
||||
problemSets.value = res.data.results
|
||||
}
|
||||
|
||||
async function toggleVisible(problemSetId: number) {
|
||||
await toggleProblemSetVisible(problemSetId)
|
||||
problemSets.value = problemSets.value.map((it) => {
|
||||
if (it.id === problemSetId) {
|
||||
it.visible = !it.visible
|
||||
}
|
||||
return it
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(listProblemSets)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listProblemSets, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit, query.difficulty, query.status],
|
||||
listProblemSets,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" justify="space-between">
|
||||
<n-flex align="center">
|
||||
<h2 class="title">题单管理</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin problemset create' })"
|
||||
>
|
||||
新建题单
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<n-flex align="center">
|
||||
<span>难度:</span>
|
||||
<n-select
|
||||
v-model:value="query.difficulty"
|
||||
:options="difficultyOptions"
|
||||
placeholder="选择难度"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span>状态:</span>
|
||||
<n-select
|
||||
v-model:value="query.status"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="problemSets" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Server } from "~/utils/types"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Server } from "utils/types"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useConfigWebSocket } from "shared/composables/websocket"
|
||||
import {
|
||||
deleteJudgeServer,
|
||||
editWebsite,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
listInvalidTestcases,
|
||||
pruneInvalidTestcases,
|
||||
} from "../api"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
interface Testcase {
|
||||
id: string
|
||||
@@ -17,6 +20,21 @@ interface Testcase {
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
const { updateConfig } = useConfigWebSocket()
|
||||
|
||||
// 确保只有登录用户才能使用WebSocket
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
(isAuthed) => {
|
||||
if (!isAuthed) {
|
||||
// 如果用户未登录,禁用WebSocket功能
|
||||
console.warn("用户未登录,WebSocket配置更新功能已禁用")
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const testcaseColumns: DataTableColumn<Testcase>[] = [
|
||||
{ title: "测试用例 ID", key: "id" },
|
||||
@@ -99,13 +117,14 @@ const abnormalServers = computed(() =>
|
||||
)
|
||||
|
||||
const websiteConfig = reactive({
|
||||
website_base_url: import.meta.env.VITE_OJ_URL,
|
||||
website_base_url: import.meta.env.PUBLIC_OJ_URL,
|
||||
website_name: "判题狗",
|
||||
website_name_shortcut: "判题狗",
|
||||
website_footer: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
|
||||
allow_register: true,
|
||||
submission_list_show_all: true,
|
||||
class_list: [],
|
||||
enable_maxkb: true,
|
||||
})
|
||||
|
||||
async function getWebsiteConfig() {
|
||||
@@ -117,12 +136,21 @@ async function getWebsiteConfig() {
|
||||
websiteConfig.allow_register = res.data.allow_register
|
||||
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
|
||||
websiteConfig.class_list = res.data.class_list
|
||||
websiteConfig.enable_maxkb = res.data.enable_maxkb
|
||||
}
|
||||
|
||||
async function saveWebsiteConfig() {
|
||||
await editWebsite(websiteConfig)
|
||||
message.success("网站配置保存成功")
|
||||
getWebsiteConfig()
|
||||
configStore.getConfig()
|
||||
|
||||
// 通过 WebSocket 广播配置变化,实现实时切换
|
||||
updateConfig("enable_maxkb", websiteConfig.enable_maxkb)
|
||||
updateConfig(
|
||||
"submission_list_show_all",
|
||||
websiteConfig.submission_list_show_all,
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteTestcase(id?: string) {
|
||||
@@ -198,6 +226,10 @@ onMounted(() => {
|
||||
<span>显示所有提交</span>
|
||||
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span>启用AI小助手</span>
|
||||
<n-switch v-model:value="websiteConfig.enable_maxkb" />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card class="box">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from "naive-ui"
|
||||
import { getRank } from "oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { getACRate } from "~/utils/functions"
|
||||
import { Rank } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { getACRate } from "utils/functions"
|
||||
import { Rank } from "utils/types"
|
||||
import { getBaseInfo, randomUser10 } from "../api"
|
||||
|
||||
const userCount = ref(0)
|
||||
@@ -13,7 +13,6 @@ const contestCount = ref(0)
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(false)
|
||||
const luckyGuy = ref("")
|
||||
const data = ref<Rank[]>([])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteTutorial } from "~/admin/api"
|
||||
import { deleteTutorial } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
tutorialID: number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "~/shared/components/MarkdownEditor.vue"
|
||||
import { Tutorial } from "~/utils/types"
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||
import { Tutorial } from "utils/types"
|
||||
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
||||
|
||||
interface Props {
|
||||
@@ -87,7 +87,11 @@ onMounted(init)
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number style="width: 100px" v-model:value="tutorial.order" :min="0" />
|
||||
<n-input-number
|
||||
style="width: 100px"
|
||||
v-model:value="tutorial.order"
|
||||
:min="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="tutorial.is_public" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Tutorial } from "~/utils/types"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Tutorial } from "utils/types"
|
||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { editUser } from "~/admin/api"
|
||||
import { User } from "~/utils/types"
|
||||
import { editUser } from "admin/api"
|
||||
import { User } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { getUserRole } from "~/utils/functions"
|
||||
import { User } from "~/utils/types"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { getUserRole } from "utils/functions"
|
||||
import { User } from "utils/types"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const isAdmin = computed(() => props.user.admin_type !== "Regular User")
|
||||
const isNotRegularUser = computed(
|
||||
() => props.user.admin_type !== USER_TYPE.REGULAR_USER,
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
@@ -14,12 +18,19 @@ const isAdmin = computed(() => props.user.admin_type !== "Regular User")
|
||||
封号中
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-if="isAdmin"
|
||||
v-if="isNotRegularUser"
|
||||
:type="getUserRole(props.user.admin_type).type"
|
||||
size="small"
|
||||
>
|
||||
{{ getUserRole(props.user.admin_type).tagString }}
|
||||
{{ getUserRole(props.user.admin_type).label }}
|
||||
</n-tag>
|
||||
{{ props.user.username }}
|
||||
<n-tag size="small" v-if="props.user.admin_type === USER_TYPE.ADMIN">
|
||||
{{
|
||||
props.user.problem_permission === PROBLEM_PERMISSION.ALL
|
||||
? "全部"
|
||||
: "仅自己"
|
||||
}}
|
||||
</n-tag>
|
||||
<TextCopy>{{ props.user.username }}</TextCopy>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { importUsers } from "../api"
|
||||
|
||||
const message = useMessage()
|
||||
const prefix = ref("")
|
||||
const prefix = ref(0)
|
||||
const rawInput = ref("")
|
||||
const [needKs] = useToggle(true)
|
||||
const [loading, toggleLoading] = useToggle()
|
||||
const users = shallowRef<string[][]>([])
|
||||
|
||||
@@ -13,25 +12,17 @@ function generateUsers() {
|
||||
message.info("请填写相关内容")
|
||||
return false
|
||||
}
|
||||
// 自动加上 ks 的开头
|
||||
let myClass = ""
|
||||
if (prefix.value) {
|
||||
if (needKs.value && !prefix.value.startsWith("ks")) {
|
||||
myClass = "ks" + prefix.value
|
||||
} else {
|
||||
myClass = prefix.value
|
||||
}
|
||||
}
|
||||
let className = !!prefix.value ? `ks${prefix.value}` : ""
|
||||
rawInput.value = rawInput.value.trim()
|
||||
const inputs = rawInput.value.split("\n")
|
||||
users.value = inputs.map((u, i) => {
|
||||
const username = myClass + u
|
||||
const username = className + u
|
||||
let password = ""
|
||||
for (let j = 0; j < 6; j++) {
|
||||
password += "123456789".charAt(Math.floor(Math.random() * 9))
|
||||
}
|
||||
const realName = u
|
||||
const email = `${myClass}.${i + 1}@example.com`
|
||||
const email = `${className}.${i + 1}@example.com`
|
||||
return [username, password, email, realName]
|
||||
})
|
||||
return true
|
||||
@@ -68,14 +59,16 @@ async function submit() {
|
||||
<n-space>
|
||||
<n-flex vertical>
|
||||
<n-flex align="center">
|
||||
<n-switch v-model:value="needKs" />
|
||||
<span>前面带上 ks</span>
|
||||
</n-flex>
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
<div style="width: 18px; font-size: 1.2rem">ks</div>
|
||||
<n-input-number
|
||||
style="width: 170px"
|
||||
v-model:value="prefix"
|
||||
clearable
|
||||
:max="9999"
|
||||
:min="0"
|
||||
placeholder="班级号"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-input
|
||||
type="textarea"
|
||||
class="inputArea"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { DataTableRowKey, SelectOption } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { User } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { User } from "utils/types"
|
||||
import {
|
||||
deleteUsers,
|
||||
editUser,
|
||||
@@ -12,19 +13,39 @@ import {
|
||||
} from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Name from "./components/Name.vue"
|
||||
import { USER_TYPE } from "~/utils/constants"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
interface UserQuery {
|
||||
keyword: string
|
||||
type: string
|
||||
orderBy: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<UserQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
type: useRouteQuery("type", "").value,
|
||||
orderBy: useRouteQuery("orderBy", "").value,
|
||||
})
|
||||
|
||||
const total = ref(0)
|
||||
const users = ref<User[]>([])
|
||||
const userEditing = ref<User | null>(null)
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
keyword: "",
|
||||
admin: false,
|
||||
})
|
||||
|
||||
const adminOptions = [
|
||||
{ label: "全部用户", value: "" },
|
||||
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "默认排序", value: "" },
|
||||
{ label: "最近登录", value: "-last_login" },
|
||||
]
|
||||
const [create, toggleCreate] = useToggle(false)
|
||||
const password = ref("")
|
||||
const userIDs = ref<DataTableRowKey[]>([])
|
||||
@@ -33,17 +54,18 @@ const rowKey = (row: User) => row.id
|
||||
|
||||
const columns: DataTableColumn<User>[] = [
|
||||
{ type: "selection" },
|
||||
{ title: "ID", key: "id", width: 100 },
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 200,
|
||||
width: 220,
|
||||
render: (row) => h(Name, { user: row }),
|
||||
},
|
||||
{
|
||||
title: "密码",
|
||||
key: "raw_password",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.raw_password),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
@@ -60,12 +82,17 @@ const columns: DataTableColumn<User>[] = [
|
||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||
: "从未登录",
|
||||
},
|
||||
{ title: "真名", key: "real_name", width: 100 },
|
||||
{
|
||||
title: "真名",
|
||||
key: "real_name",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.real_name),
|
||||
},
|
||||
{ title: "邮箱", key: "email", width: 200 },
|
||||
{
|
||||
key: "actions",
|
||||
title: "选项",
|
||||
width: 260,
|
||||
width: 280,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
user: row,
|
||||
@@ -78,15 +105,27 @@ const columns: DataTableColumn<User>[] = [
|
||||
]
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "普通", value: "Regular User" },
|
||||
{ label: "管理员", value: "Admin" },
|
||||
{ label: "超级管理员", value: "Super Admin" },
|
||||
{ label: "普通", value: USER_TYPE.REGULAR_USER },
|
||||
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
const problemPermissionOptions: SelectOption[] = [
|
||||
{ label: "无权限", value: PROBLEM_PERMISSION.NONE },
|
||||
{ label: "仅管理自己创建", value: PROBLEM_PERMISSION.OWN },
|
||||
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
||||
]
|
||||
|
||||
async function listUsers() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const isAdmin = query.admin ? "1" : "0"
|
||||
const res = await getUserList(offset, query.limit, isAdmin, query.keyword)
|
||||
const res = await getUserList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.type,
|
||||
query.keyword,
|
||||
query.orderBy,
|
||||
)
|
||||
total.value = res.data.total
|
||||
users.value = res.data.results
|
||||
}
|
||||
@@ -102,7 +141,7 @@ async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
|
||||
|
||||
async function onResetPassword(user: User) {
|
||||
const res = await resetPassword(user.id)
|
||||
message.success(`【${user.username}】密码重置为 ${res.data}`)
|
||||
message.success(`【${user.username}】的密码已重置成【${res.data}】`)
|
||||
users.value = users.value.map((it) => {
|
||||
if (it.id === user.id && user.admin_type === USER_TYPE.REGULAR_USER) {
|
||||
it.raw_password = res.data
|
||||
@@ -127,8 +166,8 @@ function createNewUser() {
|
||||
username: "",
|
||||
real_name: "",
|
||||
email: "",
|
||||
admin_type: "Regular User",
|
||||
problem_permission: "",
|
||||
admin_type: "Admin",
|
||||
problem_permission: "None",
|
||||
create_time: new Date(),
|
||||
last_login: new Date(),
|
||||
two_factor_auth: false,
|
||||
@@ -177,7 +216,12 @@ async function handleEditUser() {
|
||||
}
|
||||
|
||||
onMounted(listUsers)
|
||||
watch(query, listUsers, { deep: true })
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -200,10 +244,25 @@ watch(query, listUsers, { deep: true })
|
||||
确定删除选中的用户吗?删除后无法恢复!
|
||||
</n-popconfirm>
|
||||
<n-flex align="center">
|
||||
<span>超管出列</span>
|
||||
<n-switch v-model:value="query.admin" />
|
||||
<n-select
|
||||
v-model:value="query.orderBy"
|
||||
:options="sortOptions"
|
||||
placeholder="排序方式"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.type"
|
||||
:options="adminOptions"
|
||||
placeholder="选择用户类型"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<div>
|
||||
<n-input style="width: 200px" v-model:value="query.keyword" />
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
v-model:value="query.keyword"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
@@ -249,6 +308,17 @@ watch(query, listUsers, { deep: true })
|
||||
<n-form-item-gi v-if="!create" :span="1" label="类型">
|
||||
<n-select v-model:value="userEditing.admin_type" :options="options" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="!create && userEditing.admin_type === USER_TYPE.ADMIN"
|
||||
:span="1"
|
||||
label="出题权限"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="userEditing.problem_permission"
|
||||
:options="problemPermissionOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
|
||||
<n-form-item-gi v-if="!create" :span="1" label="是否封禁">
|
||||
<n-switch v-model:value="userEditing.is_disabled">封号</n-switch>
|
||||
</n-form-item-gi>
|
||||
|
||||
324
src/auto-imports.d.ts
vendored
@@ -1,324 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
||||
const computedEager: typeof import('@vueuse/core')['computedEager']
|
||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createEventHook: typeof import('@vueuse/core')['createEventHook']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createRef: typeof import('@vueuse/core')['createRef']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
|
||||
const h: typeof import('vue')['h']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const isShallow: typeof import('vue')['isShallow']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
|
||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
const throttledRef: typeof import('@vueuse/core')['throttledRef']
|
||||
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
||||
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
|
||||
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
|
||||
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
|
||||
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
|
||||
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
|
||||
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
|
||||
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
|
||||
const useArraySome: typeof import('@vueuse/core')['useArraySome']
|
||||
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
|
||||
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
|
||||
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useBase64: typeof import('@vueuse/core')['useBase64']
|
||||
const useBattery: typeof import('@vueuse/core')['useBattery']
|
||||
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
||||
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
||||
const useCached: typeof import('@vueuse/core')['useCached']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
const useCountdown: typeof import('@vueuse/core')['useCountdown']
|
||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
|
||||
const useCycleList: typeof import('@vueuse/core')['useCycleList']
|
||||
const useDark: typeof import('@vueuse/core')['useDark']
|
||||
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
|
||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
||||
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
|
||||
const useDialog: typeof import('naive-ui')['useDialog']
|
||||
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
|
||||
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
|
||||
const useDraggable: typeof import('@vueuse/core')['useDraggable']
|
||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
||||
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
|
||||
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
|
||||
const useElementHover: typeof import('@vueuse/core')['useElementHover']
|
||||
const useElementSize: typeof import('@vueuse/core')['useElementSize']
|
||||
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
|
||||
const useEventBus: typeof import('@vueuse/core')['useEventBus']
|
||||
const useEventListener: typeof import('@vueuse/core')['useEventListener']
|
||||
const useEventSource: typeof import('@vueuse/core')['useEventSource']
|
||||
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
|
||||
const useFavicon: typeof import('@vueuse/core')['useFavicon']
|
||||
const useFetch: typeof import('@vueuse/core')['useFetch']
|
||||
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
|
||||
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
|
||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
||||
const useFps: typeof import('@vueuse/core')['useFps']
|
||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
|
||||
const useInterval: typeof import('@vueuse/core')['useInterval']
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useMessage: typeof import('naive-ui')['useMessage']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||
const useNotification: typeof import('naive-ui')['useNotification']
|
||||
const useNow: typeof import('@vueuse/core')['useNow']
|
||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
||||
const useOnline: typeof import('@vueuse/core')['useOnline']
|
||||
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
|
||||
const useParallax: typeof import('@vueuse/core')['useParallax']
|
||||
const useParentElement: typeof import('@vueuse/core')['useParentElement']
|
||||
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
||||
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
||||
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
|
||||
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
||||
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
||||
const useScroll: typeof import('@vueuse/core')['useScroll']
|
||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
||||
const useShare: typeof import('@vueuse/core')['useShare']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSorted: typeof import('@vueuse/core')['useSorted']
|
||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
||||
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
|
||||
const useThrottle: typeof import('@vueuse/core')['useThrottle']
|
||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
|
||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
|
||||
const useTitle: typeof import('@vueuse/core')['useTitle']
|
||||
const useToNumber: typeof import('@vueuse/core')['useToNumber']
|
||||
const useToString: typeof import('@vueuse/core')['useToString']
|
||||
const useToggle: typeof import('@vueuse/core')['useToggle']
|
||||
const useTransition: typeof import('@vueuse/core')['useTransition']
|
||||
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
|
||||
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
|
||||
const useVModel: typeof import('@vueuse/core')['useVModel']
|
||||
const useVModels: typeof import('@vueuse/core')['useVModels']
|
||||
const useVibrate: typeof import('@vueuse/core')['useVibrate']
|
||||
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
|
||||
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
|
||||
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
|
||||
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
|
||||
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
|
||||
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
|
||||
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
|
||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
||||
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
|
||||
const watchDeep: typeof import('@vueuse/core')['watchDeep']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
||||
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
|
||||
const watchOnce: typeof import('@vueuse/core')['watchOnce']
|
||||
const watchPausable: typeof import('@vueuse/core')['watchPausable']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
|
||||
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
|
||||
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
|
||||
const whenever: typeof import('@vueuse/core')['whenever']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { DataTableColumn, FormRules, FormItemRule, SelectOption, UploadCustomRequestOptions, UploadFileInfo, MenuOption, DropdownDividerOption, DropdownOption } from 'naive-ui'
|
||||
import('naive-ui')
|
||||
}
|
||||
69
src/components.d.ts
vendored
@@ -1,69 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
|
||||
NCode: typeof import('naive-ui')['NCode']
|
||||
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
NDescriptions: typeof import('naive-ui')['NDescriptions']
|
||||
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
|
||||
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NFlex: typeof import('naive-ui')['NFlex']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
NGi: typeof import('naive-ui')['NGi']
|
||||
NGradientText: typeof import('naive-ui')['NGradientText']
|
||||
NGrid: typeof import('naive-ui')['NGrid']
|
||||
NH1: typeof import('naive-ui')['NH1']
|
||||
NH2: typeof import('naive-ui')['NH2']
|
||||
NH4: typeof import('naive-ui')['NH4']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
||||
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
|
||||
NList: typeof import('naive-ui')['NList']
|
||||
NListItem: typeof import('naive-ui')['NListItem']
|
||||
NMenu: typeof import('naive-ui')['NMenu']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NNumberAnimation: typeof import('naive-ui')['NNumberAnimation']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NPopover: typeof import('naive-ui')['NPopover']
|
||||
NRate: typeof import('naive-ui')['NRate']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSplit: typeof import('naive-ui')['NSplit']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||
NTabs: typeof import('naive-ui')['NTabs']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
NUpload: typeof import('naive-ui')['NUpload']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
16
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_ENV: string
|
||||
readonly PUBLIC_MAXKB_URL: string
|
||||
readonly PUBLIC_OJ_URL: string
|
||||
readonly PUBLIC_CODE_URL: string
|
||||
readonly PUBLIC_JUDGE0_URL: string
|
||||
readonly PUBLIC_ICONIFY_URL: string
|
||||
readonly PUBLIC_SIGNALING_URL: string
|
||||
readonly PUBLIC_WS_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
72
src/main.ts
@@ -8,34 +8,78 @@ import storage from "utils/storage"
|
||||
import App from "./App.vue"
|
||||
import { admins, ojs } from "./routes"
|
||||
|
||||
import { toggleLogin } from "./shared/composables/modal"
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [ojs, admins],
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const pinia = createPinia()
|
||||
|
||||
// 创建 app 并安装插件
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// 现在可以安全地使用 Store
|
||||
import { useAuthModalStore } from "./shared/store/authModal"
|
||||
import { useUserStore } from "./shared/store/user"
|
||||
|
||||
const authStore = useAuthModalStore()
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 检查是否需要认证
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
toggleLogin(true)
|
||||
authStore.openLoginModal()
|
||||
next("/")
|
||||
} else {
|
||||
next()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (
|
||||
to.matched.some(
|
||||
(record) =>
|
||||
record.meta.requiresSuperAdmin || record.meta.requiresProblemPermission,
|
||||
)
|
||||
) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
authStore.openLoginModal()
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.user) {
|
||||
try {
|
||||
await userStore.getMyProfile()
|
||||
} catch (error) {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
}
|
||||
if (to.matched.some((record) => record.meta.requiresSuperAdmin)) {
|
||||
if (!userStore.isSuperAdmin) {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
} else if (
|
||||
to.matched.some((record) => record.meta.requiresProblemPermission)
|
||||
) {
|
||||
if (!userStore.hasProblemPermission) {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.mount("#app")
|
||||
|
||||
if (!!import.meta.env.VITE_ICONIFY_URL) {
|
||||
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||
addAPIProvider("", {
|
||||
resources: [import.meta.env.VITE_ICONIFY_URL],
|
||||
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
|
||||
})
|
||||
}
|
||||
|
||||
104
src/oj/ai/analysis.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.fetching" :delay="50">
|
||||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
||||
<n-gi :span="1">
|
||||
<n-flex vertical size="large">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
||||
<n-select
|
||||
style="width: 140px"
|
||||
:options="options"
|
||||
v-model:value="aiStore.duration"
|
||||
/>
|
||||
</n-flex>
|
||||
<Overview />
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20">
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<DifficultyGradeChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<TagsRadarChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<RankDistributionChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<TimeActivityHeatmap />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<SolvedTable />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="1">
|
||||
<n-flex vertical size="large">
|
||||
<Heatmap />
|
||||
<ProgressChart />
|
||||
<EfficiencyChart />
|
||||
<DurationChart />
|
||||
<AI v-if="aiStore.detailsData.solved.length > 10" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="2">
|
||||
<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 { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import TagsRadarChart from "./components/TagsRadarChart.vue"
|
||||
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
|
||||
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
|
||||
import RankDistributionChart from "./components/RankDistributionChart.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 EfficiencyChart from "./components/EfficiencyChart.vue"
|
||||
import AI from "./components/AI.vue"
|
||||
import SolvedTable from "./components/SolvedTable.vue"
|
||||
import { useAIStore } from "../store/ai"
|
||||
import { DURATION_OPTIONS } from "utils/constants"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const options = [...DURATION_OPTIONS]
|
||||
|
||||
const subOptions = computed<Duration>(() => {
|
||||
let dur = options.find((it) => it.value === aiStore.duration) ?? options[0]
|
||||
const x = dur.value!.toString().split(":")
|
||||
const unit = x[0]
|
||||
const n = x[1]
|
||||
return { [unit]: parseInt(n) } as Duration
|
||||
})
|
||||
|
||||
const start = computed(() => {
|
||||
const current = new Date()
|
||||
return formatISO(sub(current, subOptions.value))
|
||||
})
|
||||
|
||||
const end = computed(() => {
|
||||
return formatISO(new Date())
|
||||
})
|
||||
|
||||
// 获取热力图数据(仅一次)
|
||||
onMounted(() => {
|
||||
aiStore.fetchHeatmapData()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
69
src/oj/ai/components/AI.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<n-card size="small">
|
||||
<template #header>
|
||||
<div class="cool-title">
|
||||
<span class="title-text">AI 帮你分析</span>
|
||||
</div>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.ai" :delay="50">
|
||||
<div class="container">
|
||||
<MdPreview :model-value="aiStore.mdContent" />
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
watch(
|
||||
() => aiStore.loading.fetching,
|
||||
(isLoading) => {
|
||||
if (!isLoading) {
|
||||
aiStore.fetchAIAnalysis()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.cool-title {
|
||||
position: relative;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2, #f093fb);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.8px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: gradient-flow 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-flow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 200px;
|
||||
}
|
||||
:deep(.md-editor-preview h1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
144
src/oj/ai/components/DifficultyGradeChart.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<n-card title="难度掌握情况" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
了解不同难度题目的完成等级分布
|
||||
</n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Bar :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import type { Grade } from "utils/types"
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
// 难度和等级的顺序(后端返回的是中文)
|
||||
const difficultyOrder = ["简单", "中等", "困难"]
|
||||
const gradeOrder: Grade[] = ["S", "A", "B", "C"]
|
||||
|
||||
// 统计每个难度-等级组合的题目数量
|
||||
const matrix = computed(() => {
|
||||
const result: { [difficulty: string]: { [grade: string]: number } } = {}
|
||||
|
||||
// 初始化矩阵
|
||||
difficultyOrder.forEach((diff) => {
|
||||
result[diff] = {}
|
||||
gradeOrder.forEach((grade) => {
|
||||
result[diff][grade] = 0
|
||||
})
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
aiStore.detailsData.solved.forEach((item) => {
|
||||
const diff = item.difficulty
|
||||
const grade = item.grade
|
||||
if (diff && grade && result[diff]) {
|
||||
result[diff][grade]++
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const show = computed(() => {
|
||||
return aiStore.detailsData.solved.length > 0
|
||||
})
|
||||
|
||||
// 为每个等级准备数据集
|
||||
const data = computed(() => {
|
||||
// 为每个等级生成一个 dataset
|
||||
const datasets = gradeOrder.map((grade) => {
|
||||
return {
|
||||
label: `等级 ${grade}`,
|
||||
data: difficultyOrder.map((diff) => matrix.value[diff][grade]),
|
||||
backgroundColor: getGradeColor(grade),
|
||||
borderColor: getGradeColor(grade),
|
||||
borderWidth: 1,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels: difficultyOrder,
|
||||
datasets,
|
||||
}
|
||||
})
|
||||
|
||||
// 根据等级返回对应的颜色
|
||||
function getGradeColor(grade: Grade): string {
|
||||
const colors: { [key in Grade]: string } = {
|
||||
S: "#FF6384",
|
||||
A: "#FFCE56",
|
||||
B: "#36A2EB",
|
||||
C: "#95F204",
|
||||
}
|
||||
return colors[grade]
|
||||
}
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index" as const,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "题目数量",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer: (items: any[]) => {
|
||||
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
|
||||
return `该难度总计: ${total} 题`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
203
src/oj/ai/components/DurationChart.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="bar" :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
import { Chart } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
LineController,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
// 注册混合图表(Bar + Line)所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
LineController,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
|
||||
const title = computed(() => {
|
||||
if (aiStore.duration === "months:2") {
|
||||
return "过去两个月的每周综合情况"
|
||||
} else if (aiStore.duration === "months:6") {
|
||||
return "过去半年的每月综合情况"
|
||||
} else if (aiStore.duration === "years:1") {
|
||||
return "过去一年的每月综合情况"
|
||||
} else {
|
||||
return "过去四周的综合情况"
|
||||
}
|
||||
})
|
||||
|
||||
const data = computed<ChartData<"bar" | "line">>(() => {
|
||||
return {
|
||||
labels: aiStore.durationData.map((duration) => {
|
||||
let prefix = "周"
|
||||
if (duration.unit === "months") {
|
||||
prefix = "月"
|
||||
}
|
||||
return [
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~")
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
type: "bar",
|
||||
label: "完成题目数",
|
||||
data: aiStore.durationData.map((duration) => duration.problem_count),
|
||||
yAxisID: "y",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: "bar",
|
||||
label: "总提交次数",
|
||||
data: aiStore.durationData.map((duration) => duration.submission_count),
|
||||
yAxisID: "y",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "等级",
|
||||
data: aiStore.durationData.map((duration) =>
|
||||
gradeOrder.indexOf(duration.grade || "C"),
|
||||
),
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
barThickness: 10,
|
||||
order: 1,
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed<ChartOptions<"bar" | "line">>(() => {
|
||||
return {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "数量",
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
y1: {
|
||||
type: "linear",
|
||||
position: "right",
|
||||
min: -0.5,
|
||||
max: gradeOrder.length - 0.5,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (v) => {
|
||||
const idx = Number(v)
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "等级",
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: TooltipItem<"bar" | "line">) => {
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||
const idx = Number(ctx.parsed.y)
|
||||
return `${dsLabel}: ${gradeOrder[idx] || ""}`
|
||||
}
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
},
|
||||
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||||
const barItems = items.filter(
|
||||
(item) => (item.dataset as any).yAxisID === "y",
|
||||
)
|
||||
if (barItems.length >= 2) {
|
||||
const problemCount =
|
||||
barItems.find((item) => item.dataset.label === "完成题目数")
|
||||
?.parsed.y || 0
|
||||
const submissionCount =
|
||||
barItems.find((item) => item.dataset.label === "总提交次数")
|
||||
?.parsed.y || 0
|
||||
const efficiency =
|
||||
submissionCount > 0
|
||||
? ((problemCount / submissionCount) * 100).toFixed(1)
|
||||
: "0"
|
||||
return `AC率: ${efficiency}%`
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
234
src/oj/ai/components/EfficiencyChart.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">反映刷题质量提升</n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="line" :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
import { Chart } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
// 注册折线图所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const title = computed(() => {
|
||||
if (aiStore.duration === "months:2") {
|
||||
return "过去两个月的每周提交效率"
|
||||
} else if (aiStore.duration === "months:6") {
|
||||
return "过去半年的每月提交效率"
|
||||
} else if (aiStore.duration === "years:1") {
|
||||
return "过去一年的每月提交效率"
|
||||
} else {
|
||||
return "过去四周的提交效率"
|
||||
}
|
||||
})
|
||||
|
||||
// 判断是否有数据
|
||||
const show = computed(() => {
|
||||
return aiStore.durationData.length > 0
|
||||
})
|
||||
|
||||
// 计算提交效率数据
|
||||
const efficiencyData = computed(() => {
|
||||
return aiStore.durationData.map((duration) => {
|
||||
const problemCount = duration.problem_count || 0
|
||||
const submissionCount = duration.submission_count || 0
|
||||
|
||||
// 计算效率:提交次数/完成题目数
|
||||
// 值越接近1,说明一次AC率越高
|
||||
const efficiency = problemCount > 0 ? submissionCount / problemCount : 0
|
||||
|
||||
// 计算一次AC率(百分比)
|
||||
const onePassRate =
|
||||
problemCount > 0 ? (problemCount / submissionCount) * 100 : 0
|
||||
|
||||
return {
|
||||
label: [
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~"),
|
||||
efficiency: efficiency,
|
||||
onePassRate: onePassRate,
|
||||
problemCount: problemCount,
|
||||
submissionCount: submissionCount,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 图表数据
|
||||
const data = computed<ChartData<"line">>(() => {
|
||||
const efficiency = efficiencyData.value
|
||||
|
||||
return {
|
||||
labels: efficiency.map((e) => e.label),
|
||||
datasets: [
|
||||
{
|
||||
label: "平均提交次数",
|
||||
data: efficiency.map((e) => e.efficiency),
|
||||
borderColor: "rgb(99, 102, 241)",
|
||||
backgroundColor: "rgba(99, 102, 241, 0.1)",
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: "rgb(99, 102, 241)",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
label: "一次AC率",
|
||||
data: efficiency.map((e) => e.onePassRate),
|
||||
borderColor: "rgb(34, 197, 94)",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: "rgb(34, 197, 94)",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
yAxisID: "y1",
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const options = computed(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
autoSkip: true,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: "linear" as const,
|
||||
position: "left" as const,
|
||||
title: {
|
||||
display: true,
|
||||
text: "平均提交次数(次/题)",
|
||||
font: {
|
||||
size: 13,
|
||||
},
|
||||
},
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function (value: string | number) {
|
||||
return Number(value).toFixed(1)
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: "linear" as const,
|
||||
position: "right" as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
title: {
|
||||
display: true,
|
||||
text: "一次AC率(%)",
|
||||
font: {
|
||||
size: 13,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
callback: function (value: string | number) {
|
||||
return Number(value).toFixed(0) + "%"
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: function (ctx: TooltipItem<"line">) {
|
||||
const index = ctx.dataIndex
|
||||
const item = efficiencyData.value[index]
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
|
||||
if (ctx.datasetIndex === 0) {
|
||||
// 平均提交次数
|
||||
return [
|
||||
`${dsLabel}: ${item.efficiency.toFixed(2)} 次/题`,
|
||||
`完成题目: ${item.problemCount} 题`,
|
||||
`总提交: ${item.submissionCount} 次`,
|
||||
]
|
||||
} else {
|
||||
// 一次AC率
|
||||
return [
|
||||
`${dsLabel}: ${item.onePassRate.toFixed(1)}%`,
|
||||
`提示: 值越高表示刷题质量越好`,
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
53
src/oj/ai/components/Grade.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div align="center" style="display: inline-flex; margin: 0 10px">
|
||||
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
|
||||
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
|
||||
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
|
||||
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-icon size="16" style="cursor: help">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
</template>
|
||||
<div style="max-width: 300px; line-height: 1.4">
|
||||
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
|
||||
<div>使用加权平均方法计算综合等级:</div>
|
||||
<div>• S级 = 4分,A级 = 3分,B级 = 2分,C级 = 1分</div>
|
||||
<div>• 根据平均分数确定最终等级:</div>
|
||||
<div>- S级:≥3.5分</div>
|
||||
<div>- A级:2.5-3.5分</div>
|
||||
<div>- B级:1.5-2.5分</div>
|
||||
<div>- C级:<1.5分</div>
|
||||
</div>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
grade: "S" | "A" | "B" | "C"
|
||||
}>()
|
||||
</script>
|
||||
<style scoped>
|
||||
img {
|
||||
animation: shake 0.5s infinite;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
251
src/oj/ai/components/Heatmap.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<n-card title="过去一年的提交热力图" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
||||
<div class="heatmap-container" ref="containerRef">
|
||||
<svg
|
||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
class="heatmap-svg"
|
||||
>
|
||||
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
|
||||
<text :x="label.x" :y="10" class="label" font-size="10">
|
||||
{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g v-for="(day, i) in WEEK_DAYS" :key="i">
|
||||
<text
|
||||
:x="0"
|
||||
:y="MONTH_HEIGHT + i * CELL_TOTAL + 8"
|
||||
class="label"
|
||||
font-size="9"
|
||||
>
|
||||
{{ day }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g :transform="`translate(${DAY_WIDTH}, ${MONTH_HEIGHT})`">
|
||||
<rect
|
||||
v-for="(cell, i) in cells"
|
||||
:key="i"
|
||||
:x="cell.x"
|
||||
:y="cell.y"
|
||||
:width="CELL_SIZE"
|
||||
:height="CELL_SIZE"
|
||||
:fill="cell.color"
|
||||
class="cell"
|
||||
rx="2"
|
||||
@mouseenter="(e) => showTooltip(e, cell)"
|
||||
@mouseleave="hideTooltip"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
|
||||
<div class="tooltip-date">{{ tooltip.date }}</div>
|
||||
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
|
||||
{{ tooltip.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
const containerRef = ref<HTMLElement>()
|
||||
|
||||
const CELL_SIZE = 12
|
||||
const CELL_GAP = 3
|
||||
const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
||||
const DAY_WIDTH = 20
|
||||
const MONTH_HEIGHT = 20
|
||||
const RIGHT_PADDING = 5
|
||||
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||
|
||||
const getColor = (count: number) =>
|
||||
count === 0
|
||||
? COLORS[0]
|
||||
: count <= 2
|
||||
? COLORS[1]
|
||||
: count <= 4
|
||||
? COLORS[2]
|
||||
: count <= 7
|
||||
? COLORS[3]
|
||||
: COLORS[4]
|
||||
|
||||
const cells = computed(() =>
|
||||
aiStore.heatmapData.map((item, i) => ({
|
||||
date: new Date(item.timestamp),
|
||||
count: item.value,
|
||||
color: getColor(item.value),
|
||||
week: Math.floor(i / 7),
|
||||
day: i % 7,
|
||||
x: Math.floor(i / 7) * CELL_TOTAL,
|
||||
y: (i % 7) * CELL_TOTAL,
|
||||
})),
|
||||
)
|
||||
|
||||
const monthLabels = computed(() => {
|
||||
const labels: { text: string; x: number }[] = []
|
||||
let lastMonth = -1
|
||||
|
||||
cells.value.forEach((cell, i) => {
|
||||
const month = cell.date.getMonth()
|
||||
const isWeekStart = cell.date.getDay() === 0 || i === 0
|
||||
|
||||
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
|
||||
labels.push({
|
||||
text: `${month + 1}月`,
|
||||
x: DAY_WIDTH + cell.week * CELL_TOTAL,
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
})
|
||||
|
||||
return labels
|
||||
})
|
||||
|
||||
const svgWidth = computed(
|
||||
() =>
|
||||
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
|
||||
)
|
||||
|
||||
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
|
||||
|
||||
interface Cell {
|
||||
date: Date
|
||||
count: number
|
||||
color: string
|
||||
week: number
|
||||
day: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const tooltip = ref<{
|
||||
x: number
|
||||
y: number
|
||||
date: string
|
||||
text: string
|
||||
count: number
|
||||
} | null>(null)
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltip.value?.x}px`,
|
||||
top: `${tooltip.value?.y}px`,
|
||||
}))
|
||||
|
||||
const getTooltipText = (count: number) =>
|
||||
count === 0 ? "没有提交记录" : `提交了 ${count} 次`
|
||||
|
||||
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
const containerRect = containerRef.value?.getBoundingClientRect()
|
||||
|
||||
if (containerRect) {
|
||||
tooltip.value = {
|
||||
x: rect.left - containerRect.left + rect.width / 2,
|
||||
y: rect.top - containerRect.top - 10,
|
||||
date: parseTime(cell.date, "YYYY年M月D日"),
|
||||
text: getTooltipText(cell.count),
|
||||
count: cell.count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
tooltip.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-container {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.label {
|
||||
fill: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cell {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
stroke: rgba(0, 0, 0, 0.05);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
stroke: rgba(0, 0, 0, 0.3);
|
||||
stroke-width: 1.5;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-date {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tooltip-count {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tooltip-count.active {
|
||||
color: #7bc96f;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-100% - 5px));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
src/oj/ai/components/Overview.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
>
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>你一共解决 </span>
|
||||
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
|
||||
<span> 道题</span>
|
||||
<span v-if="aiStore.detailsData.contest_count > 0">
|
||||
,并且参加
|
||||
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
|
||||
次比赛
|
||||
</span>
|
||||
<span>,综合评价给到</span>
|
||||
<Grade :grade="aiStore.detailsData.grade" />
|
||||
<span>{{ greeting }}</span>
|
||||
</n-alert>
|
||||
<n-flex vertical size="large" v-else>
|
||||
<n-alert type="error" title="你还没有完成任何题目">
|
||||
开始解题,看看你的学习能力吧!
|
||||
</n-alert>
|
||||
<AI />
|
||||
</n-flex>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import Grade from "./Grade.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import AI from "./AI.vue"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (aiStore.duration.includes("hours")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
|
||||
} else if (aiStore.duration.includes("days")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
|
||||
} else if (
|
||||
aiStore.duration.includes("weeks") ||
|
||||
aiStore.duration.includes("months")
|
||||
) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
|
||||
} else {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
|
||||
}
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
return {
|
||||
S: "要不试试高难度题目?",
|
||||
A: "你很棒,继续保持!",
|
||||
B: "请再接再厉!",
|
||||
C: "你还需要努力!",
|
||||
}[aiStore.detailsData.grade]
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.charming {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
271
src/oj/ai/components/ProgressChart.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">追踪学习成长轨迹</n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="line" :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
import { Chart } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
Filler,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Grade } from "utils/types"
|
||||
|
||||
// 注册折线图所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
Filler,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
const gradeColors: Record<Grade, string> = {
|
||||
C: "#95F204",
|
||||
B: "#36A2EB",
|
||||
A: "#FFCE56",
|
||||
S: "#FF6384",
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (aiStore.duration === "months:2") {
|
||||
return "过去两个月的进步曲线"
|
||||
} else if (aiStore.duration === "months:6") {
|
||||
return "过去半年的进步曲线"
|
||||
} else if (aiStore.duration === "years:1") {
|
||||
return "过去一年的进步曲线"
|
||||
} else {
|
||||
return "过去四周的进步曲线"
|
||||
}
|
||||
})
|
||||
|
||||
// 判断是否有数据
|
||||
const show = computed(() => {
|
||||
return aiStore.durationData.length > 0
|
||||
})
|
||||
|
||||
// 计算累计题目数量和等级趋势
|
||||
const progressData = computed(() => {
|
||||
let cumulativeCount = 0
|
||||
let totalWeightedGrade = 0 // 累计加权等级
|
||||
let totalProblems = 0 // 累计题目总数
|
||||
|
||||
return aiStore.durationData.map((duration) => {
|
||||
const problemCount = duration.problem_count || 0
|
||||
cumulativeCount += problemCount
|
||||
|
||||
// 计算本期等级的权重值
|
||||
const currentGradeValue = gradeOrder.indexOf(duration.grade || "C")
|
||||
|
||||
// 累加加权等级
|
||||
totalWeightedGrade += currentGradeValue * problemCount
|
||||
totalProblems += problemCount
|
||||
|
||||
// 计算累计平均等级
|
||||
const avgGradeValue =
|
||||
totalProblems > 0 ? totalWeightedGrade / totalProblems : 0
|
||||
|
||||
return {
|
||||
label: [
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~"),
|
||||
start: parseTime(duration.start, "YYYY-MM-DD"),
|
||||
end: parseTime(duration.end, "YYYY-MM-DD"),
|
||||
count: cumulativeCount,
|
||||
grade: duration.grade || "C",
|
||||
gradeValue: currentGradeValue,
|
||||
avgGradeValue: avgGradeValue, // 累计平均等级
|
||||
problemCount: problemCount,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 图表数据
|
||||
const data = computed<ChartData<"line">>(() => {
|
||||
const progress = progressData.value
|
||||
|
||||
return {
|
||||
labels: progress.map((p) => p.label),
|
||||
datasets: [
|
||||
{
|
||||
type: "line",
|
||||
label: "累计完成题目",
|
||||
data: progress.map((p) => p.count),
|
||||
borderColor: "#4CAF50",
|
||||
backgroundColor: "rgba(76, 175, 80, 0.1)",
|
||||
tension: 0.4,
|
||||
yAxisID: "y",
|
||||
fill: true,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: "#4CAF50",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "累计平均等级",
|
||||
data: progress.map((p) => p.avgGradeValue),
|
||||
borderColor: "#FF9800",
|
||||
backgroundColor: "rgba(255, 152, 0, 0.1)",
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
fill: false,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const options = computed<ChartOptions<"line">>(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 15,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: "linear",
|
||||
position: "left",
|
||||
title: {
|
||||
display: true,
|
||||
text: "累计题目数",
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
y1: {
|
||||
type: "linear",
|
||||
position: "right",
|
||||
min: -0.5,
|
||||
max: gradeOrder.length - 0.5,
|
||||
title: {
|
||||
display: true,
|
||||
text: "累计平均等级",
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (v: string | number) => {
|
||||
const idx = Math.round(Number(v))
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
title: (items: TooltipItem<"line">[]) => {
|
||||
if (items.length > 0) {
|
||||
const idx = items[0].dataIndex
|
||||
const progress = progressData.value[idx]
|
||||
return progress ? `${progress.start} ~ ${progress.end}` : ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
label: (ctx: TooltipItem<"line">) => {
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
const idx = ctx.dataIndex
|
||||
const progress = progressData.value[idx]
|
||||
|
||||
if (!progress) {
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
}
|
||||
|
||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||
// 累计平均等级轴
|
||||
const avgIdx = Math.round(Number(ctx.parsed.y))
|
||||
return [
|
||||
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
||||
`本期等级: ${progress.grade}`,
|
||||
`本期完成: ${progress.problemCount} 题`,
|
||||
]
|
||||
} else {
|
||||
// 累计题目数轴
|
||||
return [
|
||||
`${dsLabel}: ${ctx.formattedValue} 题`,
|
||||
`本期完成: ${progress.problemCount} 题`,
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
132
src/oj/ai/components/RankDistributionChart.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<n-card title="解题排名分布" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">了解解题速度和竞争力</n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Pie :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Pie } from "vue-chartjs"
|
||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
|
||||
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
// 排名区间定义
|
||||
const RANK_RANGES = [
|
||||
{ label: "前10%", min: 0, max: 10, color: "#FF6384" },
|
||||
{ label: "10-30%", min: 10, max: 30, color: "#FFCE56" },
|
||||
{ label: "30-50%", min: 30, max: 50, color: "#36A2EB" },
|
||||
{ label: "50-70%", min: 50, max: 70, color: "#4BC0C0" },
|
||||
{ label: "70%以后", min: 70, max: 100, color: "#9966FF" },
|
||||
]
|
||||
|
||||
// 计算每道题的排名百分位并分类
|
||||
const rankDistribution = computed(() => {
|
||||
const distribution = RANK_RANGES.map((range) => ({
|
||||
...range,
|
||||
count: 0,
|
||||
problems: [] as string[],
|
||||
}))
|
||||
|
||||
aiStore.detailsData.solved.forEach((item) => {
|
||||
const rank = item.rank
|
||||
const acCount = item.ac_count
|
||||
|
||||
if (rank && acCount && acCount > 0) {
|
||||
// 计算百分位:(rank / acCount) * 100
|
||||
// 例如:第5名/共100人 = 5%
|
||||
const percentile = (rank / acCount) * 100
|
||||
|
||||
// 找到对应的区间
|
||||
const rangeIndex = RANK_RANGES.findIndex(
|
||||
(r) => percentile >= r.min && percentile < r.max,
|
||||
)
|
||||
|
||||
if (rangeIndex !== -1) {
|
||||
distribution[rangeIndex].count++
|
||||
distribution[rangeIndex].problems.push(
|
||||
`${item.problem.display_id}: ${item.problem.title}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return distribution
|
||||
})
|
||||
|
||||
const show = computed(() => {
|
||||
return aiStore.detailsData.solved.length > 0
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
return {
|
||||
labels: RANK_RANGES.map((r) => r.label),
|
||||
datasets: [
|
||||
{
|
||||
label: "题目数量",
|
||||
data: rankDistribution.value.map((r) => r.count),
|
||||
backgroundColor: RANK_RANGES.map((r) => r.color),
|
||||
borderColor: RANK_RANGES.map((r) => r.color),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const count = context.parsed
|
||||
const total = rankDistribution.value.reduce(
|
||||
(sum, r) => sum + r.count,
|
||||
0,
|
||||
)
|
||||
const percentage =
|
||||
total > 0 ? ((count / total) * 100).toFixed(1) : "0.0"
|
||||
const label = context.label || ""
|
||||
return `${label}: ${count} 道题 (${percentage}%)`
|
||||
},
|
||||
afterLabel: (context: any) => {
|
||||
const index = context.dataIndex
|
||||
const problems = rankDistribution.value[index].problems
|
||||
if (problems.length > 0 && problems.length <= 5) {
|
||||
return problems
|
||||
} else if (problems.length > 5) {
|
||||
return [
|
||||
...problems.slice(0, 3),
|
||||
`... 还有 ${problems.length - 3} 道题`,
|
||||
]
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
126
src/oj/ai/components/SolvedTable.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<n-tabs animated v-if="submissions.length && flowcharts.length">
|
||||
<n-tab-pane name="代码提交">
|
||||
<n-data-table
|
||||
v-if="submissions.length"
|
||||
striped
|
||||
:data="submissions"
|
||||
:columns="columns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="流程图提交">
|
||||
<n-data-table
|
||||
v-if="flowcharts.length"
|
||||
striped
|
||||
:data="flowcharts"
|
||||
:columns="flowchartsColumns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-data-table
|
||||
v-if="submissions.length && !flowcharts.length"
|
||||
striped
|
||||
:data="submissions"
|
||||
:columns="columns"
|
||||
:max-height="isDesktop ? 1500 : 500"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import TagTitle from "./TagTitle.vue"
|
||||
import { FlowchartSummary, SolvedProblem } from "utils/types"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const router = useRouter()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const submissions = computed(() => aiStore.detailsData.solved)
|
||||
const flowcharts = computed(() => aiStore.detailsData.flowcharts)
|
||||
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
title: "完成的题目",
|
||||
key: "problem.title",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.problem.contest_id) {
|
||||
router.push(
|
||||
"/contest/" +
|
||||
row.problem.contest_id +
|
||||
"/problem/" +
|
||||
row.problem.display_id,
|
||||
)
|
||||
} else {
|
||||
router.push("/problem/" + row.problem.display_id)
|
||||
}
|
||||
},
|
||||
},
|
||||
() => {
|
||||
if (row.problem.contest_id) {
|
||||
return h(TagTitle, { problem: row.problem })
|
||||
} else {
|
||||
return row.problem.display_id + " " + row.problem.title
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
|
||||
key: "rank",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (row) => row.rank + " / " + row.ac_count,
|
||||
},
|
||||
{
|
||||
title: "等级",
|
||||
key: "grade",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
]
|
||||
|
||||
const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
|
||||
{
|
||||
title: "完成的题目",
|
||||
key: "problem_title",
|
||||
width: 300,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
router.push("/problem/" + row.problem__id)
|
||||
},
|
||||
},
|
||||
() => `${row.problem__id} ${row.problem_title}`,
|
||||
),
|
||||
},
|
||||
{ title: "提交次数", key: "submission_count", width: 100, align: "center" },
|
||||
{
|
||||
title: "最高分",
|
||||
key: "best",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (row) => `${row.best_score} (${row.best_grade})`,
|
||||
},
|
||||
{
|
||||
title: "最新提交时间",
|
||||
key: "latest_submission_time",
|
||||
width: 200,
|
||||
align: "center",
|
||||
render: (row) => parseTime(row.latest_submission_time),
|
||||
},
|
||||
{ title: "平均分", key: "avg_score", width: 100, align: "center" },
|
||||
]
|
||||
</script>
|
||||
177
src/oj/ai/components/StreakStats.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<n-card title="连续做题统计" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||
<n-gi>
|
||||
<n-statistic label="当前连续" :value="currentStreak">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px">天</span>
|
||||
<span
|
||||
v-if="currentStreak > 0"
|
||||
style="font-size: 20px; margin-left: 4px"
|
||||
>
|
||||
🔥
|
||||
</span>
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-statistic label="最长连续" :value="maxStreak">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px">天</span>
|
||||
<span
|
||||
v-if="maxStreak >= 7"
|
||||
style="font-size: 20px; margin-left: 4px"
|
||||
>
|
||||
⭐
|
||||
</span>
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-statistic label="本周做题" :value="weekCount">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px">天</span>
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-statistic label="本月做题" :value="monthCount">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px">天</span>
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<n-flex vertical size="small">
|
||||
<n-text depth="2" style="font-size: 12px">
|
||||
<span v-if="currentStreak === 0"> 开始做题,建立学习连续记录! </span>
|
||||
<span v-else-if="currentStreak < 3"> 继续保持,争取连续3天! </span>
|
||||
<span v-else-if="currentStreak < 7">
|
||||
很棒!继续保持一周连续记录!
|
||||
</span>
|
||||
<span v-else-if="currentStreak < 30">
|
||||
太棒了!坚持满30天将获得「持之以恒」成就!
|
||||
</span>
|
||||
<span v-else>
|
||||
🎉 恭喜你!你已经连续学习 {{ currentStreak }} 天,真的非常厉害!
|
||||
</span>
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
// 计算连续天数
|
||||
const streakData = computed(() => {
|
||||
const heatmap = aiStore.heatmapData
|
||||
if (!heatmap || heatmap.length === 0) {
|
||||
return {
|
||||
currentStreak: 0,
|
||||
maxStreak: 0,
|
||||
weekCount: 0,
|
||||
monthCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间戳排序
|
||||
const sortedData = [...heatmap].sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
let currentStreak = 0
|
||||
let maxStreak = 0
|
||||
let tempStreak = 0
|
||||
let lastDate: Date | null = null
|
||||
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
let weekCount = 0
|
||||
let monthCount = 0
|
||||
|
||||
// 检查今天是否有做题
|
||||
const todayData = sortedData.find((item) => {
|
||||
const itemDate = new Date(item.timestamp)
|
||||
return (
|
||||
itemDate.getFullYear() === today.getFullYear() &&
|
||||
itemDate.getMonth() === today.getMonth() &&
|
||||
itemDate.getDate() === today.getDate()
|
||||
)
|
||||
})
|
||||
const hasToday = todayData && todayData.value > 0
|
||||
|
||||
// 遍历数据计算连续天数
|
||||
for (const item of sortedData) {
|
||||
if (item.value > 0) {
|
||||
const currentDate = new Date(item.timestamp)
|
||||
|
||||
// 统计本周和本月
|
||||
if (currentDate >= weekAgo) {
|
||||
weekCount++
|
||||
}
|
||||
if (currentDate >= monthAgo) {
|
||||
monthCount++
|
||||
}
|
||||
|
||||
if (lastDate === null) {
|
||||
tempStreak = 1
|
||||
} else {
|
||||
const dayDiff = Math.floor(
|
||||
(currentDate.getTime() - lastDate.getTime()) / (24 * 60 * 60 * 1000),
|
||||
)
|
||||
if (dayDiff === 1) {
|
||||
tempStreak++
|
||||
} else {
|
||||
maxStreak = Math.max(maxStreak, tempStreak)
|
||||
tempStreak = 1
|
||||
}
|
||||
}
|
||||
|
||||
lastDate = currentDate
|
||||
}
|
||||
}
|
||||
|
||||
maxStreak = Math.max(maxStreak, tempStreak)
|
||||
|
||||
// 计算当前连续天数(必须包含今天或昨天)
|
||||
if (lastDate) {
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
|
||||
const lastDateOnly = new Date(
|
||||
lastDate.getFullYear(),
|
||||
lastDate.getMonth(),
|
||||
lastDate.getDate(),
|
||||
)
|
||||
|
||||
if (
|
||||
lastDateOnly.getTime() === today.getTime() ||
|
||||
lastDateOnly.getTime() === yesterday.getTime()
|
||||
) {
|
||||
currentStreak = tempStreak
|
||||
} else {
|
||||
currentStreak = 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentStreak,
|
||||
maxStreak,
|
||||
weekCount,
|
||||
monthCount,
|
||||
}
|
||||
})
|
||||
|
||||
const currentStreak = computed(() => streakData.value.currentStreak)
|
||||
const maxStreak = computed(() => streakData.value.maxStreak)
|
||||
const weekCount = computed(() => streakData.value.weekCount)
|
||||
const monthCount = computed(() => streakData.value.monthCount)
|
||||
</script>
|
||||
21
src/oj/ai/components/TagTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<n-flex vertical align="start">
|
||||
<n-flex align="center">
|
||||
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
|
||||
<span>{{ problem.contest_title }}</span>
|
||||
</n-flex>
|
||||
<span>{{ problem.display_id }} {{ problem.title }}</span>
|
||||
</n-flex>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
problem: {
|
||||
title: string
|
||||
display_id: string
|
||||
contest_title: string
|
||||
contest_id: number
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
154
src/oj/ai/components/TagsRadarChart.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">可视化知识点覆盖面</n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Radar :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Radar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
RadialLinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
|
||||
// 注册雷达图所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
RadialLinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
Legend,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const show = computed(() => {
|
||||
return Object.keys(aiStore.detailsData.tags).length > 0
|
||||
})
|
||||
|
||||
// 最多显示前10个标签,避免雷达图过于拥挤
|
||||
const MAX_TAGS = 10
|
||||
|
||||
const title = computed(() => {
|
||||
const totalTags = Object.keys(aiStore.detailsData.tags).length
|
||||
const displayTags = Math.min(totalTags, MAX_TAGS)
|
||||
return `标签雷达图(前${displayTags}个)`
|
||||
})
|
||||
|
||||
// 计算归一化的数据(用于雷达图展示)
|
||||
const normalizedData = computed(() => {
|
||||
const tags = aiStore.detailsData.tags
|
||||
|
||||
// 按题目数量降序排序,取前MAX_TAGS个
|
||||
const sortedTags = Object.entries(tags)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, MAX_TAGS)
|
||||
|
||||
const values = sortedTags.map(([, value]) => value)
|
||||
const maxValue = Math.max(...values, 1) // 避免除以0
|
||||
|
||||
// 归一化到0-100的范围
|
||||
return sortedTags.map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
normalized: (value / maxValue) * 100,
|
||||
}))
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
const tagData = normalizedData.value
|
||||
|
||||
return {
|
||||
labels: tagData.map((item) => item.label),
|
||||
datasets: [
|
||||
{
|
||||
label: "掌握程度",
|
||||
data: tagData.map((item) => item.normalized),
|
||||
backgroundColor: "rgba(99, 102, 241, 0.25)",
|
||||
borderColor: "rgb(99, 102, 241)",
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: "rgb(99, 102, 241)",
|
||||
pointBorderColor: "#fff",
|
||||
pointHoverBackgroundColor: "#fff",
|
||||
pointHoverBorderColor: "rgb(99, 102, 241)",
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
const tagData = normalizedData.value
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
min: 0,
|
||||
ticks: {
|
||||
stepSize: 20,
|
||||
backdropColor: "transparent",
|
||||
callback: function (value: string | number) {
|
||||
return Number(value) + "%"
|
||||
},
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: "rgba(0, 0, 0, 0.1)",
|
||||
circular: true,
|
||||
},
|
||||
angleLines: {
|
||||
color: "rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
pointLabels: {
|
||||
font: {
|
||||
size: 13,
|
||||
weight: 500 as const,
|
||||
},
|
||||
padding: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
const index = context.dataIndex
|
||||
const actualValue = tagData[index].value
|
||||
const percentage = Math.round(Number(context.parsed.r))
|
||||
return `完成 ${actualValue} 道题 (掌握度 ${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
152
src/oj/ai/components/TimeActivityHeatmap.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<n-card title="时间活跃度分析" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">发现最佳学习时段</n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Bar :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||
const TIME_PERIODS = [
|
||||
{ label: "凌晨(0-6)", start: 0, end: 6 },
|
||||
{ label: "上午(6-12)", start: 6, end: 12 },
|
||||
{ label: "下午(12-18)", start: 12, end: 18 },
|
||||
{ label: "晚上(18-24)", start: 18, end: 24 },
|
||||
]
|
||||
|
||||
// 统计每个星期几和时间段的做题数量
|
||||
const activityMatrix = computed(() => {
|
||||
const matrix: { [weekday: number]: { [period: number]: number } } = {}
|
||||
|
||||
// 初始化矩阵
|
||||
for (let i = 0; i < 7; i++) {
|
||||
matrix[i] = {}
|
||||
for (let j = 0; j < TIME_PERIODS.length; j++) {
|
||||
matrix[i][j] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
aiStore.detailsData.solved.forEach((item) => {
|
||||
const date = new Date(item.ac_time)
|
||||
const weekday = date.getDay() // 0-6,0是周日
|
||||
const hour = date.getHours() // 0-23
|
||||
|
||||
// 找到对应的时间段
|
||||
const periodIndex = TIME_PERIODS.findIndex(
|
||||
(p) => hour >= p.start && hour < p.end,
|
||||
)
|
||||
if (periodIndex !== -1) {
|
||||
matrix[weekday][periodIndex]++
|
||||
}
|
||||
})
|
||||
|
||||
return matrix
|
||||
})
|
||||
|
||||
const show = computed(() => {
|
||||
return aiStore.detailsData.solved.length > 0
|
||||
})
|
||||
|
||||
// 为每个时间段准备数据集
|
||||
const data = computed(() => {
|
||||
const datasets = TIME_PERIODS.map((period, periodIndex) => {
|
||||
return {
|
||||
label: period.label,
|
||||
data: WEEKDAYS.map(
|
||||
(_, weekday) => activityMatrix.value[weekday][periodIndex],
|
||||
),
|
||||
backgroundColor: getTimePeriodColor(periodIndex),
|
||||
borderColor: getTimePeriodColor(periodIndex),
|
||||
borderWidth: 1,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels: WEEKDAYS,
|
||||
datasets,
|
||||
}
|
||||
})
|
||||
|
||||
// 根据时间段返回对应的颜色
|
||||
function getTimePeriodColor(periodIndex: number): string {
|
||||
const colors = [
|
||||
"#9D9D9D", // 凌晨 - 灰色
|
||||
"#FFD700", // 上午 - 金色
|
||||
"#4ECDC4", // 下午 - 青色
|
||||
"#5B5F97", // 晚上 - 深蓝紫
|
||||
]
|
||||
return colors[periodIndex] || "#999"
|
||||
}
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index" as const,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "完成题目数",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer: (items: any[]) => {
|
||||
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
|
||||
return `当天总计: ${total} 题`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import { NTag } from "naive-ui"
|
||||
import { getAnnouncement, getAnnouncementList } from "~/oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import { Announcement } from "~/utils/types"
|
||||
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { Announcement } from "utils/types"
|
||||
import TitleWithTag from "./components/TitleWithTag.vue"
|
||||
|
||||
const total = ref(0)
|
||||
const content = ref("")
|
||||
const title = ref("")
|
||||
const [show, toggleShow] = useToggle(false)
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
|
||||
134
src/oj/api.ts
@@ -19,6 +19,7 @@ function filterResult(result: Problem) {
|
||||
rate: getACRate(result.accepted_number, result.submission_number),
|
||||
status: "",
|
||||
author: result.created_by.username,
|
||||
allow_flowchart: result.allow_flowchart,
|
||||
}
|
||||
if (result.my_status === null || result.my_status === undefined) {
|
||||
newResult.status = "not_test"
|
||||
@@ -56,6 +57,14 @@ export async function getProblemList(
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthors(all = false) {
|
||||
return http.get("problem/author", {
|
||||
params: {
|
||||
all: all ? "1" : "0",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getRandomProblemID() {
|
||||
return http.get("pickone")
|
||||
}
|
||||
@@ -84,11 +93,15 @@ export function submitCode(data: SubmitCodePayload) {
|
||||
return http.post("submission", data)
|
||||
}
|
||||
|
||||
export function getSubmissions(params: SubmissionListPayload) {
|
||||
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
||||
return http.get(endpoint, { params })
|
||||
}
|
||||
|
||||
export function getRankOfProblem(problem_id: string) {
|
||||
return http.get("user_problem_rank", { params: { problem_id: problem_id } })
|
||||
}
|
||||
|
||||
export function getTodaySubmissionCount() {
|
||||
return http.get("submissions/today_count")
|
||||
}
|
||||
@@ -240,3 +253,122 @@ export function getTutorial(id: number) {
|
||||
export function getTutorials() {
|
||||
return http.get("tutorials")
|
||||
}
|
||||
|
||||
export function getAIDetailData(start: string, end: string) {
|
||||
return http.get("ai/detail", { params: { start, end } })
|
||||
}
|
||||
|
||||
export function getAIDurationData(end: string, duration: string) {
|
||||
return http.get("ai/duration", { params: { end, duration } })
|
||||
}
|
||||
|
||||
export function getAIHeatmapData() {
|
||||
return http.get("ai/heatmap")
|
||||
}
|
||||
|
||||
// ==================== 流程图相关API ====================
|
||||
|
||||
export function submitFlowchart(data: {
|
||||
problem_id: number
|
||||
mermaid_code: string
|
||||
flowchart_data: any // 这个是压缩之后的,元数据太长了
|
||||
}) {
|
||||
return http.post("flowchart/submission", data)
|
||||
}
|
||||
|
||||
export function getFlowchartSubmission(id: string) {
|
||||
return http.get("flowchart/submission", {
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export function getFlowchartSubmissions(params: {
|
||||
username?: string
|
||||
problem_id?: string
|
||||
myself?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
}) {
|
||||
return http.get("flowchart/submissions", { params })
|
||||
}
|
||||
|
||||
export function retryFlowchartSubmission(submissionId: string) {
|
||||
return http.post("flowchart/submission/retry", {
|
||||
submission_id: submissionId,
|
||||
})
|
||||
}
|
||||
|
||||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||||
return http.get("flowchart/submission/current", {
|
||||
params: { problem_id: problemId },
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 题单相关API ====================
|
||||
|
||||
export function getProblemSetList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword = "",
|
||||
difficulty = "",
|
||||
status = "",
|
||||
) {
|
||||
return http.get("problemset", {
|
||||
params: {
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
difficulty,
|
||||
status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getProblemSetDetail(id: number) {
|
||||
return http.get(`problemset/${id}`)
|
||||
}
|
||||
|
||||
export function getProblemSetProblems(problemSetId: number) {
|
||||
return http.get(`problemset/${problemSetId}/problems`)
|
||||
}
|
||||
|
||||
export function joinProblemSet(problemSetId: number) {
|
||||
return http.post("problemset/progress", {
|
||||
problemset_id: problemSetId,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProblemSetProgress(
|
||||
problemSetId: number,
|
||||
problemId: number,
|
||||
submissionId: string,
|
||||
) {
|
||||
return http.put("problemset/progress", {
|
||||
problemset_id: problemSetId,
|
||||
problem_id: problemId,
|
||||
submission_id: submissionId,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户徽章列表
|
||||
export function getUserBadges(username?: string) {
|
||||
return http.get("user/badges", { params: username ? { username } : {} })
|
||||
}
|
||||
|
||||
// 获取题单徽章列表
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
return http.get(`problemset/${problemSetId}/badges`)
|
||||
}
|
||||
|
||||
// 获取题单用户进度列表
|
||||
export function getProblemSetUserProgress(
|
||||
problemSetId: number,
|
||||
params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
class_name?: string
|
||||
completion_status?: "" | "completed" | "in_progress" | "not_started"
|
||||
},
|
||||
) {
|
||||
return http.get(`problemset/${problemSetId}/users_progress`, { params })
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { STORAGE_KEY } from "~/utils/constants"
|
||||
import storage from "~/utils/storage"
|
||||
import { Code } from "~/utils/types"
|
||||
|
||||
export const code = reactive<Code>({
|
||||
value: "",
|
||||
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3",
|
||||
})
|
||||
|
||||
export const input = ref("")
|
||||
export const output = ref("")
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Problem } from "~/utils/types"
|
||||
|
||||
export const problem = ref<Problem | null>(null)
|
||||
73
src/oj/composables/syncStatus.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref, provide, inject } from "vue"
|
||||
|
||||
/**
|
||||
* 同步状态管理 composable
|
||||
* 使用 provide/inject 模式在组件树中共享状态
|
||||
*/
|
||||
|
||||
export interface SyncStatusState {
|
||||
hadConnection: boolean
|
||||
otherUser?: { name: string; isSuperAdmin: boolean }
|
||||
lastLeftUser?: { name: string; isSuperAdmin: boolean } // 保存离开之人的信息
|
||||
}
|
||||
|
||||
// 提供/注入的 key
|
||||
export const SYNC_STATUS_KEY = Symbol("syncStatus")
|
||||
|
||||
/**
|
||||
* 创建同步状态实例
|
||||
* 每次调用创建新的状态实例
|
||||
*/
|
||||
export function createSyncStatus() {
|
||||
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
|
||||
const hadConnection = ref(false)
|
||||
const lastLeftUser = ref<{ name: string; isSuperAdmin: boolean }>()
|
||||
|
||||
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
|
||||
// 如果之前有其他用户,现在没有了,说明用户离开了
|
||||
if (otherUser.value && !user) {
|
||||
lastLeftUser.value = otherUser.value
|
||||
}
|
||||
otherUser.value = user
|
||||
if (user) {
|
||||
hadConnection.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
otherUser.value = undefined
|
||||
hadConnection.value = false
|
||||
lastLeftUser.value = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
otherUser,
|
||||
hadConnection,
|
||||
lastLeftUser,
|
||||
setOtherUser,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供同步状态到子组件
|
||||
* 在父组件中调用
|
||||
*/
|
||||
export function provideSyncStatus() {
|
||||
const syncStatus = createSyncStatus()
|
||||
provide(SYNC_STATUS_KEY, syncStatus)
|
||||
return syncStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入同步状态
|
||||
* 在子组件中调用,获取父组件提供的状态
|
||||
*/
|
||||
export function injectSyncStatus() {
|
||||
const syncStatus =
|
||||
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)
|
||||
if (!syncStatus) {
|
||||
throw new Error("syncStatus must be provided by a parent component")
|
||||
}
|
||||
return syncStatus
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ContestRank } from "~/utils/types"
|
||||
import { ContestRank } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
rank: ContestRank
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { parseTime } from "utils/functions"
|
||||
import ContestType from "~/shared/components/ContestType.vue"
|
||||
import ContestType from "shared/components/ContestType.vue"
|
||||
|
||||
const contestStore = useContestStore()
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { ContestStatus } from "~/utils/constants"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const contestStore = useContestStore()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const contestMenuVisible = computed(() => {
|
||||
if (contestStore.isContestAdmin) return true
|
||||
if (!contestStore.isPrivate) {
|
||||
|
||||
231
src/oj/contest/components/LineChart.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="chart" v-if="showChart">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Line } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import { ContestRank } from "utils/types"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
)
|
||||
|
||||
interface Props {
|
||||
ranks: ContestRank[]
|
||||
problems: Array<{ id: number; title: string }>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showChart = computed(() => {
|
||||
const hasRanks = props.ranks.length > 0
|
||||
const hasProblems = props.problems.length >= 3
|
||||
return hasProblems && hasRanks
|
||||
})
|
||||
|
||||
// 预定义的颜色方案 - 更现代和可访问的颜色
|
||||
const colorPalette = [
|
||||
"#3B82F6", // 蓝色
|
||||
"#EF4444", // 红色
|
||||
"#10B981", // 绿色
|
||||
"#F59E0B", // 黄色
|
||||
"#8B5CF6", // 紫色
|
||||
"#EC4899", // 粉色
|
||||
"#06B6D4", // 青色
|
||||
"#84CC16", // 青绿色
|
||||
"#F97316", // 橙色
|
||||
"#6366F1", // 靛蓝色
|
||||
]
|
||||
|
||||
// 数据处理函数
|
||||
const processChartData = () => {
|
||||
if (!props.ranks || props.ranks.length === 0) {
|
||||
return {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 获取前10名用户的数据
|
||||
const topUsers = props.ranks.slice(0, 10)
|
||||
|
||||
// 获取所有题目ID(从所有用户的submission_info中收集)
|
||||
const allProblemIds = new Set<string>()
|
||||
topUsers.forEach((rank) => {
|
||||
Object.keys(rank.submission_info).forEach((problemId) => {
|
||||
allProblemIds.add(problemId)
|
||||
})
|
||||
})
|
||||
|
||||
// 按题目ID排序
|
||||
const problemIds = Array.from(allProblemIds).sort()
|
||||
|
||||
// 创建题目标签
|
||||
const labels = problemIds.map((id) => {
|
||||
if (props.problems) {
|
||||
const problem = props.problems.find((p) => p.id.toString() === id)
|
||||
return problem ? problem.title : `题目${id}`
|
||||
}
|
||||
return `题目${id}`
|
||||
})
|
||||
|
||||
// 找到所有用户中最早的提交时间
|
||||
let earliestTime = Infinity
|
||||
topUsers.forEach((rank) => {
|
||||
Object.values(rank.submission_info).forEach((submissionInfo) => {
|
||||
if (submissionInfo.is_ac && submissionInfo.ac_time < earliestTime) {
|
||||
earliestTime = submissionInfo.ac_time
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 如果没有找到任何通过记录,使用0作为基准
|
||||
if (earliestTime === Infinity) {
|
||||
earliestTime = 0
|
||||
}
|
||||
|
||||
// 为每个用户创建数据集
|
||||
const datasets = topUsers.map((rank, userIndex) => {
|
||||
const userData = problemIds.map((problemId) => {
|
||||
const submissionInfo = rank.submission_info[problemId]
|
||||
if (!submissionInfo || !submissionInfo.is_ac) {
|
||||
return null
|
||||
}
|
||||
return submissionInfo.ac_time - earliestTime
|
||||
})
|
||||
|
||||
const actualRank = userIndex + 1
|
||||
const colorIndex = userIndex % colorPalette.length
|
||||
const color = colorPalette[colorIndex]
|
||||
|
||||
return {
|
||||
label: `第${actualRank}名: ${rank.user.username}`,
|
||||
data: userData,
|
||||
borderColor: color,
|
||||
backgroundColor: color + "20",
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
spanGaps: false,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
}
|
||||
}
|
||||
|
||||
// 监听数据变化,重新处理
|
||||
watch(
|
||||
() => [props.ranks, props.problems],
|
||||
() => {
|
||||
if (props.ranks && props.ranks.length > 0) {
|
||||
// 数据变化时重新处理
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
const chartData = computed(() => {
|
||||
return processChartData()
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "top" as const,
|
||||
maxHeight: 80,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
usePointStyle: true,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function (context: any) {
|
||||
return `题目: ${context[0].label}`
|
||||
},
|
||||
label: function (context: any) {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label
|
||||
|
||||
if (value === null) {
|
||||
return `${label}: 未通过`
|
||||
}
|
||||
|
||||
const hours = Math.floor(value / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
const seconds = Math.floor(value % 60)
|
||||
|
||||
let timeStr = ""
|
||||
if (hours > 0) timeStr += `${hours}小时`
|
||||
if (minutes > 0) timeStr += `${minutes}分钟`
|
||||
if (seconds > 0 || timeStr === "") timeStr += `${seconds}秒`
|
||||
|
||||
return `${label}: +${timeStr}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "相对通过时间",
|
||||
},
|
||||
min: 0,
|
||||
ticks: {
|
||||
callback: function (value: any) {
|
||||
const hours = Math.floor(value / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
const seconds = Math.floor(value % 60)
|
||||
|
||||
if (hours > 0) return `+${hours}h${minutes}m`
|
||||
if (minutes > 0) return `+${minutes}m${seconds}s`
|
||||
return `+${seconds}s`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 500px;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useContestStore } from "../store/contest"
|
||||
import ContestInfo from "./components/ContestInfo.vue"
|
||||
import ContestMenu from "./components/ContestMenu.vue"
|
||||
@@ -12,6 +12,8 @@ const props = defineProps<{
|
||||
const contestStore = useContestStore()
|
||||
const message = useMessage()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const password = ref("")
|
||||
|
||||
async function check() {
|
||||
|
||||