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
|
PUBLIC_ENV=dev
|
||||||
VITE_OJ_URL=http://localhost:8000
|
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||||
VITE_CODE_URL=https://code.xuyue.cc
|
PUBLIC_OJ_URL=http://localhost:8000
|
||||||
VITE_JUDGE0_URL=https://judge0api.xuyue.cc
|
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
|
PUBLIC_ENV=xuyue.cc
|
||||||
VITE_OJ_URL=https://oj.xuyue.cc
|
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||||
VITE_CODE_URL=https://code.xuyue.cc
|
PUBLIC_OJ_URL=https://oj.xuyue.cc
|
||||||
VITE_JUDGE0_URL=https://judge0api.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
|
PUBLIC_ENV=school
|
||||||
VITE_OJ_URL=http://10.13.114.114:81
|
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=dd37457027c40b39
|
||||||
VITE_CODE_URL=http://10.13.114.114:82
|
PUBLIC_OJ_URL=http://10.13.114.114:81
|
||||||
VITE_JUDGE0_URL=http://10.13.114.114:8082
|
PUBLIC_CODE_URL=http://10.13.114.114:82
|
||||||
VITE_ICONIFY_URL=http://10.13.114.114:8098
|
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
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.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">
|
<html lang="zh-Hans-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>判题狗</title>
|
<title>判题狗</title>
|
||||||
<link rel="stylesheet" href="/style.css" />
|
<link rel="stylesheet" href="/style.css" />
|
||||||
<script>
|
<script>
|
||||||
window.localStorage.setItem("maxkbMaskTip", true)
|
window.localStorage.setItem("maxkbMaskTip", true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<% if (process.env.PUBLIC_MAXKB_URL) { %>
|
||||||
<script
|
<script
|
||||||
async
|
async
|
||||||
defer
|
defer
|
||||||
src="%VITE_MAXKB_URL%"
|
src="<%= process.env.PUBLIC_MAXKB_URL %>"
|
||||||
></script>
|
></script>
|
||||||
|
<% } %>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
5293
package-lock.json
generated
62
package.json
@@ -1,46 +1,58 @@
|
|||||||
{
|
{
|
||||||
"name": "oj-next",
|
"name": "oj-next",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "rsbuild dev",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "rsbuild build",
|
||||||
"build:staging": "vue-tsc && vite build --mode=staging",
|
"build:staging": "rsbuild build --env-mode=staging",
|
||||||
"preview": "vite preview",
|
"build:test": "rsbuild build --env-mode=test",
|
||||||
"fmt": "prettier --write src *.ts"
|
"fmt": "prettier --write src *.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@vueuse/core": "^13.7.0",
|
"@vue-flow/background": "^1.3.2",
|
||||||
"@wangeditor-next/editor": "^5.6.43",
|
"@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",
|
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.13.2",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.4",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"copy-text-to-clipboard": "^3.2.0",
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"md-editor-v3": "^5.8.4",
|
"md-editor-v3": "^6.2.1",
|
||||||
"naive-ui": "^2.42.0",
|
"mermaid": "^11.12.2",
|
||||||
|
"naive-ui": "^2.43.2",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.19",
|
"vue": "^3.5.26",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-codemirror": "^6.1.1",
|
"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": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.0",
|
"@iconify/vue": "^5.0.0",
|
||||||
|
"@rsbuild/core": "^1.6.15",
|
||||||
|
"@rsbuild/plugin-vue": "^1.2.2",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^25.0.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"prettier": "^3.7.4",
|
||||||
"prettier": "^3.6.2",
|
"typescript": "^5.9.3",
|
||||||
"typescript": "^5.9.2",
|
"unplugin-auto-import": "^20.3.0",
|
||||||
"unplugin-auto-import": "^20.0.0",
|
"unplugin-vue-components": "^30.0.0"
|
||||||
"unplugin-vue-components": "^29.0.0",
|
|
||||||
"vite": "^7.1.3",
|
|
||||||
"vue-tsc": "^3.0.6"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 |
@@ -1,4 +1,16 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Monaco";
|
font-family: "Monaco";
|
||||||
src: url(/Monaco.ttf);
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
53
src/App.vue
@@ -1,15 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<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 { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||||
import "normalize.css"
|
import "normalize.css"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
import { useConfigStore } from "shared/store/config"
|
||||||
hljs.registerLanguage("c", c)
|
import { useConfigUpdate } from "shared/composables/configUpdate"
|
||||||
hljs.registerLanguage("python", python)
|
import { useMaxKB } from "shared/composables/maxkb"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
const isDark = useDark()
|
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)
|
||||||
|
|
||||||
|
hljsInstance.value = hljs
|
||||||
|
return hljs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在空闲时预加载
|
||||||
|
onMounted(() => {
|
||||||
|
if ("requestIdleCallback" in window) {
|
||||||
|
requestIdleCallback(() => loadHighlightJS())
|
||||||
|
} else {
|
||||||
|
setTimeout(() => loadHighlightJS(), 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
provide("hljs", hljsInstance)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -17,7 +56,7 @@ const isDark = useDark()
|
|||||||
:theme="isDark ? darkTheme : null"
|
:theme="isDark ? darkTheme : null"
|
||||||
:locale="zhCN"
|
:locale="zhCN"
|
||||||
:date-locale="dateZhCN"
|
:date-locale="dateZhCN"
|
||||||
:hljs="hljs"
|
:hljs="hljsInstance"
|
||||||
>
|
>
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteAnnouncement } from "~/admin/api"
|
import { deleteAnnouncement } from "admin/api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
announcementID: number
|
announcementID: number
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import { AnnouncementEdit } from "~/utils/types"
|
import { AnnouncementEdit } from "utils/types"
|
||||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch } from "naive-ui"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Announcement } from "~/utils/types"
|
import { Announcement } from "utils/types"
|
||||||
import { editAnnouncement, getAnnouncementList } from "../api"
|
import { editAnnouncement, getAnnouncementList } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
|
||||||
|
|||||||
188
src/admin/api.ts
@@ -11,7 +11,7 @@ import {
|
|||||||
Tutorial,
|
Tutorial,
|
||||||
User,
|
User,
|
||||||
WebsiteConfig,
|
WebsiteConfig,
|
||||||
} from "~/utils/types"
|
} from "utils/types"
|
||||||
|
|
||||||
export function getBaseInfo() {
|
export function getBaseInfo() {
|
||||||
return http.get("admin/dashboard_info")
|
return http.get("admin/dashboard_info")
|
||||||
@@ -25,8 +25,8 @@ export async function getProblemList(
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
keyword: string,
|
keyword: string,
|
||||||
|
author?: string,
|
||||||
contestID?: string,
|
contestID?: string,
|
||||||
ruleType?: "ACM" | "OI",
|
|
||||||
) {
|
) {
|
||||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||||
const res = await http.get(endpoint, {
|
const res = await http.get(endpoint, {
|
||||||
@@ -35,8 +35,8 @@ export async function getProblemList(
|
|||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
keyword,
|
keyword,
|
||||||
|
author,
|
||||||
contest_id: contestID,
|
contest_id: contestID,
|
||||||
rule_type: ruleType,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -84,11 +84,12 @@ export function getContestProblem(id: number) {
|
|||||||
export function getUserList(
|
export function getUserList(
|
||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
admin = "0",
|
type = "",
|
||||||
keyword: string,
|
keyword: string,
|
||||||
|
orderBy = "",
|
||||||
) {
|
) {
|
||||||
return http.get("admin/user", {
|
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) {
|
export function uploadTestcases(file: File) {
|
||||||
const form = new window.FormData()
|
const form = new window.FormData()
|
||||||
form.append("file", file)
|
form.append("file", file)
|
||||||
form.append("spj", "false")
|
|
||||||
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
||||||
headers: { "content-type": "multipart/form-data" },
|
headers: { "content-type": "multipart/form-data" },
|
||||||
})
|
})
|
||||||
@@ -255,4 +255,178 @@ export function deleteTutorial(id: number) {
|
|||||||
|
|
||||||
export function setTutorialVisibility(id: number, is_public: boolean) {
|
export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||||
return http.put("admin/tutorial/visibility", { id, is_public })
|
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>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { NButton } from "naive-ui"
|
import { NButton } from "naive-ui"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Comment } from "~/utils/types"
|
import { Comment } from "utils/types"
|
||||||
import { getCommentList } from "../api"
|
import { getCommentList } from "../api"
|
||||||
import CommentActions from "./components/CommentActions.vue"
|
import CommentActions from "./components/CommentActions.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteComment } from "~/admin/api"
|
import { deleteComment } from "admin/api"
|
||||||
|
|
||||||
const props = defineProps<{ commentID: number }>()
|
const props = defineProps<{ commentID: number }>()
|
||||||
const emit = defineEmits(["deleted"])
|
const emit = defineEmits(["deleted"])
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Contest } from "~/utils/types"
|
import { Contest } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contest: Contest
|
contest: Contest
|
||||||
@@ -20,12 +20,30 @@ function goEditProblems() {
|
|||||||
params: { contestID: props.contest.id },
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
||||||
题目
|
题目
|
||||||
</n-button>
|
</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 size="small" type="info" secondary @click="goEdit">
|
||||||
编辑
|
编辑
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatISO } from "date-fns"
|
import { formatISO } from "date-fns"
|
||||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { BlankContest } from "~/utils/types"
|
import { BlankContest } from "utils/types"
|
||||||
import { createContest, editContest, getContest } from "../api"
|
import { createContest, editContest, getContest } from "../api"
|
||||||
|
|
||||||
interface Props {
|
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">
|
<script setup lang="ts">
|
||||||
import { NSwitch, NTag } from "naive-ui"
|
import { NSwitch, NTag } from "naive-ui"
|
||||||
import ContestTitle from "~/shared/components/ContestTitle.vue"
|
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||||
import ContestType from "~/shared/components/ContestType.vue"
|
import ContestType from "shared/components/ContestType.vue"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { CONTEST_STATUS } from "~/utils/constants"
|
import { CONTEST_STATUS } from "utils/constants"
|
||||||
import { Contest } from "~/utils/types"
|
import { Contest } from "utils/types"
|
||||||
import { editContest, getContestList } from "../api"
|
import { editContest, getContestList } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ const columns: DataTableColumn<Contest>[] = [
|
|||||||
{
|
{
|
||||||
title: "选项",
|
title: "选项",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 140,
|
width: 220,
|
||||||
render: (row) => h(Actions, { contest: row }),
|
render: (row) => h(Actions, { contest: row }),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteContestProblem, deleteProblem } from "~/admin/api"
|
import {
|
||||||
import download from "~/utils/download"
|
deleteContestProblem,
|
||||||
|
deleteProblem,
|
||||||
|
makeProblemPublic,
|
||||||
|
} from "admin/api"
|
||||||
|
import download from "utils/download"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
problemID: number
|
problemID: number
|
||||||
problemDisplayID: string
|
problemDisplayID: string
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits(["deleted"])
|
const emit = defineEmits(["updated"])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
const isContestProblem = computed(
|
||||||
|
() => route.name === "admin contest problem list",
|
||||||
|
)
|
||||||
|
|
||||||
|
const showMakePublicModal = ref(false)
|
||||||
|
const newDisplayID = ref("")
|
||||||
|
|
||||||
async function handleDeleteProblem() {
|
async function handleDeleteProblem() {
|
||||||
try {
|
try {
|
||||||
if (route.name === "admin contest problem list") {
|
if (route.name === "admin contest problem list") {
|
||||||
@@ -21,7 +32,7 @@ async function handleDeleteProblem() {
|
|||||||
await deleteProblem(props.problemID)
|
await deleteProblem(props.problemID)
|
||||||
}
|
}
|
||||||
message.success("删除成功")
|
message.success("删除成功")
|
||||||
emit("deleted")
|
emit("updated")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.data === "Can't delete the problem as it has submissions") {
|
if (err.data === "Can't delete the problem as it has submissions") {
|
||||||
message.error("这道题有提交之后,就不能被删除")
|
message.error("这道题有提交之后,就不能被删除")
|
||||||
@@ -53,6 +64,33 @@ function goCheck() {
|
|||||||
}
|
}
|
||||||
window.open(data.href, "_blank")
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
@@ -62,6 +100,19 @@ function goCheck() {
|
|||||||
<n-button size="small" secondary type="info" @click="goCheck">
|
<n-button size="small" secondary type="info" @click="goCheck">
|
||||||
查看
|
查看
|
||||||
</n-button>
|
</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">
|
<n-popconfirm @positive-click="handleDeleteProblem">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button secondary size="small" type="error">删除</n-button>
|
<n-button secondary size="small" type="error">删除</n-button>
|
||||||
@@ -75,4 +126,35 @@ function goCheck() {
|
|||||||
下载测试用例
|
下载测试用例
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</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>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { addProblemForContest } from "~/admin/api"
|
import { addProblemForContest } from "admin/api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
problemID: number
|
problemID: number
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { getProblemList } from "~/admin/api"
|
import { getProblemList } from "admin/api"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { AdminProblemFiltered } from "~/utils/types"
|
import { AdminProblemFiltered } from "utils/types"
|
||||||
import AddButton from "./AddButton.vue"
|
import AddButton from "./AddButton.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -42,13 +42,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
|
|
||||||
async function getList() {
|
async function getList() {
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await getProblemList(
|
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
|
||||||
offset,
|
|
||||||
query.limit,
|
|
||||||
query.keyword,
|
|
||||||
"",
|
|
||||||
"ACM",
|
|
||||||
)
|
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
problems.value = res.results
|
problems.value = res.results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getProblemTagList } from "~/shared/api"
|
import { getProblemTagList } from "shared/api"
|
||||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import {
|
import {
|
||||||
CODE_TEMPLATES,
|
CODE_TEMPLATES,
|
||||||
LANGUAGE_SHOW_VALUE,
|
LANGUAGE_SHOW_VALUE,
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
} from "~/utils/constants"
|
} from "utils/constants"
|
||||||
import download from "~/utils/download"
|
import download from "utils/download"
|
||||||
import { unique } from "~/utils/functions"
|
import { unique } from "utils/functions"
|
||||||
import { BlankProblem, LANGUAGE, Tag } from "~/utils/types"
|
import { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
||||||
import {
|
import {
|
||||||
createContestProblem,
|
createContestProblem,
|
||||||
createProblem,
|
createProblem,
|
||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
} from "../api"
|
} from "../api"
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(
|
const CodeEditor = defineAsyncComponent(
|
||||||
() => import("~/shared/components/CodeEditor.vue"),
|
() => import("shared/components/CodeEditor.vue"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const MermaidEditor = defineAsyncComponent(
|
||||||
|
() => import("shared/components/MermaidEditor.vue"),
|
||||||
)
|
)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,31 +54,35 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
|||||||
output_description: "",
|
output_description: "",
|
||||||
time_limit: 1000,
|
time_limit: 1000,
|
||||||
memory_limit: 64,
|
memory_limit: 64,
|
||||||
difficulty: "Low",
|
difficulty: "Low" as "Low" | "Mid" | "High",
|
||||||
visible: false,
|
visible: false,
|
||||||
share_submission: false,
|
share_submission: false,
|
||||||
tags: [],
|
tags: [],
|
||||||
languages: ["C", "Python3"],
|
languages: ["Python3", "C"] as LANGUAGE[],
|
||||||
template: {},
|
template: {} as { [key in LANGUAGE]?: string },
|
||||||
samples: [
|
samples: [
|
||||||
{ input: "", output: "" },
|
{ input: "", output: "" },
|
||||||
{ input: "", output: "" },
|
{ input: "", output: "" },
|
||||||
{ input: "", output: "" },
|
{ input: "", output: "" },
|
||||||
],
|
],
|
||||||
spj: false,
|
|
||||||
spj_language: "",
|
|
||||||
spj_code: "",
|
|
||||||
spj_compile_ok: false,
|
|
||||||
test_case_id: "",
|
test_case_id: "",
|
||||||
test_case_score: [],
|
test_case_score: [] as Testcase[],
|
||||||
rule_type: "ACM",
|
rule_type: "ACM",
|
||||||
hint: "",
|
hint: "",
|
||||||
source: "",
|
source: "",
|
||||||
|
prompt: "",
|
||||||
|
answers: [] as { language: LANGUAGE; code: string }[],
|
||||||
io_mode: {
|
io_mode: {
|
||||||
io_mode: "Standard IO",
|
io_mode: "Standard IO",
|
||||||
input: "input.txt",
|
input: "input.txt",
|
||||||
output: "output.txt",
|
output: "output.txt",
|
||||||
},
|
},
|
||||||
|
contest_id: "",
|
||||||
|
allow_flowchart: false,
|
||||||
|
mermaid_code: "",
|
||||||
|
flowchart_data: {},
|
||||||
|
flowchart_hint: "",
|
||||||
|
show_flowchart: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 从服务器来的tag列表
|
// 从服务器来的tag列表
|
||||||
@@ -90,14 +98,18 @@ const tags = useLocalStorage<Tags>(STORAGE_KEY.ADMIN_PROBLEM_TAGS, {
|
|||||||
upload: [],
|
upload: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
// 这三个用的少,就不缓存本地了
|
// 这几个用的少,就不缓存本地了
|
||||||
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
||||||
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
|
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 用
|
// 给 TextEditor 用
|
||||||
const [ready, toggleReady] = useToggle(false)
|
const [ready, toggleReady] = useToggle(false)
|
||||||
|
|
||||||
|
// Mermaid 渲染状态
|
||||||
|
const mermaidRenderSuccess = ref(false)
|
||||||
|
|
||||||
const difficultyOptions: SelectOption[] = [
|
const difficultyOptions: SelectOption[] = [
|
||||||
{ label: "简单", value: "Low" },
|
{ label: "简单", value: "Low" },
|
||||||
{ label: "中等", value: "Mid" },
|
{ label: "中等", value: "Mid" },
|
||||||
@@ -105,8 +117,9 @@ const difficultyOptions: SelectOption[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const languageOptions = [
|
const languageOptions = [
|
||||||
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
|
|
||||||
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
|
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
|
||||||
|
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
|
||||||
|
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const tagOptions = computed(() =>
|
const tagOptions = computed(() =>
|
||||||
@@ -118,49 +131,65 @@ async function getProblemDetail() {
|
|||||||
toggleReady(true)
|
toggleReady(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { data } = await getProblem(props.problemID)
|
try {
|
||||||
toggleReady(true)
|
const { data } = await getProblem(props.problemID)
|
||||||
problem.value.id = data.id
|
toggleReady(true)
|
||||||
problem.value._id = data._id
|
problem.value.id = data.id
|
||||||
problem.value.title = data.title
|
problem.value._id = data._id
|
||||||
problem.value.description = data.description
|
problem.value.title = data.title
|
||||||
problem.value.input_description = data.input_description
|
problem.value.description = data.description
|
||||||
problem.value.output_description = data.output_description
|
problem.value.input_description = data.input_description
|
||||||
problem.value.time_limit = data.time_limit
|
problem.value.output_description = data.output_description
|
||||||
problem.value.memory_limit = data.memory_limit
|
problem.value.time_limit = data.time_limit
|
||||||
problem.value.memory_limit = data.memory_limit
|
problem.value.memory_limit = data.memory_limit
|
||||||
problem.value.difficulty = data.difficulty
|
problem.value.memory_limit = data.memory_limit
|
||||||
problem.value.visible = data.visible
|
problem.value.difficulty = data.difficulty
|
||||||
problem.value.share_submission = data.share_submission
|
problem.value.visible = data.visible
|
||||||
problem.value.tags = data.tags
|
problem.value.share_submission = data.share_submission
|
||||||
problem.value.languages = data.languages
|
problem.value.tags = data.tags
|
||||||
problem.value.template = data.template
|
problem.value.languages = data.languages
|
||||||
problem.value.samples = data.samples
|
problem.value.template = data.template
|
||||||
problem.value.samples = data.samples
|
problem.value.samples = data.samples
|
||||||
problem.value.spj = data.spj
|
problem.value.samples = data.samples
|
||||||
problem.value.spj_language = data.spj_language
|
problem.value.test_case_id = data.test_case_id
|
||||||
problem.value.spj_code = data.spj_code
|
problem.value.test_case_score = data.test_case_score
|
||||||
problem.value.spj_compile_ok = data.spj_compile_ok
|
problem.value.rule_type = data.rule_type
|
||||||
problem.value.test_case_id = data.test_case_id
|
problem.value.hint = data.hint
|
||||||
problem.value.test_case_score = data.test_case_score
|
problem.value.source = data.source
|
||||||
problem.value.rule_type = data.rule_type
|
problem.value.prompt = data.prompt
|
||||||
problem.value.hint = data.hint
|
// 流程图相关字段
|
||||||
problem.value.source = data.source
|
problem.value.allow_flowchart = data.allow_flowchart
|
||||||
problem.value.io_mode = data.io_mode
|
problem.value.show_flowchart = data.show_flowchart
|
||||||
if (problem.value.contest_id) {
|
problem.value.mermaid_code = data.mermaid_code ?? ""
|
||||||
problem.value.contest_id = problem.value.contest_id
|
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.languages.forEach((lang) => {
|
problem.value.answers = data.languages.map((lang: LANGUAGE) => ({
|
||||||
if (data.template[lang]) {
|
language: lang,
|
||||||
template[lang] = data.template[lang]
|
code: "",
|
||||||
toggleNeedTemplate(true)
|
}))
|
||||||
}
|
}
|
||||||
})
|
problem.value.io_mode = data.io_mode
|
||||||
// 标签
|
if (problem.value.contest_id) {
|
||||||
tags.value.select = data.tags
|
problem.value.contest_id = problem.value.contest_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下面是用来显示的:
|
||||||
|
// 代码模板 和 模板开关
|
||||||
|
problem.value.languages.forEach((lang) => {
|
||||||
|
if (data.template[lang]) {
|
||||||
|
template[lang] = data.template[lang]
|
||||||
|
toggleNeedTemplate(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 标签
|
||||||
|
tags.value.select = data.tags
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取题目失败")
|
||||||
|
router.push({ name: "admin problem list" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTagList() {
|
async function getTagList() {
|
||||||
@@ -207,9 +236,6 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
|||||||
const testcases = res.data.info
|
const testcases = res.data.info
|
||||||
for (let file of testcases) {
|
for (let file of testcases) {
|
||||||
file.score = (100 / testcases.length).toFixed(0)
|
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_score = testcases
|
||||||
problem.value.test_case_id = res.data.id
|
problem.value.test_case_id = res.data.id
|
||||||
@@ -222,18 +248,23 @@ function downloadTestcases() {
|
|||||||
download("test_case?problem_id=" + problem.value.id)
|
download("test_case?problem_id=" + problem.value.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mermaid 渲染事件处理
|
||||||
|
function onMermaidRenderSuccess() {
|
||||||
|
mermaidRenderSuccess.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 题目是否有漏写的
|
// 题目是否有漏写的
|
||||||
function detectProblemCompletion() {
|
async function validateProblem() {
|
||||||
let flag = false
|
let hasErrors = false
|
||||||
// 标题
|
// 标题
|
||||||
if (!problem.value._id || !problem.value.title) {
|
if (!problem.value._id || !problem.value.title) {
|
||||||
message.error("编号或标题没有填写")
|
message.error("编号或标题没有填写")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 标签
|
// 标签
|
||||||
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) {
|
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) {
|
||||||
message.error("标签没有填写")
|
message.error("标签没有填写")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 题目
|
// 题目
|
||||||
else if (
|
else if (
|
||||||
@@ -242,12 +273,12 @@ function detectProblemCompletion() {
|
|||||||
!problem.value.output_description
|
!problem.value.output_description
|
||||||
) {
|
) {
|
||||||
message.error("题目或输入或输出没有填写")
|
message.error("题目或输入或输出没有填写")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 样例
|
// 样例
|
||||||
else if (problem.value.samples.length == 0) {
|
else if (problem.value.samples.length == 0) {
|
||||||
message.error("样例没有填写")
|
message.error("样例没有填写")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 样例是空的
|
// 样例是空的
|
||||||
else if (
|
else if (
|
||||||
@@ -256,21 +287,34 @@ function detectProblemCompletion() {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
message.error("空样例没有删干净")
|
message.error("空样例没有删干净")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 测试用例
|
// 测试用例
|
||||||
else if (problem.value.test_case_score.length === 0) {
|
else if (problem.value.test_case_score.length === 0) {
|
||||||
message.error("测试用例没有上传")
|
message.error("测试用例没有上传")
|
||||||
flag = true
|
hasErrors = true
|
||||||
} else if (problem.value.languages.length === 0) {
|
} else if (problem.value.languages.length === 0) {
|
||||||
message.error("编程语言没有选择")
|
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 {
|
else {
|
||||||
flag = false
|
hasErrors = false
|
||||||
}
|
}
|
||||||
return flag
|
return hasErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTemplate() {
|
function getTemplate() {
|
||||||
@@ -294,11 +338,18 @@ function filterHint() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterAnswers() {
|
||||||
|
problem.value.answers = problem.value.answers.filter(
|
||||||
|
(ans) => ans.code.trim() !== "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const notCompleted = detectProblemCompletion()
|
const hasValidationErrors = await validateProblem()
|
||||||
if (notCompleted) return
|
if (hasValidationErrors) return
|
||||||
filterHint()
|
filterHint()
|
||||||
getTemplate()
|
getTemplate()
|
||||||
|
filterAnswers()
|
||||||
const api = {
|
const api = {
|
||||||
"admin problem create": createProblem,
|
"admin problem create": createProblem,
|
||||||
"admin problem edit": editProblem,
|
"admin problem edit": editProblem,
|
||||||
@@ -366,6 +417,19 @@ watch(
|
|||||||
problem.value.tags = uniqueTags
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -453,39 +517,138 @@ watch(
|
|||||||
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
||||||
添加用例
|
添加用例
|
||||||
</n-button>
|
</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>
|
||||||
<n-form-item label="题目的来源">
|
<n-form-item label="题目的来源(选填)">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="problem.source"
|
v-model:value="problem.source"
|
||||||
placeholder="比如来自某道题的改编等,或者网上的资料"
|
placeholder="比如来自某道题的改编等,或者网上的资料"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
|
||||||
<n-form v-if="needTemplate">
|
<n-input
|
||||||
<n-form-item label="编写代码模板">
|
v-model:value="problem.prompt"
|
||||||
<n-tabs
|
placeholder="比如考察选择、循环、算法等知识点"
|
||||||
type="segment"
|
/>
|
||||||
default-value="C"
|
|
||||||
class="template box"
|
|
||||||
v-model:value="currentActiveTemplate"
|
|
||||||
>
|
|
||||||
<n-tab-pane
|
|
||||||
v-for="(lang, index) in problem.languages"
|
|
||||||
:key="index"
|
|
||||||
:name="lang"
|
|
||||||
>
|
|
||||||
<CodeEditor
|
|
||||||
v-model:value="template[lang]"
|
|
||||||
:language="lang"
|
|
||||||
:font-size="16"
|
|
||||||
height="200px"
|
|
||||||
/>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
|
||||||
|
<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="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
|
||||||
|
v-for="(lang, index) in problem.languages"
|
||||||
|
:key="index"
|
||||||
|
:name="lang"
|
||||||
|
>
|
||||||
|
<CodeEditor
|
||||||
|
v-model:value="template[lang]"
|
||||||
|
:language="lang"
|
||||||
|
:font-size="16"
|
||||||
|
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
|
<n-alert
|
||||||
class="box"
|
class="box"
|
||||||
v-if="problem.test_case_score.length"
|
v-if="problem.test_case_score.length"
|
||||||
@@ -511,57 +674,24 @@ watch(
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-space style="margin-bottom: 100px" justify="space-between">
|
<n-flex style="margin-bottom: 120px" align="center" justify="end">
|
||||||
<n-form inline label-placement="left" :show-feedback="false">
|
<n-tooltip placement="left">
|
||||||
<n-form-item label="语言">
|
<template #trigger>
|
||||||
<n-checkbox-group v-model:value="problem.languages">
|
<n-button text>温馨提醒</n-button>
|
||||||
<n-flex align="center">
|
</template>
|
||||||
<n-checkbox
|
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||||
v-for="(language, index) in languageOptions"
|
</n-tooltip>
|
||||||
:key="index"
|
<div>
|
||||||
:value="language.value"
|
<n-upload
|
||||||
:label="language.label"
|
:show-file-list="false"
|
||||||
/>
|
accept=".zip"
|
||||||
</n-flex>
|
:custom-request="handleUploadTestcases"
|
||||||
</n-checkbox-group>
|
>
|
||||||
</n-form-item>
|
<n-button type="info">上传测试用例</n-button>
|
||||||
<n-form-item>
|
</n-upload>
|
||||||
<n-checkbox
|
</div>
|
||||||
v-model:checked="needTemplate"
|
<n-button type="primary" @click="submit">提交</n-button>
|
||||||
label="代码模板(一般用不到)"
|
</n-flex>
|
||||||
/>
|
|
||||||
</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-tooltip placement="left">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button text>温馨提醒</n-button>
|
|
||||||
</template>
|
|
||||||
【测试用例】最好要有10个,要考虑边界情况,不要跟【测试样例】一模一样
|
|
||||||
</n-tooltip>
|
|
||||||
<div>
|
|
||||||
<n-upload
|
|
||||||
:show-file-list="false"
|
|
||||||
accept=".zip"
|
|
||||||
:custom-request="handleUploadTestcases"
|
|
||||||
>
|
|
||||||
<n-button type="info">上传测试用例</n-button>
|
|
||||||
</n-upload>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" @click="submit">提交</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -588,8 +718,4 @@ watch(
|
|||||||
.addSamples {
|
.addSamples {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template {
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch } from "naive-ui"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime, filterEmptyValue } from "~/utils/functions"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { AdminProblemFiltered } from "~/utils/types"
|
import { parseTime } from "utils/functions"
|
||||||
|
import { AdminProblemFiltered } from "utils/types"
|
||||||
import { getProblemList, toggleProblemVisible } from "../api"
|
import { getProblemList, toggleProblemVisible } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
import Modal from "./components/Modal.vue"
|
import Modal from "./components/Modal.vue"
|
||||||
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
|
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contestID?: string
|
contestID?: string
|
||||||
@@ -30,10 +33,16 @@ const [show, toggleShow] = useToggle()
|
|||||||
const { count, inc } = useCounter(0)
|
const { count, inc } = useCounter(0)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const problems = ref<AdminProblemFiltered[]>([])
|
const problems = ref<AdminProblemFiltered[]>([])
|
||||||
const query = reactive({
|
|
||||||
limit: parseInt(<string>route.query.limit) || 10,
|
interface ProblemQuery {
|
||||||
page: parseInt(<string>route.query.page) || 1,
|
keyword: string
|
||||||
keyword: <string>route.query.keyword ?? "",
|
author: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用分页 composable
|
||||||
|
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||||
|
keyword: useRouteQuery("keyword", "").value,
|
||||||
|
author: useRouteQuery("author", "").value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||||
@@ -50,6 +59,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
{
|
{
|
||||||
title: "可见",
|
title: "可见",
|
||||||
key: "visible",
|
key: "visible",
|
||||||
|
minWidth: 80,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(NSwitch, {
|
h(NSwitch, {
|
||||||
value: row.visible,
|
value: row.visible,
|
||||||
@@ -61,34 +71,24 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
{
|
{
|
||||||
title: "选项",
|
title: "选项",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 260,
|
width: 330,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(Actions, {
|
h(Actions, {
|
||||||
problemID: row.id,
|
problemID: row.id,
|
||||||
problemDisplayID: row._id,
|
problemDisplayID: row._id,
|
||||||
onDeleted: listProblems,
|
onUpdated: listProblems,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function routerPush() {
|
|
||||||
router.push({
|
|
||||||
path: route.path,
|
|
||||||
query: filterEmptyValue(query),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listProblems() {
|
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
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await getProblemList(
|
const res = await getProblemList(
|
||||||
offset,
|
offset,
|
||||||
query.limit,
|
query.limit,
|
||||||
query.keyword,
|
query.keyword,
|
||||||
|
query.author,
|
||||||
props.contestID,
|
props.contestID,
|
||||||
)
|
)
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
@@ -118,28 +118,15 @@ async function selectProblems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(listProblems)
|
onMounted(listProblems)
|
||||||
watch(() => query.page, routerPush)
|
|
||||||
watch(
|
// 监听搜索关键词变化(防抖)
|
||||||
() => [query.limit, query.keyword],
|
watchDebounced(() => query.keyword, listProblems, {
|
||||||
() => {
|
debounce: 500,
|
||||||
query.page = 1
|
maxWait: 1000,
|
||||||
routerPush()
|
})
|
||||||
},
|
|
||||||
)
|
// 监听其他查询条件变化
|
||||||
watchDebounced(
|
watch(() => [query.page, query.limit, query.author], listProblems)
|
||||||
() => query.keyword,
|
|
||||||
() => {
|
|
||||||
query.page = 1
|
|
||||||
routerPush()
|
|
||||||
},
|
|
||||||
{ debounce: 500, maxWait: 1000 },
|
|
||||||
)
|
|
||||||
watch(
|
|
||||||
() => route.name === "admin problem list" && route.query,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) listProblems()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -163,10 +150,19 @@ watch(
|
|||||||
type="primary"
|
type="primary"
|
||||||
@click="selectProblems"
|
@click="selectProblems"
|
||||||
>
|
>
|
||||||
从题库中选择
|
从题目中选择
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-flex align="center" v-if="!props.contestID">
|
||||||
|
<span>出题人</span>
|
||||||
|
<AuthorSelect v-model:value="query.author" all />
|
||||||
|
</n-flex>
|
||||||
<div>
|
<div>
|
||||||
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" />
|
<n-input
|
||||||
|
v-model:value="query.keyword"
|
||||||
|
placeholder="输入标题关键字"
|
||||||
|
clearable
|
||||||
|
@clear="clearQuery"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</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">
|
<script setup lang="ts">
|
||||||
import { NButton, NTag } from "naive-ui"
|
import { NButton, NTag } from "naive-ui"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Server } from "~/utils/types"
|
import { Server } from "utils/types"
|
||||||
|
import { useConfigStore } from "shared/store/config"
|
||||||
|
import { useConfigWebSocket } from "shared/composables/websocket"
|
||||||
import {
|
import {
|
||||||
deleteJudgeServer,
|
deleteJudgeServer,
|
||||||
editWebsite,
|
editWebsite,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
listInvalidTestcases,
|
listInvalidTestcases,
|
||||||
pruneInvalidTestcases,
|
pruneInvalidTestcases,
|
||||||
} from "../api"
|
} from "../api"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
interface Testcase {
|
interface Testcase {
|
||||||
id: string
|
id: string
|
||||||
@@ -17,6 +20,21 @@ interface Testcase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
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>[] = [
|
const testcaseColumns: DataTableColumn<Testcase>[] = [
|
||||||
{ title: "测试用例 ID", key: "id" },
|
{ title: "测试用例 ID", key: "id" },
|
||||||
@@ -99,13 +117,14 @@ const abnormalServers = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const websiteConfig = reactive({
|
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: "判题狗",
|
||||||
website_name_shortcut: "判题狗",
|
website_name_shortcut: "判题狗",
|
||||||
website_footer: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
|
website_footer: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
|
||||||
allow_register: true,
|
allow_register: true,
|
||||||
submission_list_show_all: true,
|
submission_list_show_all: true,
|
||||||
class_list: [],
|
class_list: [],
|
||||||
|
enable_maxkb: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function getWebsiteConfig() {
|
async function getWebsiteConfig() {
|
||||||
@@ -117,12 +136,21 @@ async function getWebsiteConfig() {
|
|||||||
websiteConfig.allow_register = res.data.allow_register
|
websiteConfig.allow_register = res.data.allow_register
|
||||||
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
|
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
|
||||||
websiteConfig.class_list = res.data.class_list
|
websiteConfig.class_list = res.data.class_list
|
||||||
|
websiteConfig.enable_maxkb = res.data.enable_maxkb
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveWebsiteConfig() {
|
async function saveWebsiteConfig() {
|
||||||
await editWebsite(websiteConfig)
|
await editWebsite(websiteConfig)
|
||||||
message.success("网站配置保存成功")
|
message.success("网站配置保存成功")
|
||||||
getWebsiteConfig()
|
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) {
|
async function deleteTestcase(id?: string) {
|
||||||
@@ -198,6 +226,10 @@ onMounted(() => {
|
|||||||
<span>显示所有提交</span>
|
<span>显示所有提交</span>
|
||||||
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
|
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span>启用AI小助手</span>
|
||||||
|
<n-switch v-model:value="websiteConfig.enable_maxkb" />
|
||||||
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-card>
|
</n-card>
|
||||||
<n-card class="box">
|
<n-card class="box">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton } from "naive-ui"
|
import { NButton } from "naive-ui"
|
||||||
import { getRank } from "oj/api"
|
import { getRank } from "oj/api"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { getACRate } from "~/utils/functions"
|
import { getACRate } from "utils/functions"
|
||||||
import { Rank } from "~/utils/types"
|
import { Rank } from "utils/types"
|
||||||
import { getBaseInfo, randomUser10 } from "../api"
|
import { getBaseInfo, randomUser10 } from "../api"
|
||||||
|
|
||||||
const userCount = ref(0)
|
const userCount = ref(0)
|
||||||
@@ -13,7 +13,6 @@ const contestCount = ref(0)
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const luckyGuy = ref("")
|
const luckyGuy = ref("")
|
||||||
const data = ref<Rank[]>([])
|
const data = ref<Rank[]>([])
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteTutorial } from "~/admin/api"
|
import { deleteTutorial } from "admin/api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tutorialID: number
|
tutorialID: number
|
||||||
@@ -40,4 +40,4 @@ async function handleDelete() {
|
|||||||
</n-popconfirm>
|
</n-popconfirm>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||||
import MarkdownEditor from "~/shared/components/MarkdownEditor.vue"
|
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||||
import { Tutorial } from "~/utils/types"
|
import { Tutorial } from "utils/types"
|
||||||
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -87,7 +87,11 @@ onMounted(init)
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="顺序">
|
<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>
|
||||||
<n-form-item label="可见">
|
<n-form-item label="可见">
|
||||||
<n-switch v-model:value="tutorial.is_public" />
|
<n-switch v-model:value="tutorial.is_public" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch } from "naive-ui"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Tutorial } from "~/utils/types"
|
import { Tutorial } from "utils/types"
|
||||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { editUser } from "~/admin/api"
|
import { editUser } from "admin/api"
|
||||||
import { User } from "~/utils/types"
|
import { User } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getUserRole } from "~/utils/functions"
|
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||||
import { User } from "~/utils/types"
|
import { getUserRole } from "utils/functions"
|
||||||
|
import { User } from "utils/types"
|
||||||
|
import TextCopy from "shared/components/TextCopy.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>()
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
@@ -14,12 +18,19 @@ const isAdmin = computed(() => props.user.admin_type !== "Regular User")
|
|||||||
封号中
|
封号中
|
||||||
</n-tag>
|
</n-tag>
|
||||||
<n-tag
|
<n-tag
|
||||||
v-if="isAdmin"
|
v-if="isNotRegularUser"
|
||||||
:type="getUserRole(props.user.admin_type).type"
|
:type="getUserRole(props.user.admin_type).type"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{{ getUserRole(props.user.admin_type).tagString }}
|
{{ getUserRole(props.user.admin_type).label }}
|
||||||
</n-tag>
|
</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>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
import { importUsers } from "../api"
|
import { importUsers } from "../api"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const prefix = ref("")
|
const prefix = ref(0)
|
||||||
const rawInput = ref("")
|
const rawInput = ref("")
|
||||||
const [needKs] = useToggle(true)
|
|
||||||
const [loading, toggleLoading] = useToggle()
|
const [loading, toggleLoading] = useToggle()
|
||||||
const users = shallowRef<string[][]>([])
|
const users = shallowRef<string[][]>([])
|
||||||
|
|
||||||
@@ -13,25 +12,17 @@ function generateUsers() {
|
|||||||
message.info("请填写相关内容")
|
message.info("请填写相关内容")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// 自动加上 ks 的开头
|
let className = !!prefix.value ? `ks${prefix.value}` : ""
|
||||||
let myClass = ""
|
|
||||||
if (prefix.value) {
|
|
||||||
if (needKs.value && !prefix.value.startsWith("ks")) {
|
|
||||||
myClass = "ks" + prefix.value
|
|
||||||
} else {
|
|
||||||
myClass = prefix.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rawInput.value = rawInput.value.trim()
|
rawInput.value = rawInput.value.trim()
|
||||||
const inputs = rawInput.value.split("\n")
|
const inputs = rawInput.value.split("\n")
|
||||||
users.value = inputs.map((u, i) => {
|
users.value = inputs.map((u, i) => {
|
||||||
const username = myClass + u
|
const username = className + u
|
||||||
let password = ""
|
let password = ""
|
||||||
for (let j = 0; j < 6; j++) {
|
for (let j = 0; j < 6; j++) {
|
||||||
password += "123456789".charAt(Math.floor(Math.random() * 9))
|
password += "123456789".charAt(Math.floor(Math.random() * 9))
|
||||||
}
|
}
|
||||||
const realName = u
|
const realName = u
|
||||||
const email = `${myClass}.${i + 1}@example.com`
|
const email = `${className}.${i + 1}@example.com`
|
||||||
return [username, password, email, realName]
|
return [username, password, email, realName]
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
@@ -68,14 +59,16 @@ async function submit() {
|
|||||||
<n-space>
|
<n-space>
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-switch v-model:value="needKs" />
|
<div style="width: 18px; font-size: 1.2rem">ks</div>
|
||||||
<span>前面带上 ks</span>
|
<n-input-number
|
||||||
|
style="width: 170px"
|
||||||
|
v-model:value="prefix"
|
||||||
|
clearable
|
||||||
|
:max="9999"
|
||||||
|
:min="0"
|
||||||
|
placeholder="班级号"
|
||||||
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-input
|
|
||||||
style="width: 200px"
|
|
||||||
v-model:value="prefix"
|
|
||||||
placeholder="班级号"
|
|
||||||
/>
|
|
||||||
<n-input
|
<n-input
|
||||||
type="textarea"
|
type="textarea"
|
||||||
class="inputArea"
|
class="inputArea"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DataTableRowKey, SelectOption } from "naive-ui"
|
import { DataTableRowKey, SelectOption } from "naive-ui"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { User } from "~/utils/types"
|
import { parseTime } from "utils/functions"
|
||||||
|
import { User } from "utils/types"
|
||||||
import {
|
import {
|
||||||
deleteUsers,
|
deleteUsers,
|
||||||
editUser,
|
editUser,
|
||||||
@@ -12,19 +13,39 @@ import {
|
|||||||
} from "../api"
|
} from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
import Name from "./components/Name.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()
|
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 total = ref(0)
|
||||||
const users = ref<User[]>([])
|
const users = ref<User[]>([])
|
||||||
const userEditing = ref<User | null>(null)
|
const userEditing = ref<User | null>(null)
|
||||||
const query = reactive({
|
|
||||||
limit: 10,
|
const adminOptions = [
|
||||||
page: 1,
|
{ label: "全部用户", value: "" },
|
||||||
keyword: "",
|
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||||
admin: false,
|
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||||
})
|
]
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ label: "默认排序", value: "" },
|
||||||
|
{ label: "最近登录", value: "-last_login" },
|
||||||
|
]
|
||||||
const [create, toggleCreate] = useToggle(false)
|
const [create, toggleCreate] = useToggle(false)
|
||||||
const password = ref("")
|
const password = ref("")
|
||||||
const userIDs = ref<DataTableRowKey[]>([])
|
const userIDs = ref<DataTableRowKey[]>([])
|
||||||
@@ -33,17 +54,18 @@ const rowKey = (row: User) => row.id
|
|||||||
|
|
||||||
const columns: DataTableColumn<User>[] = [
|
const columns: DataTableColumn<User>[] = [
|
||||||
{ type: "selection" },
|
{ type: "selection" },
|
||||||
{ title: "ID", key: "id", width: 100 },
|
{ title: "ID", key: "id", width: 80 },
|
||||||
{
|
{
|
||||||
title: "用户名",
|
title: "用户名",
|
||||||
key: "username",
|
key: "username",
|
||||||
width: 200,
|
width: 220,
|
||||||
render: (row) => h(Name, { user: row }),
|
render: (row) => h(Name, { user: row }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "密码",
|
title: "密码",
|
||||||
key: "raw_password",
|
key: "raw_password",
|
||||||
width: 100,
|
width: 100,
|
||||||
|
render: (row) => h(TextCopy, () => row.raw_password),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
@@ -60,12 +82,17 @@ const columns: DataTableColumn<User>[] = [
|
|||||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
? 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 },
|
{ title: "邮箱", key: "email", width: 200 },
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
title: "选项",
|
title: "选项",
|
||||||
width: 260,
|
width: 280,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(Actions, {
|
h(Actions, {
|
||||||
user: row,
|
user: row,
|
||||||
@@ -78,15 +105,27 @@ const columns: DataTableColumn<User>[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const options: SelectOption[] = [
|
const options: SelectOption[] = [
|
||||||
{ label: "普通", value: "Regular User" },
|
{ label: "普通", value: USER_TYPE.REGULAR_USER },
|
||||||
{ label: "管理员", value: "Admin" },
|
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||||
{ label: "超级管理员", value: "Super 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() {
|
async function listUsers() {
|
||||||
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const isAdmin = query.admin ? "1" : "0"
|
const res = await getUserList(
|
||||||
const res = await getUserList(offset, query.limit, isAdmin, query.keyword)
|
offset,
|
||||||
|
query.limit,
|
||||||
|
query.type,
|
||||||
|
query.keyword,
|
||||||
|
query.orderBy,
|
||||||
|
)
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
users.value = res.data.results
|
users.value = res.data.results
|
||||||
}
|
}
|
||||||
@@ -102,7 +141,7 @@ async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
|
|||||||
|
|
||||||
async function onResetPassword(user: User) {
|
async function onResetPassword(user: User) {
|
||||||
const res = await resetPassword(user.id)
|
const res = await resetPassword(user.id)
|
||||||
message.success(`【${user.username}】密码重置为 ${res.data}`)
|
message.success(`【${user.username}】的密码已重置成【${res.data}】`)
|
||||||
users.value = users.value.map((it) => {
|
users.value = users.value.map((it) => {
|
||||||
if (it.id === user.id && user.admin_type === USER_TYPE.REGULAR_USER) {
|
if (it.id === user.id && user.admin_type === USER_TYPE.REGULAR_USER) {
|
||||||
it.raw_password = res.data
|
it.raw_password = res.data
|
||||||
@@ -127,8 +166,8 @@ function createNewUser() {
|
|||||||
username: "",
|
username: "",
|
||||||
real_name: "",
|
real_name: "",
|
||||||
email: "",
|
email: "",
|
||||||
admin_type: "Regular User",
|
admin_type: "Admin",
|
||||||
problem_permission: "",
|
problem_permission: "None",
|
||||||
create_time: new Date(),
|
create_time: new Date(),
|
||||||
last_login: new Date(),
|
last_login: new Date(),
|
||||||
two_factor_auth: false,
|
two_factor_auth: false,
|
||||||
@@ -177,7 +216,12 @@ async function handleEditUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(listUsers)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -200,10 +244,25 @@ watch(query, listUsers, { deep: true })
|
|||||||
确定删除选中的用户吗?删除后无法恢复!
|
确定删除选中的用户吗?删除后无法恢复!
|
||||||
</n-popconfirm>
|
</n-popconfirm>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<span>超管出列</span>
|
<n-select
|
||||||
<n-switch v-model:value="query.admin" />
|
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>
|
<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>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -249,6 +308,17 @@ watch(query, listUsers, { deep: true })
|
|||||||
<n-form-item-gi v-if="!create" :span="1" label="类型">
|
<n-form-item-gi v-if="!create" :span="1" label="类型">
|
||||||
<n-select v-model:value="userEditing.admin_type" :options="options" />
|
<n-select v-model:value="userEditing.admin_type" :options="options" />
|
||||||
</n-form-item-gi>
|
</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-form-item-gi v-if="!create" :span="1" label="是否封禁">
|
||||||
<n-switch v-model:value="userEditing.is_disabled">封号</n-switch>
|
<n-switch v-model:value="userEditing.is_disabled">封号</n-switch>
|
||||||
</n-form-item-gi>
|
</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 App from "./App.vue"
|
||||||
import { admins, ojs } from "./routes"
|
import { admins, ojs } from "./routes"
|
||||||
|
|
||||||
import { toggleLogin } from "./shared/composables/modal"
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [ojs, admins],
|
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 (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||||
toggleLogin(true)
|
authStore.openLoginModal()
|
||||||
next("/")
|
next("/")
|
||||||
} else {
|
return
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
} 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")
|
app.mount("#app")
|
||||||
|
|
||||||
if (!!import.meta.env.VITE_ICONIFY_URL) {
|
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||||
addAPIProvider("", {
|
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>
|
<script lang="ts" setup>
|
||||||
import { NTag } from "naive-ui"
|
import { NTag } from "naive-ui"
|
||||||
import { getAnnouncement, getAnnouncementList } from "~/oj/api"
|
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { Announcement } from "~/utils/types"
|
import { Announcement } from "utils/types"
|
||||||
import TitleWithTag from "./components/TitleWithTag.vue"
|
import TitleWithTag from "./components/TitleWithTag.vue"
|
||||||
|
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const content = ref("")
|
const content = ref("")
|
||||||
const title = ref("")
|
const title = ref("")
|
||||||
const [show, toggleShow] = useToggle(false)
|
const [show, toggleShow] = useToggle(false)
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
limit: 10,
|
limit: 10,
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|||||||
136
src/oj/api.ts
@@ -19,6 +19,7 @@ function filterResult(result: Problem) {
|
|||||||
rate: getACRate(result.accepted_number, result.submission_number),
|
rate: getACRate(result.accepted_number, result.submission_number),
|
||||||
status: "",
|
status: "",
|
||||||
author: result.created_by.username,
|
author: result.created_by.username,
|
||||||
|
allow_flowchart: result.allow_flowchart,
|
||||||
}
|
}
|
||||||
if (result.my_status === null || result.my_status === undefined) {
|
if (result.my_status === null || result.my_status === undefined) {
|
||||||
newResult.status = "not_test"
|
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() {
|
export function getRandomProblemID() {
|
||||||
return http.get("pickone")
|
return http.get("pickone")
|
||||||
}
|
}
|
||||||
@@ -84,11 +93,15 @@ export function submitCode(data: SubmitCodePayload) {
|
|||||||
return http.post("submission", data)
|
return http.post("submission", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSubmissions(params: SubmissionListPayload) {
|
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||||
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
||||||
return http.get(endpoint, { params })
|
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() {
|
export function getTodaySubmissionCount() {
|
||||||
return http.get("submissions/today_count")
|
return http.get("submissions/today_count")
|
||||||
}
|
}
|
||||||
@@ -239,4 +252,123 @@ export function getTutorial(id: number) {
|
|||||||
|
|
||||||
export function getTutorials() {
|
export function getTutorials() {
|
||||||
return http.get("tutorials")
|
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">
|
<script setup lang="ts">
|
||||||
import { ContestRank } from "~/utils/types"
|
import { ContestRank } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rank: ContestRank
|
rank: ContestRank
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import ContestType from "~/shared/components/ContestType.vue"
|
import ContestType from "shared/components/ContestType.vue"
|
||||||
|
|
||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { ContestStatus } from "~/utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const contestMenuVisible = computed(() => {
|
const contestMenuVisible = computed(() => {
|
||||||
if (contestStore.isContestAdmin) return true
|
if (contestStore.isContestAdmin) return true
|
||||||
if (!contestStore.isPrivate) {
|
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">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
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 { useContestStore } from "../store/contest"
|
||||||
import ContestInfo from "./components/ContestInfo.vue"
|
import ContestInfo from "./components/ContestInfo.vue"
|
||||||
import ContestMenu from "./components/ContestMenu.vue"
|
import ContestMenu from "./components/ContestMenu.vue"
|
||||||
@@ -12,6 +12,8 @@ const props = defineProps<{
|
|||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const password = ref("")
|
const password = ref("")
|
||||||
|
|
||||||
async function check() {
|
async function check() {
|
||||||
|
|||||||