Compare commits
155 Commits
c16059a2ee
...
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 |
4
.env
@@ -2,4 +2,6 @@ PUBLIC_ENV=dev
|
||||
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||
PUBLIC_OJ_URL=http://localhost:8000
|
||||
PUBLIC_CODE_URL=http://localhost:3000
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||
PUBLIC_WS_URL=ws://localhost:8001/ws
|
||||
@@ -2,4 +2,6 @@ PUBLIC_ENV=xuyue.cc
|
||||
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
||||
PUBLIC_OJ_URL=https://oj.xuyue.cc
|
||||
PUBLIC_CODE_URL=https://code.xuyue.cc
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
||||
PUBLIC_WS_URL=wss://oj.xuyue.cc/ws
|
||||
@@ -3,4 +3,6 @@ PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13
|
||||
PUBLIC_OJ_URL=http://10.13.114.114:81
|
||||
PUBLIC_CODE_URL=http://10.13.114.114:82
|
||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_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
|
||||
@@ -1,6 +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:81
|
||||
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_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
|
||||
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
|
||||
@@ -1,187 +0,0 @@
|
||||
# 分页组件重构总结
|
||||
|
||||
## 🎯 重构目标
|
||||
|
||||
通过创建 `usePagination` composable 来减少项目中分页相关的重复代码,统一分页逻辑。
|
||||
|
||||
## 📋 已完成的更新
|
||||
|
||||
### 1. 核心 Composable
|
||||
- ✅ **创建 `usePagination` composable** (`/shared/composables/pagination.ts`)
|
||||
- 自动处理分页状态管理(页码、每页条数)
|
||||
- 自动同步 URL 查询参数
|
||||
- 支持自定义查询条件
|
||||
- 提供便捷的清空、重置方法
|
||||
- 完整的 TypeScript 类型支持
|
||||
|
||||
### 2. 前台用户页面
|
||||
- ✅ **问题列表页** (`/oj/problem/list.vue`)
|
||||
- 移除 25+ 行重复代码
|
||||
- 简化查询逻辑
|
||||
- 保留防抖搜索功能
|
||||
|
||||
- ✅ **提交记录页** (`/oj/submission/list.vue`)
|
||||
- 简化复杂的查询条件处理
|
||||
- 统一 URL 同步逻辑
|
||||
- 保留所有筛选功能
|
||||
|
||||
- ✅ **比赛列表页** (`/oj/contest/list.vue`)
|
||||
- 减少路由处理代码
|
||||
- 统一分页行为
|
||||
|
||||
### 3. 管理员页面
|
||||
- ✅ **用户管理页** (`/admin/user/list.vue`)
|
||||
- 简化用户查询和筛选逻辑
|
||||
- 统一分页处理
|
||||
|
||||
- ✅ **题目管理页** (`/admin/problem/list.vue`)
|
||||
- 减少重复的路由同步代码
|
||||
- 保留题目搜索功能
|
||||
|
||||
## 📊 重构效果
|
||||
|
||||
### 代码减少统计
|
||||
- **每个页面减少代码量**: 20-30 行
|
||||
- **总计减少代码量**: 约 150+ 行
|
||||
- **重复逻辑消除**: 100%
|
||||
|
||||
### 重构前后对比
|
||||
|
||||
#### 重构前(每个分页页面都需要)
|
||||
```vue
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const query = reactive({
|
||||
page: parseInt(route.query.page) || 1,
|
||||
limit: parseInt(route.query.limit) || 10,
|
||||
keyword: route.query.keyword ?? "",
|
||||
// ... 其他查询条件
|
||||
})
|
||||
|
||||
function routerPush() {
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: filterEmptyValue(query),
|
||||
})
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
// 从 URL 同步状态
|
||||
query.keyword = route.query.keyword ?? ""
|
||||
query.page = parseInt(route.query.page) || 1
|
||||
query.limit = parseInt(route.query.limit) || 10
|
||||
// ... 其他同步逻辑
|
||||
|
||||
// API 调用
|
||||
const res = await api.getData(...)
|
||||
}
|
||||
|
||||
// 大量的 watch 逻辑
|
||||
watch(() => query.page, routerPush)
|
||||
watch(() => [query.limit, /* 其他条件 */], () => {
|
||||
query.page = 1
|
||||
routerPush()
|
||||
})
|
||||
watchDebounced(() => query.keyword, () => {
|
||||
query.page = 1
|
||||
routerPush()
|
||||
}, { debounce: 500 })
|
||||
</script>
|
||||
```
|
||||
|
||||
#### 重构后(简洁高效)
|
||||
```vue
|
||||
<script setup>
|
||||
// 一行代码完成所有分页逻辑
|
||||
const { query, clearQuery } = usePagination({
|
||||
keyword: "",
|
||||
// ... 其他查询条件
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
// 直接使用 query,无需同步逻辑
|
||||
const res = await api.getData({
|
||||
offset: (query.page - 1) * query.limit,
|
||||
limit: query.limit,
|
||||
keyword: query.keyword,
|
||||
})
|
||||
}
|
||||
|
||||
// 简化的监听逻辑
|
||||
watchDebounced(() => query.keyword, loadData, { debounce: 500 })
|
||||
watch(() => [query.page, query.limit], loadData)
|
||||
</script>
|
||||
```
|
||||
|
||||
## 🚀 主要优势
|
||||
|
||||
### 1. 代码复用性
|
||||
- 所有分页逻辑集中管理
|
||||
- 统一的行为模式
|
||||
- 减少维护成本
|
||||
|
||||
### 2. 类型安全
|
||||
- 完整的 TypeScript 支持
|
||||
- 自定义查询条件的类型推导
|
||||
- 编译时错误检查
|
||||
|
||||
### 3. 自动化处理
|
||||
- 自动 URL 同步
|
||||
- 自动页码重置
|
||||
- 自动路由监听
|
||||
- 浏览器前进后退支持
|
||||
|
||||
### 4. 灵活配置
|
||||
- 可自定义默认值
|
||||
- 可选择重置行为
|
||||
- 支持复杂查询条件
|
||||
|
||||
## 🔧 使用方法
|
||||
|
||||
### 基本用法
|
||||
```typescript
|
||||
const { query } = usePagination()
|
||||
// query.page, query.limit 自动可用
|
||||
```
|
||||
|
||||
### 带查询条件
|
||||
```typescript
|
||||
const { query, clearQuery } = usePagination({
|
||||
keyword: "",
|
||||
status: "",
|
||||
category: "",
|
||||
})
|
||||
```
|
||||
|
||||
### 配置选项
|
||||
```typescript
|
||||
const { query } = usePagination(
|
||||
{ keyword: "" },
|
||||
{
|
||||
defaultLimit: 20,
|
||||
defaultPage: 1,
|
||||
resetPageOnChange: true,
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 📝 迁移指南
|
||||
|
||||
1. 导入 `usePagination`
|
||||
2. 替换原有的 `query` 定义
|
||||
3. 移除 `routerPush` 函数
|
||||
4. 简化 `watch` 逻辑
|
||||
5. 更新 `clear` 函数使用 `clearQuery`
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
这次重构成功地:
|
||||
- **大幅减少了重复代码**(每个页面减少 20-30 行)
|
||||
- **统一了分页行为**(所有页面行为一致)
|
||||
- **提升了开发效率**(新页面只需 1 行代码即可获得完整分页功能)
|
||||
- **增强了类型安全**(完整的 TypeScript 支持)
|
||||
- **保持了组件纯净性**(Pagination.vue 组件职责单一)
|
||||
|
||||
通过这个 composable,未来添加新的分页页面将变得非常简单,只需要一行代码就能获得完整的分页功能。
|
||||
@@ -1,234 +0,0 @@
|
||||
# 分页 Composable 使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
`usePagination` composable 是为了减少重复的分页和 URL 同步代码而创建的。它自动处理:
|
||||
|
||||
- 分页状态管理(页码、每页条数)
|
||||
- URL 查询参数同步
|
||||
- 查询条件变化时的页码重置
|
||||
- 防抖和监听逻辑
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 1. 简单分页(只有分页功能)
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
|
||||
// 只使用基本分页功能
|
||||
const { query } = usePagination()
|
||||
|
||||
// 获取数据
|
||||
async function loadData() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
// 调用 API...
|
||||
}
|
||||
|
||||
// 监听分页变化
|
||||
watch([() => query.page, () => query.limit], loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 你的数据展示 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="query.page"
|
||||
v-model:limit="query.limit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. 带查询条件的分页
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
|
||||
interface SearchQuery {
|
||||
keyword: string
|
||||
status: string
|
||||
category: string
|
||||
}
|
||||
|
||||
// 定义查询条件的初始值
|
||||
const { query, clearQuery } = usePagination<SearchQuery>({
|
||||
keyword: "",
|
||||
status: "",
|
||||
category: "",
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await api.getData({
|
||||
offset,
|
||||
limit: query.limit,
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
category: query.category,
|
||||
})
|
||||
// 处理返回数据...
|
||||
}
|
||||
|
||||
// 监听所有查询条件变化(会自动重置页码)
|
||||
watch([
|
||||
() => query.page,
|
||||
() => query.limit,
|
||||
() => query.keyword,
|
||||
() => query.status,
|
||||
() => query.category,
|
||||
], loadData)
|
||||
|
||||
// 对于需要防抖的搜索
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
loadData,
|
||||
{ debounce: 500 }
|
||||
)
|
||||
|
||||
function handleSearch() {
|
||||
// 手动触发搜索,会自动重置到第一页
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
// 清空所有查询条件
|
||||
clearQuery()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 搜索表单 -->
|
||||
<n-form inline>
|
||||
<n-form-item>
|
||||
<n-input v-model:value="query.keyword" placeholder="搜索关键词" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-select v-model:value="query.status" :options="statusOptions" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button @click="handleSearch">搜索</n-button>
|
||||
<n-button @click="handleClear">清空</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<!-- 数据展示 -->
|
||||
<!-- ... -->
|
||||
|
||||
<!-- 分页组件 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="query.page"
|
||||
v-model:limit="query.limit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
```typescript
|
||||
const { query } = usePagination(
|
||||
{
|
||||
// 查询条件初始值
|
||||
keyword: "",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
defaultLimit: 20, // 默认每页条数,默认 10
|
||||
defaultPage: 1, // 默认页码,默认 1
|
||||
resetPageOnChange: true, // 查询条件变化时是否重置页码,默认 true
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 返回值说明
|
||||
|
||||
```typescript
|
||||
const {
|
||||
query, // 响应式查询对象,包含 page、limit 和自定义查询条件
|
||||
updateRoute, // 手动更新 URL(通常不需要调用)
|
||||
resetPage, // 重置页码到第一页
|
||||
clearQuery, // 清空所有查询条件
|
||||
syncFromRoute // 从 URL 同步到本地状态(通常不需要调用)
|
||||
} = usePagination()
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 迁移前(旧代码)
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const query = reactive({
|
||||
page: parseInt(route.query.page) || 1,
|
||||
limit: parseInt(route.query.limit) || 10,
|
||||
keyword: route.query.keyword ?? "",
|
||||
status: route.query.status ?? "",
|
||||
})
|
||||
|
||||
function routerPush() {
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: filterEmptyValue(query),
|
||||
})
|
||||
}
|
||||
|
||||
function clear() {
|
||||
query.keyword = ""
|
||||
query.status = ""
|
||||
}
|
||||
|
||||
// 大量的 watch 逻辑
|
||||
watch(() => query.page, routerPush)
|
||||
watch(() => [query.limit, query.status], () => {
|
||||
query.page = 1
|
||||
routerPush()
|
||||
})
|
||||
watchDebounced(() => query.keyword, () => {
|
||||
query.page = 1
|
||||
routerPush()
|
||||
}, { debounce: 500 })
|
||||
</script>
|
||||
```
|
||||
|
||||
### 迁移后(新代码)
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { query, clearQuery } = usePagination({
|
||||
keyword: "",
|
||||
status: "",
|
||||
})
|
||||
|
||||
function clear() {
|
||||
clearQuery()
|
||||
}
|
||||
|
||||
// 简化的监听逻辑
|
||||
watchDebounced(() => query.keyword, loadData, { debounce: 500 })
|
||||
watch([() => query.page, () => query.limit, () => query.status], loadData)
|
||||
</script>
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
1. **减少重复代码**:URL 同步逻辑自动处理
|
||||
2. **统一行为**:所有分页组件行为一致
|
||||
3. **类型安全**:完整的 TypeScript 支持
|
||||
4. **灵活配置**:支持自定义默认值和行为
|
||||
5. **易于维护**:集中管理分页逻辑
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 查询条件变化时会自动重置页码(可通过 `resetPageOnChange: false` 禁用)
|
||||
2. URL 同步是自动的,无需手动调用 `router.push`
|
||||
3. 组件挂载时会自动从 URL 同步初始状态
|
||||
4. 支持浏览器前进后退按钮
|
||||
@@ -1,123 +0,0 @@
|
||||
# 前端权限控制说明
|
||||
|
||||
本文档说明了前端如何根据后端权限设计实现权限控制。
|
||||
|
||||
## 权限等级
|
||||
|
||||
### 1. 普通用户 (Regular User)
|
||||
- admin_type: "Regular User"
|
||||
- problem_permission: "None"
|
||||
- 只能访问前台功能,无法进入管理后台
|
||||
|
||||
### 2. 管理员 (Admin)
|
||||
- admin_type: "Admin"
|
||||
- problem_permission: "None" | "Own" | "All"
|
||||
- 可以访问部分管理功能
|
||||
|
||||
### 3. 超级管理员 (Super Admin)
|
||||
- admin_type: "Super Admin"
|
||||
- problem_permission: "All" (自动设置)
|
||||
- 可以访问所有管理功能
|
||||
|
||||
## 权限控制实现
|
||||
|
||||
### 1. 用户状态管理 (`src/shared/store/user.ts`)
|
||||
```typescript
|
||||
const isAdminRole = computed(() =>
|
||||
user.value?.admin_type === USER_TYPE.ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN
|
||||
)
|
||||
const isSuperAdmin = computed(() =>
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN
|
||||
)
|
||||
const hasProblemPermission = computed(() =>
|
||||
user.value?.problem_permission !== PROBLEM_PERMISSION.NONE
|
||||
)
|
||||
```
|
||||
|
||||
### 2. 权限工具函数 (`src/utils/permissions.ts`)
|
||||
提供了一系列权限检查函数:
|
||||
- `canManageUsers`: 只有super_admin可以管理用户
|
||||
- `canManageProblems`: 需要题目权限
|
||||
- `canManageSystemConfig`: 只有super_admin可以管理系统配置
|
||||
- 等等...
|
||||
|
||||
### 3. 路由权限控制 (`src/main.ts`)
|
||||
在路由守卫中检查权限:
|
||||
- `requiresAdmin`: 需要admin或super_admin权限
|
||||
- `requiresSuperAdmin`: 只有super_admin可以访问
|
||||
- `requiresProblemPermission`: 需要题目管理权限
|
||||
|
||||
### 4. 菜单权限控制 (`src/shared/layout/admin.vue`)
|
||||
根据用户权限动态显示菜单项:
|
||||
- admin和super_admin都可以看到:题目管理
|
||||
- 只有super_admin可以看到:比赛管理、用户管理、系统设置、公告管理、评论管理、教程管理
|
||||
|
||||
## 各功能模块权限说明
|
||||
|
||||
### 用户管理 (需要 super_admin)
|
||||
- 创建、编辑、删除用户
|
||||
- 重置用户密码
|
||||
- 封禁/解封用户
|
||||
- 批量导入用户
|
||||
|
||||
### 题目管理 (需要 problem_permission)
|
||||
- `problem_permission = "None"`: 无法管理题目
|
||||
- `problem_permission = "Own"`: 只能管理自己创建的题目
|
||||
- `problem_permission = "All"`: 可以管理所有题目
|
||||
|
||||
### 比赛管理 (super_admin)
|
||||
- 创建、编辑、删除比赛
|
||||
- 管理比赛题目
|
||||
- 查看比赛数据
|
||||
|
||||
### 系统配置 (需要 super_admin)
|
||||
- SMTP邮件配置
|
||||
- 判题服务器管理
|
||||
- 网站基本设置
|
||||
- 测试用例清理
|
||||
|
||||
### 公告管理 (需要 super_admin)
|
||||
- 创建、编辑、删除公告
|
||||
|
||||
### 评论管理 (需要 super_admin)
|
||||
- 查看、删除用户评论
|
||||
|
||||
### 教程管理 (需要 super_admin)
|
||||
- 创建、编辑、删除教程
|
||||
|
||||
## 权限检查流程
|
||||
|
||||
1. **路由级权限检查**: 在 `main.ts` 的路由守卫中进行
|
||||
2. **组件级权限检查**: 在组件内部使用 `usePermissions()` 进行检查
|
||||
3. **UI权限控制**: 根据权限动态显示/隐藏UI元素
|
||||
|
||||
## 使用示例
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { usePermissions } from "~/utils/permissions"
|
||||
|
||||
const { canManageUsers, isSuperAdmin } = usePermissions()
|
||||
|
||||
// 权限检查
|
||||
if (!canManageUsers.value) {
|
||||
message.error("您没有权限访问此页面")
|
||||
router.push("/admin")
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 根据权限显示不同内容 -->
|
||||
<n-button v-if="isSuperAdmin" type="primary">
|
||||
超级管理员专用功能
|
||||
</n-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **前端权限控制只是UI层面的限制**,真正的权限控制在后端
|
||||
2. **所有API调用都会在后端进行权限验证**
|
||||
3. **权限信息来自后端用户接口**,前端不应该缓存或修改权限信息
|
||||
4. **路由权限检查是异步的**,需要等待用户信息加载完成
|
||||
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
|
||||
@@ -9,11 +9,14 @@
|
||||
<script>
|
||||
window.localStorage.setItem("maxkbMaskTip", true)
|
||||
</script>
|
||||
|
||||
<% if (process.env.PUBLIC_MAXKB_URL) { %>
|
||||
<script
|
||||
async
|
||||
defer
|
||||
src="<%= process.env.PUBLIC_MAXKB_URL %>"
|
||||
></script>
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
3268
package-lock.json
generated
49
package.json
@@ -12,36 +12,47 @@
|
||||
"dependencies": {
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"@wangeditor-next/editor": "^5.6.46",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.1",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@vue-flow/node-resizer": "^1.5.0",
|
||||
"@vue-flow/node-toolbar": "^1.1.1",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/router": "^14.1.0",
|
||||
"@wangeditor-next/editor": "^5.6.49",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"axios": "^1.12.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"chart.js": "^4.5.0",
|
||||
"axios": "^1.13.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"md-editor-v3": "^6.0.1",
|
||||
"naive-ui": "^2.43.1",
|
||||
"md-editor-v3": "^6.2.1",
|
||||
"mermaid": "^11.12.2",
|
||||
"naive-ui": "^2.43.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^3.0.3",
|
||||
"query-string": "^9.3.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.6.4",
|
||||
"y-codemirror.next": "^0.3.5",
|
||||
"y-webrtc": "^10.3.0",
|
||||
"yjs": "^13.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@rsbuild/core": "^1.5.13",
|
||||
"@rsbuild/plugin-vue": "^1.1.2",
|
||||
"@rsbuild/core": "^1.6.15",
|
||||
"@rsbuild/plugin-vue": "^1.2.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^24.5.2",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.2",
|
||||
"unplugin-auto-import": "^20.2.0",
|
||||
"unplugin-vue-components": "^29.1.0"
|
||||
"@types/node": "^25.0.3",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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/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>
|
||||
@@ -1,4 +1,16 @@
|
||||
@font-face {
|
||||
font-family: "Monaco";
|
||||
src: url(/Monaco.ttf);
|
||||
}
|
||||
font-family: "Monaco";
|
||||
src: url(/Monaco.ttf);
|
||||
}
|
||||
|
||||
.md-editor-preview .md-editor-code .md-editor-code-head {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
.md-editor-preview h1 {
|
||||
font-size: 1.6rem !important;
|
||||
}
|
||||
|
||||
.md-editor-preview h2 {
|
||||
font-size: 1.4rem !important;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ export default defineConfig(({ envMode }) => {
|
||||
mode: envMode,
|
||||
})
|
||||
|
||||
const url = rawPublicVars["PUBLIC_OJ_URL"]
|
||||
const proxyConfig = {
|
||||
target: url,
|
||||
headers: { Referer: url },
|
||||
target: rawPublicVars["PUBLIC_OJ_URL"],
|
||||
changeOrigin: true,
|
||||
}
|
||||
|
||||
const wsProxyConfig = {
|
||||
target: rawPublicVars["PUBLIC_WS_URL"],
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
}
|
||||
return {
|
||||
@@ -76,10 +80,10 @@ export default defineConfig(({ envMode }) => {
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": "./src",
|
||||
utils: "./src/utils",
|
||||
oj: "./src/oj",
|
||||
admin: "./src/admin",
|
||||
shared: "./src/shared",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@@ -87,6 +91,7 @@ export default defineConfig(({ envMode }) => {
|
||||
proxy: {
|
||||
"/api": proxyConfig,
|
||||
"/public": proxyConfig,
|
||||
"/ws": wsProxyConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
55
src/App.vue
@@ -1,17 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import hljs from "highlight.js/lib/core"
|
||||
import c from "highlight.js/lib/languages/c"
|
||||
import cpp from "highlight.js/lib/languages/cpp"
|
||||
import python from "highlight.js/lib/languages/python"
|
||||
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||
import "normalize.css"
|
||||
import "./index.css"
|
||||
|
||||
hljs.registerLanguage("c", c)
|
||||
hljs.registerLanguage("python", python)
|
||||
hljs.registerLanguage("cpp", cpp)
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useConfigUpdate } from "shared/composables/configUpdate"
|
||||
import { useMaxKB } from "shared/composables/maxkb"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const isDark = useDark()
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 初始化配置和实时更新
|
||||
onMounted(() => {
|
||||
configStore.getConfig()
|
||||
userStore.getMyProfile()
|
||||
})
|
||||
|
||||
// 使用配置更新和 MaxKB 功能
|
||||
useConfigUpdate()
|
||||
useMaxKB()
|
||||
|
||||
// 延迟加载 highlight.js,避免阻塞首屏
|
||||
const hljsInstance = ref<any>(null)
|
||||
const loadHighlightJS = async () => {
|
||||
if (hljsInstance.value) return hljsInstance.value
|
||||
|
||||
const hljs = (await import("highlight.js/lib/core")).default
|
||||
const c = (await import("highlight.js/lib/languages/c")).default
|
||||
const cpp = (await import("highlight.js/lib/languages/cpp")).default
|
||||
const python = (await import("highlight.js/lib/languages/python")).default
|
||||
|
||||
hljs.registerLanguage("c", c)
|
||||
hljs.registerLanguage("python", python)
|
||||
hljs.registerLanguage("cpp", cpp)
|
||||
|
||||
hljsInstance.value = hljs
|
||||
return hljs
|
||||
}
|
||||
|
||||
// 在空闲时预加载
|
||||
onMounted(() => {
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => loadHighlightJS())
|
||||
} else {
|
||||
setTimeout(() => loadHighlightJS(), 1000)
|
||||
}
|
||||
})
|
||||
|
||||
provide("hljs", hljsInstance)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +56,7 @@ const isDark = useDark()
|
||||
:theme="isDark ? darkTheme : null"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:hljs="hljs"
|
||||
:hljs="hljsInstance"
|
||||
>
|
||||
<n-message-provider>
|
||||
<router-view></router-view>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteAnnouncement } from "~/admin/api"
|
||||
import { deleteAnnouncement } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
announcementID: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { AnnouncementEdit } from "~/utils/types"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { AnnouncementEdit } from "utils/types"
|
||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Announcement } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Announcement } from "utils/types"
|
||||
import { editAnnouncement, getAnnouncementList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
|
||||
184
src/admin/api.ts
@@ -11,7 +11,7 @@ import {
|
||||
Tutorial,
|
||||
User,
|
||||
WebsiteConfig,
|
||||
} from "~/utils/types"
|
||||
} from "utils/types"
|
||||
|
||||
export function getBaseInfo() {
|
||||
return http.get("admin/dashboard_info")
|
||||
@@ -25,8 +25,8 @@ export async function getProblemList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword: string,
|
||||
author?: string,
|
||||
contestID?: string,
|
||||
ruleType?: "ACM" | "OI",
|
||||
) {
|
||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||
const res = await http.get(endpoint, {
|
||||
@@ -35,8 +35,8 @@ export async function getProblemList(
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
rule_type: ruleType,
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -86,9 +86,10 @@ export function getUserList(
|
||||
limit = 10,
|
||||
type = "",
|
||||
keyword: string,
|
||||
orderBy = "",
|
||||
) {
|
||||
return http.get("admin/user", {
|
||||
params: { paging: true, offset, limit, keyword, type },
|
||||
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,7 +134,6 @@ export async function uploadImage(file: File): Promise<string> {
|
||||
export function uploadTestcases(file: File) {
|
||||
const form = new window.FormData()
|
||||
form.append("file", file)
|
||||
form.append("spj", "false")
|
||||
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
@@ -256,3 +256,177 @@ export function deleteTutorial(id: number) {
|
||||
export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||
return http.put("admin/tutorial/visibility", { id, is_public })
|
||||
}
|
||||
|
||||
// 将竞赛题目转为公开题目
|
||||
export function makeProblemPublic(id: number, display_id: string) {
|
||||
return http.post("admin/contest_problem/make_public", {
|
||||
id,
|
||||
display_id,
|
||||
})
|
||||
}
|
||||
|
||||
// 比赛辅助检查
|
||||
export function getACMHelperList(contest_id: number) {
|
||||
return http.get("admin/contest/acm_helper", {
|
||||
params: { contest_id },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateACMHelperChecked(
|
||||
contest_id: number,
|
||||
rank_id: number,
|
||||
problem_id: string,
|
||||
checked: boolean,
|
||||
) {
|
||||
return http.put("admin/contest/acm_helper", {
|
||||
contest_id,
|
||||
rank_id,
|
||||
problem_id,
|
||||
checked,
|
||||
})
|
||||
}
|
||||
|
||||
// 题单管理 API
|
||||
export function getProblemSetList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword = "",
|
||||
difficulty = "",
|
||||
status = "",
|
||||
) {
|
||||
return http.get("admin/problemset", {
|
||||
params: {
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
difficulty,
|
||||
status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getProblemSetDetail(id: number) {
|
||||
return http.get(`admin/problemset/${id}`)
|
||||
}
|
||||
|
||||
export function createProblemSet(data: {
|
||||
title: string
|
||||
description: string
|
||||
difficulty: string
|
||||
status: string
|
||||
}) {
|
||||
return http.post("admin/problemset", data)
|
||||
}
|
||||
|
||||
export function editProblemSet(data: {
|
||||
id: number
|
||||
title?: string
|
||||
description?: string
|
||||
difficulty?: string
|
||||
status?: string
|
||||
visible?: boolean
|
||||
}) {
|
||||
return http.put("admin/problemset", data)
|
||||
}
|
||||
|
||||
export function deleteProblemSet(id: number) {
|
||||
return http.delete("admin/problemset", { params: { id } })
|
||||
}
|
||||
|
||||
export function toggleProblemSetVisible(id: number) {
|
||||
return http.put("admin/problemset/visible", { id })
|
||||
}
|
||||
|
||||
export function updateProblemSetStatus(id: number, status: string) {
|
||||
return http.put("admin/problemset/status", { id, status })
|
||||
}
|
||||
|
||||
// 题单题目管理 API
|
||||
export function getProblemSetProblems(problemSetId: number) {
|
||||
return http.get(`admin/problemset/${problemSetId}/problems`)
|
||||
}
|
||||
|
||||
export function addProblemToSet(
|
||||
problemSetId: number,
|
||||
data: {
|
||||
problem_id: string
|
||||
order?: number
|
||||
is_required?: boolean
|
||||
score?: number
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return http.post(`admin/problemset/${problemSetId}/problems`, data)
|
||||
}
|
||||
|
||||
export function editProblemInSet(
|
||||
problemSetId: number,
|
||||
problemSetProblemId: number,
|
||||
data: {
|
||||
order?: number
|
||||
is_required?: boolean
|
||||
score?: number
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return http.put(
|
||||
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
export function removeProblemFromSet(
|
||||
problemSetId: number,
|
||||
problemSetProblemId: number,
|
||||
) {
|
||||
return http.delete(
|
||||
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 题单奖章管理 API
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
return http.get(`admin/problemset/${problemSetId}/badges`)
|
||||
}
|
||||
|
||||
export function createProblemSetBadge(
|
||||
problemSetId: number,
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
condition_type: string
|
||||
condition_value: number
|
||||
level?: number
|
||||
},
|
||||
) {
|
||||
return http.post(`admin/problemset/${problemSetId}/badges`, data)
|
||||
}
|
||||
|
||||
export function editProblemSetBadge(
|
||||
problemSetId: number,
|
||||
badgeId: number,
|
||||
data: {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
condition_type?: string
|
||||
condition_value?: number
|
||||
level?: number
|
||||
},
|
||||
) {
|
||||
return http.put(`admin/problemset/${problemSetId}/badges/${badgeId}`, data)
|
||||
}
|
||||
|
||||
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||
return http.delete(`admin/problemset/${problemSetId}/badges/${badgeId}`)
|
||||
}
|
||||
|
||||
// 题单进度管理 API
|
||||
export function getProblemSetProgress(problemSetId: number) {
|
||||
return http.get(`admin/problemset/${problemSetId}/progress`)
|
||||
}
|
||||
|
||||
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
||||
return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Comment } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Comment } from "utils/types"
|
||||
import { getCommentList } from "../api"
|
||||
import CommentActions from "./components/CommentActions.vue"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteComment } from "~/admin/api"
|
||||
import { deleteComment } from "admin/api"
|
||||
|
||||
const props = defineProps<{ commentID: number }>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Contest } from "~/utils/types"
|
||||
import { Contest } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
contest: Contest
|
||||
@@ -20,12 +20,30 @@ function goEditProblems() {
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
function goACMHelper() {
|
||||
router.push({
|
||||
name: "admin contest helper",
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
const isACM = computed(() => props.contest.rule_type === "ACM")
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
||||
题目
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="isACM"
|
||||
size="small"
|
||||
type="warning"
|
||||
secondary
|
||||
@click="goACMHelper"
|
||||
>
|
||||
审核
|
||||
</n-button>
|
||||
<n-button size="small" type="info" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { formatISO } from "date-fns"
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { BlankContest } from "~/utils/types"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { BlankContest } from "utils/types"
|
||||
import { createContest, editContest, getContest } from "../api"
|
||||
|
||||
interface Props {
|
||||
|
||||
325
src/admin/contest/helper.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
||||
import { getSubmission, getSubmissions } from "oj/api"
|
||||
import SubmissionDetail from "oj/submission/detail.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
|
||||
interface Props {
|
||||
contestID: string
|
||||
}
|
||||
|
||||
interface HelperItem {
|
||||
id: number
|
||||
username: string
|
||||
real_name: string
|
||||
problem_id: string
|
||||
problem_display_id: string
|
||||
ac_info: {
|
||||
is_ac: boolean
|
||||
ac_time: number
|
||||
error_number: number
|
||||
checked?: boolean
|
||||
}
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const message = useMessage()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const submissions = ref<HelperItem[]>([])
|
||||
const contestStartTime = ref<Date | null>(null)
|
||||
const query = reactive({
|
||||
username: "",
|
||||
problemId: "",
|
||||
checked: "all",
|
||||
})
|
||||
|
||||
// 检查状态选项
|
||||
const checkedOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "已检查", value: "checked" },
|
||||
{ label: "未检查", value: "unchecked" },
|
||||
]
|
||||
|
||||
// 代码查看模态框
|
||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||
const currentSubmission = ref<any>(null)
|
||||
|
||||
// 格式化 AC 时间(ac_time 是相对于比赛开始的秒数)
|
||||
function formatACTime(relativeSeconds: number) {
|
||||
if (!contestStartTime.value) return "-"
|
||||
const acTime = new Date(
|
||||
contestStartTime.value.getTime() + relativeSeconds * 1000,
|
||||
)
|
||||
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
|
||||
}
|
||||
|
||||
// 切换检查状态
|
||||
async function toggleChecked(item: HelperItem) {
|
||||
const newChecked = !item.checked
|
||||
try {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problem_id,
|
||||
newChecked,
|
||||
)
|
||||
// 更新本地状态
|
||||
item.checked = newChecked
|
||||
item.ac_info.checked = newChecked
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 批量标记为已检查
|
||||
async function markAllAsChecked() {
|
||||
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
|
||||
if (unchecked.length === 0) {
|
||||
message.info("没有需要标记的提交")
|
||||
return
|
||||
}
|
||||
|
||||
const loadingMsg = message.loading("正在标记...", { duration: 0 })
|
||||
try {
|
||||
for (const item of unchecked) {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problem_id,
|
||||
true,
|
||||
)
|
||||
item.checked = true
|
||||
item.ac_info.checked = true
|
||||
}
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
loadingMsg.destroy()
|
||||
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
||||
} catch (err: any) {
|
||||
loadingMsg.destroy()
|
||||
message.error(err.data || "批量操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤后的提交列表
|
||||
const filteredSubmissions = computed(() => {
|
||||
return submissions.value.filter((item) => {
|
||||
if (query.username && !item.username.includes(query.username)) return false
|
||||
if (query.problemId && !item.problem_display_id.includes(query.problemId))
|
||||
return false
|
||||
if (query.checked === "checked" && !item.checked) return false
|
||||
if (query.checked === "unchecked" && item.checked) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 统计信息
|
||||
const stats = computed(() => {
|
||||
const total = submissions.value.length
|
||||
const checked = submissions.value.filter((item) => item.checked).length
|
||||
const unchecked = total - checked
|
||||
return { total, checked, unchecked }
|
||||
})
|
||||
|
||||
// 查看代码 - 获取该用户在该题目的 AC 提交
|
||||
async function viewSubmission(item: HelperItem) {
|
||||
try {
|
||||
// 查询该用户在该竞赛该题目的 AC 提交
|
||||
const res = await getSubmissions({
|
||||
username: item.username,
|
||||
problem_id: item.problem_display_id,
|
||||
contest_id: props.contestID,
|
||||
result: "0", // ACCEPTED
|
||||
language: "",
|
||||
page: 1,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (res.data.results.length === 0) {
|
||||
message.warning("未找到该用户的 AC 提交")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取提交详情
|
||||
const submissionListItem = res.data.results[0]
|
||||
const detailRes = await getSubmission(submissionListItem.id)
|
||||
|
||||
// 手动添加 contest 字段(ACM模式下后端不返回此字段)
|
||||
currentSubmission.value = {
|
||||
...detailRes.data,
|
||||
contest: Number(props.contestID),
|
||||
problem_display_id: item.problem_display_id,
|
||||
}
|
||||
|
||||
toggleCodePanel(true)
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "加载提交失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
try {
|
||||
// 先获取比赛信息,获取开始时间
|
||||
const contestRes = await getContest(props.contestID)
|
||||
contestStartTime.value = new Date(contestRes.data.start_time)
|
||||
|
||||
// 再获取 AC 提交列表
|
||||
const { data } = await getACMHelperList(Number(props.contestID))
|
||||
submissions.value = data
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "加载失败")
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<HelperItem>[] = [
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "题目",
|
||||
key: "problem_display_id",
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { type: "info" }, () => row.problem_display_id),
|
||||
},
|
||||
{
|
||||
title: "AC时间",
|
||||
key: "ac_time",
|
||||
width: 180,
|
||||
render: (row) => formatACTime(row.ac_info.ac_time),
|
||||
},
|
||||
{
|
||||
title: "错误次数",
|
||||
key: "error_number",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: row.ac_info.error_number > 0 ? "warning" : "success",
|
||||
size: "small",
|
||||
},
|
||||
() => row.ac_info.error_number,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "已检查",
|
||||
key: "checked",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NCheckbox, {
|
||||
checked: row.checked,
|
||||
onUpdateChecked: () => toggleChecked(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "primary",
|
||||
secondary: true,
|
||||
onClick: () => viewSubmission(row),
|
||||
},
|
||||
() => "查看代码",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-flex align="center">
|
||||
<h2 style="margin: 0">比赛辅助检查</h2>
|
||||
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag>
|
||||
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag>
|
||||
<n-tag type="warning" size="large">
|
||||
未检查: {{ stats.unchecked }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="stats.unchecked === 0"
|
||||
@click="markAllAsChecked"
|
||||
>
|
||||
标记全部为已检查
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-alert type="info" style="margin-bottom: 16px">
|
||||
<template #header>使用说明</template>
|
||||
此工具用于赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选"已检查"。
|
||||
</n-alert>
|
||||
|
||||
<n-flex align="center" style="margin-bottom: 16px">
|
||||
<n-input
|
||||
v-model:value="query.username"
|
||||
placeholder="筛选用户名"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="query.problemId"
|
||||
placeholder="筛选题目"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.checked"
|
||||
:options="checkedOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="filteredSubmissions"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
<n-modal
|
||||
v-model:show="codePanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="代码详情"
|
||||
>
|
||||
<SubmissionDetail
|
||||
v-if="currentSubmission"
|
||||
:submission="currentSubmission"
|
||||
:problemID="currentSubmission.problem_display_id"
|
||||
:submissionID="currentSubmission.id"
|
||||
hideList
|
||||
/>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-data-table) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch, NTag } from "naive-ui"
|
||||
import ContestTitle from "~/shared/components/ContestTitle.vue"
|
||||
import ContestType from "~/shared/components/ContestType.vue"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "~/utils/constants"
|
||||
import { Contest } from "~/utils/types"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import ContestType from "shared/components/ContestType.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "utils/constants"
|
||||
import { Contest } from "utils/types"
|
||||
import { editContest, getContestList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
@@ -66,7 +66,7 @@ const columns: DataTableColumn<Contest>[] = [
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
width: 220,
|
||||
render: (row) => h(Actions, { contest: row }),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteContestProblem, deleteProblem } from "~/admin/api"
|
||||
import download from "~/utils/download"
|
||||
import {
|
||||
deleteContestProblem,
|
||||
deleteProblem,
|
||||
makeProblemPublic,
|
||||
} from "admin/api"
|
||||
import download from "utils/download"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
problemDisplayID: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
const emit = defineEmits(["updated"])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const isContestProblem = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
|
||||
const showMakePublicModal = ref(false)
|
||||
const newDisplayID = ref("")
|
||||
|
||||
async function handleDeleteProblem() {
|
||||
try {
|
||||
if (route.name === "admin contest problem list") {
|
||||
@@ -21,7 +32,7 @@ async function handleDeleteProblem() {
|
||||
await deleteProblem(props.problemID)
|
||||
}
|
||||
message.success("删除成功")
|
||||
emit("deleted")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Can't delete the problem as it has submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
@@ -53,6 +64,33 @@ function goCheck() {
|
||||
}
|
||||
window.open(data.href, "_blank")
|
||||
}
|
||||
|
||||
function openMakePublicModal() {
|
||||
newDisplayID.value = ""
|
||||
showMakePublicModal.value = true
|
||||
}
|
||||
|
||||
async function handleMakePublic() {
|
||||
if (!newDisplayID.value.trim()) {
|
||||
message.error("请输入新的题目编号")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
|
||||
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display ID") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (err.data === "Already be a public problem") {
|
||||
message.error("该题目已经是公开题目")
|
||||
} else {
|
||||
message.error("转换失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
@@ -62,6 +100,19 @@ function goCheck() {
|
||||
<n-button size="small" secondary type="info" @click="goCheck">
|
||||
查看
|
||||
</n-button>
|
||||
<n-tooltip v-if="isContestProblem">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
type="warning"
|
||||
@click="openMakePublicModal"
|
||||
>
|
||||
公开
|
||||
</n-button>
|
||||
</template>
|
||||
将此竞赛题目转为公开题目
|
||||
</n-tooltip>
|
||||
<n-popconfirm @positive-click="handleDeleteProblem">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="error">删除</n-button>
|
||||
@@ -75,4 +126,35 @@ function goCheck() {
|
||||
下载测试用例
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showMakePublicModal"
|
||||
preset="card"
|
||||
title="转为公开题目"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>
|
||||
将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。
|
||||
</p>
|
||||
<n-form>
|
||||
<n-form-item label="新的题目编号" required>
|
||||
<n-input
|
||||
v-model:value="newDisplayID"
|
||||
placeholder="例如: 1001"
|
||||
clearable
|
||||
@keyup.enter="handleMakePublic"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
|
||||
</n-alert>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showMakePublicModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleMakePublic">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { addProblemForContest } from "~/admin/api"
|
||||
import { addProblemForContest } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { getProblemList } from "~/admin/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { AdminProblemFiltered } from "~/utils/types"
|
||||
import { getProblemList } from "admin/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { AdminProblemFiltered } from "utils/types"
|
||||
import AddButton from "./AddButton.vue"
|
||||
|
||||
interface Props {
|
||||
@@ -42,13 +42,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
|
||||
async function getList() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
"",
|
||||
"ACM",
|
||||
)
|
||||
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
|
||||
total.value = res.total
|
||||
problems.value = res.results
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { getProblemTagList } from "~/shared/api"
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import {
|
||||
CODE_TEMPLATES,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
STORAGE_KEY,
|
||||
} from "~/utils/constants"
|
||||
import download from "~/utils/download"
|
||||
import { unique } from "~/utils/functions"
|
||||
import { BlankProblem, LANGUAGE, Tag } from "~/utils/types"
|
||||
} from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { unique } from "utils/functions"
|
||||
import { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
} from "../api"
|
||||
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("~/shared/components/CodeEditor.vue"),
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
|
||||
const MermaidEditor = defineAsyncComponent(
|
||||
() => import("shared/components/MermaidEditor.vue"),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
@@ -50,31 +54,35 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
||||
output_description: "",
|
||||
time_limit: 1000,
|
||||
memory_limit: 64,
|
||||
difficulty: "Low",
|
||||
difficulty: "Low" as "Low" | "Mid" | "High",
|
||||
visible: false,
|
||||
share_submission: false,
|
||||
tags: [],
|
||||
languages: ["C", "Python3"],
|
||||
template: {},
|
||||
languages: ["Python3", "C"] as LANGUAGE[],
|
||||
template: {} as { [key in LANGUAGE]?: string },
|
||||
samples: [
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
],
|
||||
spj: false,
|
||||
spj_language: "",
|
||||
spj_code: "",
|
||||
spj_compile_ok: false,
|
||||
test_case_id: "",
|
||||
test_case_score: [],
|
||||
test_case_score: [] as Testcase[],
|
||||
rule_type: "ACM",
|
||||
hint: "",
|
||||
source: "",
|
||||
prompt: "",
|
||||
answers: [] as { language: LANGUAGE; code: string }[],
|
||||
io_mode: {
|
||||
io_mode: "Standard IO",
|
||||
input: "input.txt",
|
||||
output: "output.txt",
|
||||
},
|
||||
contest_id: "",
|
||||
allow_flowchart: false,
|
||||
mermaid_code: "",
|
||||
flowchart_data: {},
|
||||
flowchart_hint: "",
|
||||
show_flowchart: false,
|
||||
})
|
||||
|
||||
// 从服务器来的tag列表
|
||||
@@ -90,14 +98,18 @@ const tags = useLocalStorage<Tags>(STORAGE_KEY.ADMIN_PROBLEM_TAGS, {
|
||||
upload: [],
|
||||
})
|
||||
|
||||
// 这三个用的少,就不缓存本地了
|
||||
// 这几个用的少,就不缓存本地了
|
||||
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
||||
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
|
||||
const currentActiveTemplate = ref<LANGUAGE>("C")
|
||||
const currentActiveTemplate = ref<LANGUAGE>("Python3")
|
||||
const currentActiveAnswer = ref<LANGUAGE>("Python3")
|
||||
|
||||
// 给 TextEditor 用
|
||||
const [ready, toggleReady] = useToggle(false)
|
||||
|
||||
// Mermaid 渲染状态
|
||||
const mermaidRenderSuccess = ref(false)
|
||||
|
||||
const difficultyOptions: SelectOption[] = [
|
||||
{ label: "简单", value: "Low" },
|
||||
{ label: "中等", value: "Mid" },
|
||||
@@ -139,15 +151,26 @@ async function getProblemDetail() {
|
||||
problem.value.template = data.template
|
||||
problem.value.samples = data.samples
|
||||
problem.value.samples = data.samples
|
||||
problem.value.spj = data.spj
|
||||
problem.value.spj_language = data.spj_language
|
||||
problem.value.spj_code = data.spj_code
|
||||
problem.value.spj_compile_ok = data.spj_compile_ok
|
||||
problem.value.test_case_id = data.test_case_id
|
||||
problem.value.test_case_score = data.test_case_score
|
||||
problem.value.rule_type = data.rule_type
|
||||
problem.value.hint = data.hint
|
||||
problem.value.source = data.source
|
||||
problem.value.prompt = data.prompt
|
||||
// 流程图相关字段
|
||||
problem.value.allow_flowchart = data.allow_flowchart
|
||||
problem.value.show_flowchart = data.show_flowchart
|
||||
problem.value.mermaid_code = data.mermaid_code ?? ""
|
||||
problem.value.flowchart_hint = data.flowchart_hint ?? ""
|
||||
problem.value.flowchart_data = data.flowchart_data
|
||||
if (data.answers && data.answers.length) {
|
||||
problem.value.answers = data.answers
|
||||
} else {
|
||||
problem.value.answers = data.languages.map((lang: LANGUAGE) => ({
|
||||
language: lang,
|
||||
code: "",
|
||||
}))
|
||||
}
|
||||
problem.value.io_mode = data.io_mode
|
||||
if (problem.value.contest_id) {
|
||||
problem.value.contest_id = problem.value.contest_id
|
||||
@@ -213,9 +236,6 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
||||
const testcases = res.data.info
|
||||
for (let file of testcases) {
|
||||
file.score = (100 / testcases.length).toFixed(0)
|
||||
if (!file.output_name && problem.value.spj) {
|
||||
file.output_name = "-"
|
||||
}
|
||||
}
|
||||
problem.value.test_case_score = testcases
|
||||
problem.value.test_case_id = res.data.id
|
||||
@@ -228,18 +248,23 @@ function downloadTestcases() {
|
||||
download("test_case?problem_id=" + problem.value.id)
|
||||
}
|
||||
|
||||
// Mermaid 渲染事件处理
|
||||
function onMermaidRenderSuccess() {
|
||||
mermaidRenderSuccess.value = true
|
||||
}
|
||||
|
||||
// 题目是否有漏写的
|
||||
function detectProblemCompletion() {
|
||||
let flag = false
|
||||
async function validateProblem() {
|
||||
let hasErrors = false
|
||||
// 标题
|
||||
if (!problem.value._id || !problem.value.title) {
|
||||
message.error("编号或标题没有填写")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 标签
|
||||
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) {
|
||||
message.error("标签没有填写")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 题目
|
||||
else if (
|
||||
@@ -248,12 +273,12 @@ function detectProblemCompletion() {
|
||||
!problem.value.output_description
|
||||
) {
|
||||
message.error("题目或输入或输出没有填写")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 样例
|
||||
else if (problem.value.samples.length == 0) {
|
||||
message.error("样例没有填写")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 样例是空的
|
||||
else if (
|
||||
@@ -262,21 +287,34 @@ function detectProblemCompletion() {
|
||||
)
|
||||
) {
|
||||
message.error("空样例没有删干净")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 测试用例
|
||||
else if (problem.value.test_case_score.length === 0) {
|
||||
message.error("测试用例没有上传")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
} else if (problem.value.languages.length === 0) {
|
||||
message.error("编程语言没有选择")
|
||||
flag = true
|
||||
hasErrors = true
|
||||
}
|
||||
// 流程图验证
|
||||
else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
|
||||
if (
|
||||
!problem.value.mermaid_code ||
|
||||
problem.value.mermaid_code.trim() === ""
|
||||
) {
|
||||
message.error("启用了流程图功能,但流程图代码为空")
|
||||
hasErrors = true
|
||||
} else if (!mermaidRenderSuccess.value) {
|
||||
message.error("Mermaid 代码尚未成功渲染,请检查代码语法")
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
// 通过了
|
||||
else {
|
||||
flag = false
|
||||
hasErrors = false
|
||||
}
|
||||
return flag
|
||||
return hasErrors
|
||||
}
|
||||
|
||||
function getTemplate() {
|
||||
@@ -300,11 +338,18 @@ function filterHint() {
|
||||
}
|
||||
}
|
||||
|
||||
function filterAnswers() {
|
||||
problem.value.answers = problem.value.answers.filter(
|
||||
(ans) => ans.code.trim() !== "",
|
||||
)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const notCompleted = detectProblemCompletion()
|
||||
if (notCompleted) return
|
||||
const hasValidationErrors = await validateProblem()
|
||||
if (hasValidationErrors) return
|
||||
filterHint()
|
||||
getTemplate()
|
||||
filterAnswers()
|
||||
const api = {
|
||||
"admin problem create": createProblem,
|
||||
"admin problem edit": editProblem,
|
||||
@@ -372,6 +417,19 @@ watch(
|
||||
problem.value.tags = uniqueTags
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => problem.value.languages,
|
||||
(langs) => {
|
||||
const answers = langs.map((lang) => {
|
||||
const existing = problem.value.answers.find(
|
||||
(ans) => ans.language === lang,
|
||||
)
|
||||
return existing || { language: lang, code: "" }
|
||||
})
|
||||
problem.value.answers = answers
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -459,39 +517,138 @@ watch(
|
||||
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
||||
添加用例
|
||||
</n-button>
|
||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示" />
|
||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
|
||||
<n-form>
|
||||
<n-form-item label="题目的来源">
|
||||
<n-form-item label="题目的来源(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.source"
|
||||
placeholder="比如来自某道题的改编等,或者网上的资料"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form v-if="needTemplate">
|
||||
<n-form-item label="编写代码模板">
|
||||
<n-tabs
|
||||
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 label="本题的考察知识点(选填,用于 AI 分析)">
|
||||
<n-input
|
||||
v-model:value="problem.prompt"
|
||||
placeholder="比如考察选择、循环、算法等知识点"
|
||||
/>
|
||||
</n-form-item>
|
||||
</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
|
||||
class="box"
|
||||
v-if="problem.test_case_score.length"
|
||||
@@ -517,57 +674,24 @@ watch(
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-space style="margin-bottom: 100px" justify="space-between">
|
||||
<n-form inline label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="语言">
|
||||
<n-checkbox-group v-model:value="problem.languages">
|
||||
<n-flex align="center">
|
||||
<n-checkbox
|
||||
v-for="(language, index) in languageOptions"
|
||||
:key="index"
|
||||
:value="language.value"
|
||||
:label="language.label"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-checkbox
|
||||
v-model:checked="needTemplate"
|
||||
label="代码模板(一般用不到)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button
|
||||
v-if="needTemplate"
|
||||
size="small"
|
||||
tertiary
|
||||
type="warning"
|
||||
@click="resetTemplate(currentActiveTemplate)"
|
||||
>
|
||||
重置 {{ LANGUAGE_SHOW_VALUE[currentActiveTemplate] }} 代码模板
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex align="center">
|
||||
<n-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>
|
||||
<n-flex style="margin-bottom: 120px" align="center" justify="end">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -594,8 +718,4 @@ watch(
|
||||
.addSamples {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template {
|
||||
width: 60%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { AdminProblemFiltered } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { AdminProblemFiltered } from "utils/types"
|
||||
import { getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Modal from "./components/Modal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
|
||||
interface Props {
|
||||
contestID?: string
|
||||
@@ -34,11 +36,13 @@ const problems = ref<AdminProblemFiltered[]>([])
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
author: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: "",
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
@@ -55,6 +59,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
minWidth: 80,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
@@ -66,17 +71,16 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 260,
|
||||
width: 330,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemID: row.id,
|
||||
problemDisplayID: row._id,
|
||||
onDeleted: listProblems,
|
||||
onUpdated: listProblems,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async function listProblems() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
@@ -84,6 +88,7 @@ async function listProblems() {
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.author,
|
||||
props.contestID,
|
||||
)
|
||||
total.value = res.total
|
||||
@@ -115,17 +120,13 @@ async function selectProblems() {
|
||||
onMounted(listProblems)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
listProblems,
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watchDebounced(() => query.keyword, listProblems, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit],
|
||||
listProblems,
|
||||
)
|
||||
watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -149,10 +150,19 @@ watch(
|
||||
type="primary"
|
||||
@click="selectProblems"
|
||||
>
|
||||
从题库中选择
|
||||
从题目中选择
|
||||
</n-button>
|
||||
<n-flex align="center" v-if="!props.contestID">
|
||||
<span>出题人</span>
|
||||
<AuthorSelect v-model:value="query.author" all />
|
||||
</n-flex>
|
||||
<div>
|
||||
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" />
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
105
src/admin/problemset/components/Actions.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
problemSetId: number
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["updated"])
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showStatusModal = ref(false)
|
||||
const newStatus = ref<"active" | "archived" | "draft">("active")
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
async function handleDeleteProblemSet() {
|
||||
try {
|
||||
await deleteProblemSet(props.problemSetId)
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
message.error("删除失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin problemset edit",
|
||||
params: { problemSetId: props.problemSetId },
|
||||
})
|
||||
}
|
||||
|
||||
function goDetail() {
|
||||
router.push({
|
||||
name: "admin problemset detail",
|
||||
params: { problemSetId: props.problemSetId },
|
||||
})
|
||||
}
|
||||
|
||||
function openStatusModal() {
|
||||
showStatusModal.value = true
|
||||
}
|
||||
|
||||
async function handleUpdateStatus() {
|
||||
try {
|
||||
await updateProblemSetStatus(props.problemSetId, newStatus.value)
|
||||
message.success("状态更新成功")
|
||||
showStatusModal.value = false
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
message.error("状态更新失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" secondary type="primary" @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="small" secondary type="info" @click="goDetail">
|
||||
详情
|
||||
</n-button>
|
||||
<n-button size="small" secondary type="warning" @click="openStatusModal">
|
||||
状态
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="handleDeleteProblemSet">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="error">删除</n-button>
|
||||
</template>
|
||||
确定删除这个题单吗?删除后题单将不可见。
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showStatusModal"
|
||||
preset="card"
|
||||
title="更新题单状态"
|
||||
style="width: 400px"
|
||||
>
|
||||
<n-space vertical>
|
||||
<n-form>
|
||||
<n-form-item label="状态" required>
|
||||
<n-select
|
||||
v-model:value="newStatus"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showStatusModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
162
src/admin/problemset/components/AddBadgeModal.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
condition_type: "all_problems" | "problem_count" | "score"
|
||||
condition_value?: number
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const newBadgeName = ref("")
|
||||
const newBadgeDescription = ref("")
|
||||
const newBadgeIcon = ref("")
|
||||
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||
"all_problems",
|
||||
)
|
||||
const newBadgeConditionValue = ref(1)
|
||||
|
||||
const BADGE_LEN = 6
|
||||
const badgeIconOptions = []
|
||||
for (let i = 1; i <= BADGE_LEN; i++) {
|
||||
badgeIconOptions.push({
|
||||
label: `奖章${i}`,
|
||||
value: `/badge-${i}.png`,
|
||||
icon: `/badge-${i}.png`,
|
||||
})
|
||||
}
|
||||
|
||||
const conditionTypeOptions = [
|
||||
{ label: "完成所有题目", value: "all_problems" },
|
||||
{ label: "完成指定数量题目", value: "problem_count" },
|
||||
{ label: "达到指定分数", value: "score" },
|
||||
]
|
||||
|
||||
function handleConfirm() {
|
||||
const data: any = {
|
||||
name: newBadgeName.value,
|
||||
description: newBadgeDescription.value,
|
||||
icon: newBadgeIcon.value,
|
||||
condition_type: newBadgeConditionType.value,
|
||||
}
|
||||
|
||||
// 只有非"完成所有题目"时才添加条件值
|
||||
if (newBadgeConditionType.value !== "all_problems") {
|
||||
data.condition_value = newBadgeConditionValue.value
|
||||
}
|
||||
|
||||
emit("confirm", data)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
newBadgeName.value = ""
|
||||
newBadgeDescription.value = ""
|
||||
newBadgeIcon.value = ""
|
||||
newBadgeConditionType.value = "all_problems"
|
||||
newBadgeConditionValue.value = 1
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="添加奖章"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="奖章名称" required>
|
||||
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="newBadgeDescription"
|
||||
type="textarea"
|
||||
placeholder="奖章描述"
|
||||
required
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="图标" required>
|
||||
<n-flex align="center" gap="small">
|
||||
<div
|
||||
v-for="option in badgeIconOptions"
|
||||
:key="option.value"
|
||||
@click="newBadgeIcon = option.value"
|
||||
:style="{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border:
|
||||
newBadgeIcon === option.value
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor:
|
||||
newBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
}"
|
||||
>
|
||||
<n-image
|
||||
:src="option.icon"
|
||||
width="50"
|
||||
height="50"
|
||||
object-fit="cover"
|
||||
preview-disabled
|
||||
style="border-radius: 2px"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-flex align="center">
|
||||
<n-form-item label="获得条件">
|
||||
<n-select
|
||||
style="width: 200px"
|
||||
v-model:value="newBadgeConditionType"
|
||||
:options="conditionTypeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="条件值"
|
||||
v-if="newBadgeConditionType !== 'all_problems'"
|
||||
>
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
v-model:value="newBadgeConditionValue"
|
||||
placeholder="条件值"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
103
src/admin/problemset/components/AddProblemModal.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
problem_id: string
|
||||
order: number
|
||||
is_required: boolean
|
||||
score: number
|
||||
hint: string
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const newProblemId = ref("")
|
||||
const newProblemOrder = ref(0)
|
||||
const newProblemRequired = ref(true)
|
||||
const newProblemScore = ref(0)
|
||||
const newProblemHint = ref("")
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm", {
|
||||
problem_id: newProblemId.value,
|
||||
order: newProblemOrder.value,
|
||||
is_required: newProblemRequired.value,
|
||||
score: newProblemScore.value,
|
||||
hint: newProblemHint.value,
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
newProblemId.value = ""
|
||||
newProblemOrder.value = 0
|
||||
newProblemRequired.value = true
|
||||
newProblemScore.value = 0
|
||||
newProblemHint.value = ""
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="添加题目"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="题目ID" required>
|
||||
<n-input
|
||||
v-model:value="newProblemId"
|
||||
placeholder="请输入题目的显示ID(如:1001)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="newProblemOrder"
|
||||
placeholder="题目在题单中的顺序"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="newProblemRequired" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分数">
|
||||
<n-input-number
|
||||
v-model:value="newProblemScore"
|
||||
placeholder="题目分数"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="提示">
|
||||
<n-input
|
||||
v-model:value="newProblemHint"
|
||||
type="textarea"
|
||||
placeholder="题目提示"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
96
src/admin/problemset/components/BadgeManagement.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { ProblemSetBadge } from "utils/types"
|
||||
import { NButton, NImage } from "naive-ui"
|
||||
|
||||
interface Props {
|
||||
badges: ProblemSetBadge[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "add-badge"): void
|
||||
(e: "edit-badge", badge: ProblemSetBadge): void
|
||||
(e: "delete-badge", badgeId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<h3>奖章列表</h3>
|
||||
<n-button type="primary" @click="$emit('add-badge')"> 添加奖章 </n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{
|
||||
title: '图标',
|
||||
key: 'icon',
|
||||
render: (row) =>
|
||||
h(NImage, {
|
||||
src: row.icon,
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: 'cover',
|
||||
previewDisabled: true,
|
||||
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
|
||||
}),
|
||||
},
|
||||
{ title: '名称', key: 'name' },
|
||||
{
|
||||
title: '条件类型',
|
||||
key: 'condition_type',
|
||||
render: (row) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
all_problems: '完成所有题目',
|
||||
problem_count: '完成指定数量题目',
|
||||
score: '达到指定分数',
|
||||
}
|
||||
return typeMap[row.condition_type] || row.condition_type
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '条件值',
|
||||
key: 'condition_value',
|
||||
render: (row) => {
|
||||
return row.condition_type === 'all_problems'
|
||||
? '-'
|
||||
: row.condition_value
|
||||
},
|
||||
},
|
||||
{ title: '描述', key: 'description' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) =>
|
||||
h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
secondary: true,
|
||||
onClick: () => $emit('edit-badge', row),
|
||||
},
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => $emit('delete-badge', row.id),
|
||||
},
|
||||
{ default: () => '删除' },
|
||||
),
|
||||
]),
|
||||
},
|
||||
]"
|
||||
:data="badges"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
167
src/admin/problemset/components/EditBadgeModal.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { ProblemSetBadge } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
badge: ProblemSetBadge | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
condition_type: "all_problems" | "problem_count" | "score"
|
||||
condition_value?: number
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const editBadgeName = ref("")
|
||||
const editBadgeDescription = ref("")
|
||||
const editBadgeIcon = ref("")
|
||||
const editBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||
"all_problems",
|
||||
)
|
||||
const editBadgeConditionValue = ref(1)
|
||||
|
||||
// 预设奖章图标选项
|
||||
const BADGE_LEN = 6
|
||||
const badgeIconOptions = []
|
||||
for (let i = 1; i <= BADGE_LEN; i++) {
|
||||
badgeIconOptions.push({
|
||||
label: `奖章${i}`,
|
||||
value: `/badge-${i}.png`,
|
||||
icon: `/badge-${i}.png`,
|
||||
})
|
||||
}
|
||||
|
||||
const conditionTypeOptions = [
|
||||
{ label: "完成所有题目", value: "all_problems" },
|
||||
{ label: "完成指定数量题目", value: "problem_count" },
|
||||
{ label: "达到指定分数", value: "score" },
|
||||
]
|
||||
|
||||
function handleConfirm() {
|
||||
const data: any = {
|
||||
name: editBadgeName.value,
|
||||
description: editBadgeDescription.value,
|
||||
icon: editBadgeIcon.value,
|
||||
condition_type: editBadgeConditionType.value,
|
||||
}
|
||||
|
||||
// 只有非"完成所有题目"时才添加条件值
|
||||
if (editBadgeConditionType.value !== "all_problems") {
|
||||
data.condition_value = editBadgeConditionValue.value
|
||||
}
|
||||
|
||||
emit("confirm", data)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 当奖章数据变化时,更新表单数据
|
||||
watch(
|
||||
() => props.badge,
|
||||
(newBadge) => {
|
||||
if (newBadge) {
|
||||
editBadgeName.value = newBadge.name
|
||||
editBadgeDescription.value = newBadge.description
|
||||
editBadgeIcon.value = newBadge.icon
|
||||
editBadgeConditionType.value = newBadge.condition_type
|
||||
editBadgeConditionValue.value = newBadge.condition_value
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="编辑奖章"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form v-if="badge">
|
||||
<n-form-item label="奖章名称" required>
|
||||
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="editBadgeDescription"
|
||||
type="textarea"
|
||||
placeholder="奖章描述"
|
||||
required
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="图标" required>
|
||||
<n-flex align="center" gap="small">
|
||||
<div
|
||||
v-for="option in badgeIconOptions"
|
||||
:key="option.value"
|
||||
@click="editBadgeIcon = option.value"
|
||||
:style="{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border:
|
||||
editBadgeIcon === option.value
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor:
|
||||
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
}"
|
||||
>
|
||||
<n-image
|
||||
:src="option.icon"
|
||||
width="50"
|
||||
height="50"
|
||||
object-fit="cover"
|
||||
style="border-radius: 2px"
|
||||
preview-disabled
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-flex align="center">
|
||||
<n-form-item label="获得条件">
|
||||
<n-select
|
||||
style="width: 200px"
|
||||
v-model:value="editBadgeConditionType"
|
||||
:options="conditionTypeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="条件值"
|
||||
v-if="editBadgeConditionType !== 'all_problems'"
|
||||
>
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
v-model:value="editBadgeConditionValue"
|
||||
placeholder="条件值"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
100
src/admin/problemset/components/EditProblemModal.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ProblemSetProblem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
problem: ProblemSetProblem | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
order: number
|
||||
is_required: boolean
|
||||
score: number
|
||||
hint: string
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const editProblemOrder = ref(0)
|
||||
const editProblemRequired = ref(true)
|
||||
const editProblemScore = ref(0)
|
||||
const editProblemHint = ref("")
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm", {
|
||||
order: editProblemOrder.value,
|
||||
is_required: editProblemRequired.value,
|
||||
score: editProblemScore.value,
|
||||
hint: editProblemHint.value || "",
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 当问题数据变化时,更新表单数据
|
||||
watch(
|
||||
() => props.problem,
|
||||
(newProblem) => {
|
||||
if (newProblem) {
|
||||
editProblemOrder.value = newProblem.order
|
||||
editProblemRequired.value = newProblem.is_required
|
||||
editProblemScore.value = newProblem.score
|
||||
editProblemHint.value = newProblem.hint || ""
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="编辑题目"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form v-if="problem">
|
||||
<n-form-item label="题目标题">
|
||||
<n-input :value="problem.problem.title" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="editProblemOrder"
|
||||
placeholder="题目在题单中的顺序"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="editProblemRequired" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分数">
|
||||
<n-input-number
|
||||
v-model:value="editProblemScore"
|
||||
placeholder="题目分数"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="提示">
|
||||
<n-input
|
||||
v-model:value="editProblemHint"
|
||||
type="textarea"
|
||||
placeholder="题目提示"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
73
src/admin/problemset/components/ProblemManagement.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||
import { ProblemSetProblem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
problems: ProblemSetProblem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "add-problem"): void
|
||||
(e: "edit-problem", problem: ProblemSetProblem): void
|
||||
(e: "remove-problem", problemSetProblemId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<h3>题目列表</h3>
|
||||
<n-button type="primary" @click="$emit('add-problem')">
|
||||
添加题目
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
{ title: '题目ID', key: 'problem._id', width: 80 },
|
||||
{ title: '题目标题', key: 'problem.title', minWidth: 200 },
|
||||
{ title: '顺序', key: 'order', width: 80 },
|
||||
{
|
||||
title: '必做',
|
||||
key: 'is_required',
|
||||
width: 80,
|
||||
render: (row) => (row.is_required ? '是' : '否'),
|
||||
},
|
||||
{ title: '分数', key: 'score', width: 80 },
|
||||
{ title: '提示', key: 'hint', minWidth: 200 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) =>
|
||||
h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
secondary: true,
|
||||
onClick: () => $emit('edit-problem', row),
|
||||
},
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
secondary: true,
|
||||
onClick: () => $emit('remove-problem', row.id),
|
||||
},
|
||||
{ default: () => '移除' },
|
||||
),
|
||||
]),
|
||||
},
|
||||
]"
|
||||
:data="problems"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
70
src/admin/problemset/components/ProblemSetInfo.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { parseTime } from "utils/functions"
|
||||
import { ProblemSet } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
problemSet: ProblemSet
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card title="题单信息" style="margin-bottom: 16px">
|
||||
<n-descriptions :column="4" bordered>
|
||||
<n-descriptions-item label="描述">
|
||||
{{ problemSet.description }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建者">
|
||||
{{ problemSet.created_by.username }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="难度">
|
||||
<n-tag
|
||||
:type="
|
||||
problemSet.difficulty === 'Easy'
|
||||
? 'success'
|
||||
: problemSet.difficulty === 'Medium'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
>
|
||||
{{
|
||||
problemSet.difficulty === "Easy"
|
||||
? "简单"
|
||||
: problemSet.difficulty === "Medium"
|
||||
? "中等"
|
||||
: "困难"
|
||||
}}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag
|
||||
:type="
|
||||
problemSet.status === 'active'
|
||||
? 'success'
|
||||
: problemSet.status === 'archived'
|
||||
? 'default'
|
||||
: 'info'
|
||||
"
|
||||
>
|
||||
{{
|
||||
problemSet.status === "active"
|
||||
? "活跃"
|
||||
: problemSet.status === "archived"
|
||||
? "已归档"
|
||||
: "草稿"
|
||||
}}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="可见">
|
||||
{{ problemSet.visible ? "是" : "否" }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="题目数量">
|
||||
{{ problemSet.problems_count }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss") }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</template>
|
||||
69
src/admin/problemset/components/ProgressManagement.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { ProblemSetProgress } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
progress: ProblemSetProgress[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "remove-user", userId: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 定义表格列
|
||||
const progressColumns = [
|
||||
{ title: "用户", key: "user.username", width: 120 },
|
||||
{
|
||||
title: "加入时间",
|
||||
key: "join_time",
|
||||
width: 180,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{ title: "已完成", key: "completed_problems_count", width: 100 },
|
||||
{ title: "总题目", key: "total_problems_count", width: 100 },
|
||||
{
|
||||
title: "进度",
|
||||
key: "progress_percentage",
|
||||
width: 100,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
`${row.progress_percentage.toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
title: "是否完成",
|
||||
key: "is_completed",
|
||||
width: 100,
|
||||
render: (row: ProblemSetProgress) => (row.is_completed ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "error",
|
||||
secondary: true,
|
||||
onClick: () => emit("remove-user", row.user.id),
|
||||
},
|
||||
{ default: () => "移除" },
|
||||
),
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<h3>用户进度</h3>
|
||||
</n-flex>
|
||||
<n-data-table :columns="progressColumns" :data="progress" />
|
||||
</div>
|
||||
</template>
|
||||
270
src/admin/problemset/detail.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
|
||||
import {
|
||||
ProblemSet,
|
||||
ProblemSetProblem,
|
||||
ProblemSetBadge,
|
||||
ProblemSetProgress,
|
||||
} from "utils/types"
|
||||
import {
|
||||
getProblemSetDetail,
|
||||
getProblemSetProblems,
|
||||
getProblemSetBadges,
|
||||
getProblemSetProgress,
|
||||
addProblemToSet,
|
||||
editProblemInSet,
|
||||
removeProblemFromSet,
|
||||
createProblemSetBadge,
|
||||
editProblemSetBadge,
|
||||
deleteProblemSetBadge,
|
||||
removeUserFromProblemSet,
|
||||
} from "../api"
|
||||
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
|
||||
import ProblemManagement from "./components/ProblemManagement.vue"
|
||||
import BadgeManagement from "./components/BadgeManagement.vue"
|
||||
import ProgressManagement from "./components/ProgressManagement.vue"
|
||||
import AddProblemModal from "./components/AddProblemModal.vue"
|
||||
import EditProblemModal from "./components/EditProblemModal.vue"
|
||||
import AddBadgeModal from "./components/AddBadgeModal.vue"
|
||||
import EditBadgeModal from "./components/EditBadgeModal.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||
|
||||
const problemSet = ref<ProblemSet | null>(null)
|
||||
const problems = ref<ProblemSetProblem[]>([])
|
||||
const badges = ref<ProblemSetBadge[]>([])
|
||||
const progress = ref<ProblemSetProgress[]>([])
|
||||
|
||||
// 模态框状态
|
||||
const showAddProblemModal = ref(false)
|
||||
const showEditProblemModal = ref(false)
|
||||
const showAddBadgeModal = ref(false)
|
||||
const showEditBadgeModal = ref(false)
|
||||
|
||||
// 编辑数据
|
||||
const editingProblem = ref<ProblemSetProblem | null>(null)
|
||||
const editingBadge = ref<ProblemSetBadge | null>(null)
|
||||
|
||||
async function loadProblemSetDetail() {
|
||||
try {
|
||||
const res = await getProblemSetDetail(problemSetId.value)
|
||||
problemSet.value = res.data
|
||||
} catch (err: any) {
|
||||
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProblems() {
|
||||
try {
|
||||
const res = await getProblemSetProblems(problemSetId.value)
|
||||
problems.value = res.data
|
||||
} catch (err: any) {
|
||||
message.error("加载题目列表失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBadges() {
|
||||
try {
|
||||
const res = await getProblemSetBadges(problemSetId.value)
|
||||
badges.value = res.data
|
||||
} catch (err: any) {
|
||||
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
try {
|
||||
const res = await getProblemSetProgress(problemSetId.value)
|
||||
progress.value = res.data
|
||||
} catch (err: any) {
|
||||
message.error("加载进度列表失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddProblem(data: any) {
|
||||
try {
|
||||
await addProblemToSet(problemSetId.value, data)
|
||||
message.success("题目添加成功")
|
||||
showAddProblemModal.value = false
|
||||
loadProblems()
|
||||
loadProblemSetDetail() // 刷新题目数量
|
||||
} catch (err: any) {
|
||||
message.error("添加题目失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveProblem(problemSetProblemId: number) {
|
||||
try {
|
||||
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
|
||||
message.success("题目移除成功")
|
||||
loadProblems()
|
||||
loadProblemSetDetail() // 刷新题目数量
|
||||
} catch (err: any) {
|
||||
message.error("移除题目失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditProblem(data: any) {
|
||||
if (!editingProblem.value) return
|
||||
|
||||
try {
|
||||
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
|
||||
message.success("题目编辑成功")
|
||||
showEditProblemModal.value = false
|
||||
loadProblems()
|
||||
} catch (err: any) {
|
||||
message.error("编辑题目失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddBadge(data: any) {
|
||||
try {
|
||||
await createProblemSetBadge(problemSetId.value, data)
|
||||
message.success("奖章创建成功")
|
||||
showAddBadgeModal.value = false
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("创建奖章失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBadge(badgeId: number) {
|
||||
try {
|
||||
await deleteProblemSetBadge(problemSetId.value, badgeId)
|
||||
message.success("奖章删除成功")
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("删除奖章失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditBadge(data: any) {
|
||||
if (!editingBadge.value) return
|
||||
|
||||
try {
|
||||
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
|
||||
message.success("奖章编辑成功")
|
||||
showEditBadgeModal.value = false
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("编辑奖章失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveUser(userId: number) {
|
||||
try {
|
||||
await removeUserFromProblemSet(problemSetId.value, userId)
|
||||
message.success("用户移除成功")
|
||||
loadProgress()
|
||||
} catch (err: any) {
|
||||
message.error("移除用户失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
function openAddProblemModal() {
|
||||
showAddProblemModal.value = true
|
||||
}
|
||||
|
||||
function openAddBadgeModal() {
|
||||
showAddBadgeModal.value = true
|
||||
}
|
||||
|
||||
function openEditProblemModal(problem: ProblemSetProblem) {
|
||||
editingProblem.value = problem
|
||||
showEditProblemModal.value = true
|
||||
}
|
||||
|
||||
function openEditBadgeModal(badge: ProblemSetBadge) {
|
||||
editingBadge.value = badge
|
||||
showEditBadgeModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProblemSetDetail()
|
||||
loadProblems()
|
||||
loadBadges()
|
||||
loadProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="problemSet">
|
||||
<n-flex class="titleWrapper" justify="space-between" align="center">
|
||||
<h2 class="title">{{ problemSet.title }}</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'admin problemset edit',
|
||||
params: { problemSetId },
|
||||
})
|
||||
"
|
||||
>
|
||||
编辑题单
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<ProblemSetInfo :problem-set="problemSet" />
|
||||
|
||||
<n-tabs type="line">
|
||||
<n-tab-pane name="problems" tab="题目管理">
|
||||
<ProblemManagement
|
||||
:problems="problems"
|
||||
@add-problem="openAddProblemModal"
|
||||
@edit-problem="openEditProblemModal"
|
||||
@remove-problem="handleRemoveProblem"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="badges" tab="奖章管理">
|
||||
<BadgeManagement
|
||||
:badges="badges"
|
||||
@add-badge="openAddBadgeModal"
|
||||
@edit-badge="openEditBadgeModal"
|
||||
@delete-badge="handleDeleteBadge"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="progress" tab="进度管理">
|
||||
<ProgressManagement
|
||||
:progress="progress"
|
||||
@remove-user="handleRemoveUser"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
<AddProblemModal
|
||||
v-model:show="showAddProblemModal"
|
||||
@confirm="handleAddProblem"
|
||||
/>
|
||||
|
||||
<EditProblemModal
|
||||
v-model:show="showEditProblemModal"
|
||||
:problem="editingProblem"
|
||||
@confirm="handleEditProblem"
|
||||
/>
|
||||
|
||||
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
|
||||
|
||||
<EditBadgeModal
|
||||
v-model:show="showEditBadgeModal"
|
||||
:badge="editingBadge"
|
||||
@confirm="handleEditBadge"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
149
src/admin/problemset/edit.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { CreateProblemSetData, EditProblemSetData } from "utils/types"
|
||||
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||
const isEdit = computed(() => !!problemSetId.value)
|
||||
|
||||
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
|
||||
title: "",
|
||||
description: "",
|
||||
difficulty: "Easy",
|
||||
status: "draft",
|
||||
visible: false,
|
||||
})
|
||||
|
||||
const difficultyOptions = [
|
||||
{ label: "简单", value: "Easy" },
|
||||
{ label: "中等", value: "Medium" },
|
||||
{ label: "困难", value: "Hard" },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadProblemSetDetail() {
|
||||
if (!isEdit.value) return
|
||||
|
||||
try {
|
||||
const res = await getProblemSetDetail(problemSetId.value)
|
||||
const data = res.data
|
||||
formData.value = {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
difficulty: data.difficulty,
|
||||
status: data.status,
|
||||
visible: data.visible,
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.title?.trim()) {
|
||||
message.error("请输入题单标题")
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.description?.trim()) {
|
||||
message.error("请输入题单描述")
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await editProblemSet(formData.value as EditProblemSetData)
|
||||
message.success("题单更新成功")
|
||||
} else {
|
||||
await createProblemSet(formData.value as CreateProblemSetData)
|
||||
message.success("题单创建成功")
|
||||
}
|
||||
router.push({ name: "admin problemset list" })
|
||||
} catch (err: any) {
|
||||
message.error(
|
||||
(isEdit.value ? "更新" : "创建") +
|
||||
"题单失败:" +
|
||||
(err.data || "未知错误"),
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit.value) {
|
||||
loadProblemSetDetail()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="title">{{ isEdit ? "编辑题单" : "创建题单" }}</h2>
|
||||
|
||||
<n-form :model="formData" label-placement="top">
|
||||
<n-flex>
|
||||
<n-form-item label="题单标题" required>
|
||||
<n-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入题单标题"
|
||||
maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="难度">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
v-model:value="formData.difficulty"
|
||||
:options="difficultyOptions"
|
||||
placeholder="选择难度"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
v-model:value="formData.status"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="isEdit" label="是否可见">
|
||||
<n-switch v-model:value="formData.visible" />
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
|
||||
<n-form-item label="题单描述" required>
|
||||
<n-input
|
||||
v-model:value="formData.description"
|
||||
type="textarea"
|
||||
placeholder="请输入题单描述"
|
||||
:rows="4"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
{{ isEdit ? "更新" : "创建" }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
</style>
|
||||
209
src/admin/problemset/list.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { ProblemSetList } from "utils/types"
|
||||
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import { NTag, NSwitch } from "naive-ui"
|
||||
|
||||
const total = ref(0)
|
||||
const problemSets = ref<ProblemSetList[]>([])
|
||||
|
||||
interface ProblemSetQuery {
|
||||
keyword: string
|
||||
difficulty: string
|
||||
status: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemSetQuery>({
|
||||
keyword: "",
|
||||
difficulty: "",
|
||||
status: "",
|
||||
})
|
||||
|
||||
const difficultyOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "简单", value: "Easy" },
|
||||
{ label: "中等", value: "Medium" },
|
||||
{ label: "困难", value: "Hard" },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<ProblemSetList>[] = [
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
|
||||
{
|
||||
title: "创建者",
|
||||
key: "created_by",
|
||||
width: 120,
|
||||
render: (row) => row.created_by.username,
|
||||
},
|
||||
{
|
||||
title: "难度",
|
||||
key: "difficulty",
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const difficultyMap = {
|
||||
Easy: { type: "success" as const, text: "简单" },
|
||||
Medium: { type: "warning" as const, text: "中等" },
|
||||
Hard: { type: "error" as const, text: "困难" },
|
||||
}
|
||||
const config = difficultyMap[row.difficulty]
|
||||
return h(NTag, { type: config.type }, { default: () => config.text })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const statusMap = {
|
||||
active: { type: "success" as const, text: "活跃" },
|
||||
archived: { type: "default" as const, text: "已归档" },
|
||||
draft: { type: "info" as const, text: "草稿" },
|
||||
}
|
||||
const config = statusMap[row.status]
|
||||
return h(NTag, { type: config.type }, { default: () => config.text })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "题目数量",
|
||||
key: "problems_count",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 300,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemSetId: row.id,
|
||||
onUpdated: listProblemSets,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
async function listProblemSets() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemSetList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.difficulty,
|
||||
query.status,
|
||||
)
|
||||
total.value = res.data.total
|
||||
problemSets.value = res.data.results
|
||||
}
|
||||
|
||||
async function toggleVisible(problemSetId: number) {
|
||||
await toggleProblemSetVisible(problemSetId)
|
||||
problemSets.value = problemSets.value.map((it) => {
|
||||
if (it.id === problemSetId) {
|
||||
it.visible = !it.visible
|
||||
}
|
||||
return it
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(listProblemSets)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listProblemSets, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit, query.difficulty, query.status],
|
||||
listProblemSets,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" justify="space-between">
|
||||
<n-flex align="center">
|
||||
<h2 class="title">题单管理</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin problemset create' })"
|
||||
>
|
||||
新建题单
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<n-flex align="center">
|
||||
<span>难度:</span>
|
||||
<n-select
|
||||
v-model:value="query.difficulty"
|
||||
:options="difficultyOptions"
|
||||
placeholder="选择难度"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span>状态:</span>
|
||||
<n-select
|
||||
v-model:value="query.status"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="problemSets" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Server } from "~/utils/types"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Server } from "utils/types"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useConfigWebSocket } from "shared/composables/websocket"
|
||||
import {
|
||||
deleteJudgeServer,
|
||||
editWebsite,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
listInvalidTestcases,
|
||||
pruneInvalidTestcases,
|
||||
} from "../api"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
interface Testcase {
|
||||
id: string
|
||||
@@ -17,6 +20,21 @@ interface Testcase {
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
const { updateConfig } = useConfigWebSocket()
|
||||
|
||||
// 确保只有登录用户才能使用WebSocket
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
(isAuthed) => {
|
||||
if (!isAuthed) {
|
||||
// 如果用户未登录,禁用WebSocket功能
|
||||
console.warn("用户未登录,WebSocket配置更新功能已禁用")
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const testcaseColumns: DataTableColumn<Testcase>[] = [
|
||||
{ title: "测试用例 ID", key: "id" },
|
||||
@@ -106,6 +124,7 @@ const websiteConfig = reactive({
|
||||
allow_register: true,
|
||||
submission_list_show_all: true,
|
||||
class_list: [],
|
||||
enable_maxkb: true,
|
||||
})
|
||||
|
||||
async function getWebsiteConfig() {
|
||||
@@ -117,12 +136,21 @@ async function getWebsiteConfig() {
|
||||
websiteConfig.allow_register = res.data.allow_register
|
||||
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
|
||||
websiteConfig.class_list = res.data.class_list
|
||||
websiteConfig.enable_maxkb = res.data.enable_maxkb
|
||||
}
|
||||
|
||||
async function saveWebsiteConfig() {
|
||||
await editWebsite(websiteConfig)
|
||||
message.success("网站配置保存成功")
|
||||
getWebsiteConfig()
|
||||
configStore.getConfig()
|
||||
|
||||
// 通过 WebSocket 广播配置变化,实现实时切换
|
||||
updateConfig("enable_maxkb", websiteConfig.enable_maxkb)
|
||||
updateConfig(
|
||||
"submission_list_show_all",
|
||||
websiteConfig.submission_list_show_all,
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteTestcase(id?: string) {
|
||||
@@ -198,6 +226,10 @@ onMounted(() => {
|
||||
<span>显示所有提交</span>
|
||||
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span>启用AI小助手</span>
|
||||
<n-switch v-model:value="websiteConfig.enable_maxkb" />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card class="box">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from "naive-ui"
|
||||
import { getRank } from "oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { getACRate } from "~/utils/functions"
|
||||
import { Rank } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { getACRate } from "utils/functions"
|
||||
import { Rank } from "utils/types"
|
||||
import { getBaseInfo, randomUser10 } from "../api"
|
||||
|
||||
const userCount = ref(0)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteTutorial } from "~/admin/api"
|
||||
import { deleteTutorial } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
tutorialID: number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "~/shared/components/MarkdownEditor.vue"
|
||||
import { Tutorial } from "~/utils/types"
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||
import { Tutorial } from "utils/types"
|
||||
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Tutorial } from "~/utils/types"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Tutorial } from "utils/types"
|
||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { editUser } from "~/admin/api"
|
||||
import { User } from "~/utils/types"
|
||||
import { editUser } from "admin/api"
|
||||
import { User } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants"
|
||||
import { getUserRole } from "~/utils/functions"
|
||||
import { User } from "~/utils/types"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { getUserRole } from "utils/functions"
|
||||
import { User } from "utils/types"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
@@ -30,6 +31,6 @@ const isNotRegularUser = computed(
|
||||
: "仅自己"
|
||||
}}
|
||||
</n-tag>
|
||||
{{ props.user.username }}
|
||||
<TextCopy>{{ props.user.username }}</TextCopy>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { DataTableRowKey, SelectOption } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { User } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { User } from "utils/types"
|
||||
import {
|
||||
deleteUsers,
|
||||
editUser,
|
||||
@@ -13,19 +13,23 @@ import {
|
||||
} from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Name from "./components/Name.vue"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
interface UserQuery {
|
||||
keyword: string
|
||||
type: string
|
||||
orderBy: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<UserQuery>({
|
||||
keyword: "",
|
||||
type: "",
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
type: useRouteQuery("type", "").value,
|
||||
orderBy: useRouteQuery("orderBy", "").value,
|
||||
})
|
||||
|
||||
const total = ref(0)
|
||||
@@ -37,6 +41,11 @@ const adminOptions = [
|
||||
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "默认排序", value: "" },
|
||||
{ label: "最近登录", value: "-last_login" },
|
||||
]
|
||||
const [create, toggleCreate] = useToggle(false)
|
||||
const password = ref("")
|
||||
const userIDs = ref<DataTableRowKey[]>([])
|
||||
@@ -56,6 +65,7 @@ const columns: DataTableColumn<User>[] = [
|
||||
title: "密码",
|
||||
key: "raw_password",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.raw_password),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
@@ -72,12 +82,17 @@ const columns: DataTableColumn<User>[] = [
|
||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||
: "从未登录",
|
||||
},
|
||||
{ title: "真名", key: "real_name", width: 100 },
|
||||
{
|
||||
title: "真名",
|
||||
key: "real_name",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.real_name),
|
||||
},
|
||||
{ title: "邮箱", key: "email", width: 200 },
|
||||
{
|
||||
key: "actions",
|
||||
title: "选项",
|
||||
width: 260,
|
||||
width: 280,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
user: row,
|
||||
@@ -101,11 +116,16 @@ const problemPermissionOptions: SelectOption[] = [
|
||||
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
||||
]
|
||||
|
||||
|
||||
async function listUsers() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getUserList(offset, query.limit, query.type, query.keyword)
|
||||
const res = await getUserList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.type,
|
||||
query.keyword,
|
||||
query.orderBy,
|
||||
)
|
||||
total.value = res.data.total
|
||||
users.value = res.data.results
|
||||
}
|
||||
@@ -198,17 +218,10 @@ async function handleEditUser() {
|
||||
onMounted(listUsers)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
listUsers,
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit, query.type],
|
||||
listUsers,
|
||||
)
|
||||
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -231,6 +244,12 @@ watch(
|
||||
确定删除选中的用户吗?删除后无法恢复!
|
||||
</n-popconfirm>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="query.orderBy"
|
||||
:options="sortOptions"
|
||||
placeholder="排序方式"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.type"
|
||||
:options="adminOptions"
|
||||
@@ -238,7 +257,12 @@ watch(
|
||||
style="width: 120px"
|
||||
/>
|
||||
<div>
|
||||
<n-input style="width: 200px" v-model:value="query.keyword" />
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
v-model:value="query.keyword"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
2
src/env.d.ts
vendored
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
|
||||
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 {
|
||||
|
||||
55
src/main.ts
@@ -2,41 +2,35 @@ import { addAPIProvider } from "@iconify/vue"
|
||||
import { createPinia } from "pinia"
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
|
||||
import {
|
||||
ArcElement,
|
||||
BarElement,
|
||||
BarController,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Colors,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
LineElement,
|
||||
PointElement,
|
||||
} from "chart.js"
|
||||
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
|
||||
import App from "./App.vue"
|
||||
import { admins, ojs } from "./routes"
|
||||
|
||||
import { toggleLogin } from "./shared/composables/modal"
|
||||
import { useUserStore } from "./shared/store/user"
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [ojs, admins],
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
// 创建 app 并安装插件
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// 现在可以安全地使用 Store
|
||||
import { useAuthModalStore } from "./shared/store/authModal"
|
||||
import { useUserStore } from "./shared/store/user"
|
||||
|
||||
const authStore = useAuthModalStore()
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 检查是否需要认证
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
toggleLogin(true)
|
||||
authStore.openLoginModal()
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
@@ -50,7 +44,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
)
|
||||
) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
toggleLogin(true)
|
||||
authStore.openLoginModal()
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
@@ -82,25 +76,6 @@ router.beforeEach(async (to, from, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
BarController,
|
||||
ArcElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
PointElement,
|
||||
Colors,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
)
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.mount("#app")
|
||||
|
||||
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||
|
||||
@@ -1,50 +1,76 @@
|
||||
<template>
|
||||
<n-grid :cols="isDesktop ? 5: 1" :x-gap="20">
|
||||
<n-gi :span="2">
|
||||
<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-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>
|
||||
<Details :start="start" :end="end" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="3">
|
||||
<n-flex vertical size="large">
|
||||
<Heatmap />
|
||||
<WeeklyChart :end="end" />
|
||||
<AI v-if="aiStore.detailsData.solved.length" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</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 { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import WeeklyChart from "./components/WeeklyChart.vue"
|
||||
import Details from "./components/Details.vue"
|
||||
import 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 start = ref("")
|
||||
const end = ref("")
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "一节课内", value: "hours:1" },
|
||||
{ label: "两节课内", value: "hours:2" },
|
||||
{ label: "一天内", value: "days:1" },
|
||||
{ label: "一周内", value: "weeks:1" },
|
||||
{ label: "一个月内", value: "months:1" },
|
||||
{ label: "两个月内", value: "months:2" },
|
||||
{ label: "半年内", value: "months:6" },
|
||||
{ label: "一年内", value: "years:1" },
|
||||
]
|
||||
const options = [...DURATION_OPTIONS]
|
||||
|
||||
const subOptions = computed<Duration>(() => {
|
||||
let dur = options.find((it) => it.value === aiStore.duration) ?? options[0]
|
||||
@@ -54,11 +80,25 @@ const subOptions = computed<Duration>(() => {
|
||||
return { [unit]: parseInt(n) } as Duration
|
||||
})
|
||||
|
||||
function updateRange() {
|
||||
const start = computed(() => {
|
||||
const current = new Date()
|
||||
end.value = formatISO(current)
|
||||
start.value = formatISO(sub(current, subOptions.value))
|
||||
}
|
||||
return formatISO(sub(current, subOptions.value))
|
||||
})
|
||||
|
||||
watch(() => aiStore.duration, updateRange, { immediate: true })
|
||||
const end = computed(() => {
|
||||
return formatISO(new Date())
|
||||
})
|
||||
|
||||
// 获取热力图数据(仅一次)
|
||||
onMounted(() => {
|
||||
aiStore.fetchHeatmapData()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.ai">
|
||||
<div class="container">
|
||||
<MdPreview :model-value="aiStore.mdContent" />
|
||||
</div>
|
||||
</n-spin>
|
||||
<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 { useAIStore } from "oj/store/ai"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
watch(
|
||||
() => [aiStore.loading.details, aiStore.loading.weekly],
|
||||
(newVal) => {
|
||||
if (newVal.every((val) => val === false)) {
|
||||
() => aiStore.loading.fetching,
|
||||
(isLoading) => {
|
||||
if (!isLoading) {
|
||||
aiStore.fetchAIAnalysis()
|
||||
}
|
||||
},
|
||||
@@ -22,7 +29,41 @@ watch(
|
||||
)
|
||||
</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>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.details">
|
||||
<n-flex vertical size="large">
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
>
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>你一共解决 </span>
|
||||
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
|
||||
<span> 道题,</span>
|
||||
<span v-if="aiStore.detailsData.contest_count > 0">
|
||||
并且参加
|
||||
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
|
||||
次比赛,
|
||||
</span>
|
||||
<span>综合评价给到</span>
|
||||
<Grade :grade="aiStore.detailsData.grade" />
|
||||
<span>{{ greeting }}</span>
|
||||
</n-alert>
|
||||
<n-flex vertical size="large" v-else>
|
||||
<n-alert type="error" title="你还没有完成任何题目"></n-alert>
|
||||
<AI />
|
||||
</n-flex>
|
||||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="10" :y-gap="10">
|
||||
<n-gi>
|
||||
<TagsChart :tags="aiStore.detailsData.tags" />
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<DifficultyChart :difficulty="aiStore.detailsData.difficulty" />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-data-table
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
striped
|
||||
:data="aiStore.detailsData.solved"
|
||||
:columns="columns"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import Grade from "./Grade.vue"
|
||||
import TagsChart from "./TagsChart.vue"
|
||||
import DifficultyChart from "./DifficultyChart.vue"
|
||||
import TagTitle from "./TagTitle.vue"
|
||||
import AI from "./AI.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { SolvedProblem } from "~/utils/types"
|
||||
import { useAIStore } from "~/oj/store/ai"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
|
||||
const props = defineProps<{
|
||||
start: string
|
||||
end: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
title: "完成的题目",
|
||||
key: "problem.title",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.problem.contest_id) {
|
||||
router.push(
|
||||
"/contest/" +
|
||||
row.problem.contest_id +
|
||||
"/problem/" +
|
||||
row.problem.display_id,
|
||||
)
|
||||
} else {
|
||||
router.push("/problem/" + row.problem.display_id)
|
||||
}
|
||||
},
|
||||
},
|
||||
() => {
|
||||
if (row.problem.contest_id) {
|
||||
return h(TagTitle, { problem: row.problem })
|
||||
} else {
|
||||
return row.problem.display_id + " " + row.problem.title
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
|
||||
key: "rank",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (row) => row.rank + " / " + row.ac_count,
|
||||
},
|
||||
{
|
||||
title: "等级",
|
||||
key: "grade",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
]
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (aiStore.duration.includes("hours")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
|
||||
} else if (aiStore.duration.includes("days")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
|
||||
} else if (
|
||||
aiStore.duration.includes("weeks") ||
|
||||
aiStore.duration.includes("months")
|
||||
) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
|
||||
} else {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
|
||||
}
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
return {
|
||||
S: "要不试试高难度题目?",
|
||||
A: "你很棒,继续保持!",
|
||||
B: "请再接再厉!",
|
||||
C: "你还需要努力!",
|
||||
}[aiStore.detailsData.grade]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchDetailsData(props.start, props.end)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.charming {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div class="chart" v-if="show">
|
||||
<Bar :data="data" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
|
||||
const props = defineProps<{
|
||||
difficulty: { [key: string]: number }
|
||||
}>()
|
||||
|
||||
const show = computed(() => {
|
||||
return Object.values(props.difficulty).reduce((a, b) => a + b, 0) > 0
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
return {
|
||||
labels: Object.keys(props.difficulty),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(props.difficulty),
|
||||
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
text: "题目的难度统计",
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</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>
|
||||
@@ -1,8 +1,31 @@
|
||||
<template>
|
||||
<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'" />
|
||||
<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<{
|
||||
@@ -14,7 +37,6 @@ img {
|
||||
animation: shake 0.5s infinite;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0 10px -10px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
|
||||
@@ -1,43 +1,251 @@
|
||||
<template>
|
||||
<div class="chart">
|
||||
<n-h1 class="title">过去一年的提交次数热力图</n-h1>
|
||||
<n-heatmap
|
||||
:loading="!data.length"
|
||||
:color-theme="getRandomColorTheme()"
|
||||
size="large"
|
||||
:data="data"
|
||||
:tooltip="{ placement: 'top' }"
|
||||
>
|
||||
<template #tooltip="{ timestamp, value }">
|
||||
<div>{{ new Date(timestamp).toLocaleDateString() }}</div>
|
||||
<div>提交次数: {{ value }}</div>
|
||||
</template>
|
||||
</n-heatmap>
|
||||
</div>
|
||||
<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 { type HeatmapData } from "naive-ui"
|
||||
import { getAIHeatmapData } from "~/oj/api"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const data = ref<HeatmapData>([])
|
||||
const aiStore = useAIStore()
|
||||
const containerRef = ref<HTMLElement>()
|
||||
|
||||
function getRandomColorTheme() {
|
||||
const themes = ["green", "blue", "orange", "purple", "red"] as const
|
||||
return themes[Math.floor(Math.random() * themes.length)]
|
||||
}
|
||||
const CELL_SIZE = 12
|
||||
const CELL_GAP = 3
|
||||
const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
||||
const DAY_WIDTH = 20
|
||||
const MONTH_HEIGHT = 20
|
||||
const RIGHT_PADDING = 5
|
||||
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getAIHeatmapData()
|
||||
data.value = res.data
|
||||
const getColor = (count: number) =>
|
||||
count === 0
|
||||
? COLORS[0]
|
||||
: count <= 2
|
||||
? COLORS[1]
|
||||
: count <= 4
|
||||
? COLORS[2]
|
||||
: count <= 7
|
||||
? COLORS[3]
|
||||
: COLORS[4]
|
||||
|
||||
const cells = computed(() =>
|
||||
aiStore.heatmapData.map((item, i) => ({
|
||||
date: new Date(item.timestamp),
|
||||
count: item.value,
|
||||
color: getColor(item.value),
|
||||
week: Math.floor(i / 7),
|
||||
day: i % 7,
|
||||
x: Math.floor(i / 7) * CELL_TOTAL,
|
||||
y: (i % 7) * CELL_TOTAL,
|
||||
})),
|
||||
)
|
||||
|
||||
const monthLabels = computed(() => {
|
||||
const labels: { text: string; x: number }[] = []
|
||||
let lastMonth = -1
|
||||
|
||||
cells.value.forEach((cell, i) => {
|
||||
const month = cell.date.getMonth()
|
||||
const isWeekStart = cell.date.getDay() === 0 || i === 0
|
||||
|
||||
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
|
||||
labels.push({
|
||||
text: `${month + 1}月`,
|
||||
x: DAY_WIDTH + cell.week * CELL_TOTAL,
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
})
|
||||
|
||||
return labels
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
margin: 0 auto;
|
||||
|
||||
const svgWidth = computed(
|
||||
() =>
|
||||
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
|
||||
)
|
||||
|
||||
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
|
||||
|
||||
interface Cell {
|
||||
date: Date
|
||||
count: number
|
||||
color: string
|
||||
week: number
|
||||
day: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
.title {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
|
||||
const tooltip = ref<{
|
||||
x: number
|
||||
y: number
|
||||
date: string
|
||||
text: string
|
||||
count: number
|
||||
} | null>(null)
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltip.value?.x}px`,
|
||||
top: `${tooltip.value?.y}px`,
|
||||
}))
|
||||
|
||||
const getTooltipText = (count: number) =>
|
||||
count === 0 ? "没有提交记录" : `提交了 ${count} 次`
|
||||
|
||||
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
const containerRect = containerRef.value?.getBoundingClientRect()
|
||||
|
||||
if (containerRect) {
|
||||
tooltip.value = {
|
||||
x: rect.left - containerRect.left + rect.width / 2,
|
||||
y: rect.top - containerRect.top - 10,
|
||||
date: parseTime(cell.date, "YYYY年M月D日"),
|
||||
text: getTooltipText(cell.count),
|
||||
count: cell.count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
tooltip.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-container {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.label {
|
||||
fill: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cell {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
stroke: rgba(0, 0, 0, 0.05);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
stroke: rgba(0, 0, 0, 0.3);
|
||||
stroke-width: 1.5;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<div class="chart" v-if="show">
|
||||
<Pie :data="data" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Pie } from "vue-chartjs"
|
||||
const props = defineProps<{
|
||||
tags: { [key: string]: number }
|
||||
}>()
|
||||
|
||||
const show = computed(() => {
|
||||
return Object.keys(props.tags).length > 0
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
return {
|
||||
labels: Object.keys(props.tags),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(props.tags),
|
||||
backgroundColor: [
|
||||
"#FF6384",
|
||||
"#36A2EB",
|
||||
"#FFCE56",
|
||||
"#4BC0C0",
|
||||
"#9966FF",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
text: `题目的标签分布(前${Object.keys(props.tags).length}个)`,
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
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,136 +0,0 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.weekly">
|
||||
<div class="chart">
|
||||
<Chart type="bar" :data="data" :options="options" />
|
||||
</div>
|
||||
</n-spin>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
import { Chart } from "vue-chartjs"
|
||||
import { useAIStore } from "~/oj/store/ai"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
|
||||
const props = defineProps<{
|
||||
end: string
|
||||
}>()
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
|
||||
const title = computed(() => {
|
||||
if (aiStore.duration === "months:2") {
|
||||
return "过去两个月的每周综合情况一览图"
|
||||
} 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.weeklyData.map((weekly) => {
|
||||
let prefix = "周"
|
||||
if (weekly.unit === "months") {
|
||||
prefix = "月"
|
||||
}
|
||||
return [
|
||||
parseTime(weekly.start, "M月D日"),
|
||||
parseTime(weekly.end, "M月D日"),
|
||||
].join("~")
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
type: "bar",
|
||||
label: "完成题目数量",
|
||||
data: aiStore.weeklyData.map((weekly) => weekly.problem_count),
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
type: "bar",
|
||||
label: "总提交次数",
|
||||
data: aiStore.weeklyData.map((weekly) => weekly.submission_count),
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "等级",
|
||||
data: aiStore.weeklyData.map((weekly) =>
|
||||
gradeOrder.indexOf(weekly.grade || "C"),
|
||||
),
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
barThickness: 10,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed<ChartOptions<"bar" | "line">>(() => {
|
||||
return {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: "linear",
|
||||
position: "right",
|
||||
min: -1,
|
||||
max: gradeOrder.length,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (v) => {
|
||||
const idx = Number(v)
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
text: title.value,
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: TooltipItem<"bar">) => {
|
||||
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}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchWeeklyData(props.end, aiStore.duration)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import { NTag } from "naive-ui"
|
||||
import { getAnnouncement, getAnnouncementList } from "~/oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import { Announcement } from "~/utils/types"
|
||||
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { Announcement } from "utils/types"
|
||||
import TitleWithTag from "./components/TitleWithTag.vue"
|
||||
|
||||
const total = ref(0)
|
||||
const content = ref("")
|
||||
const title = ref("")
|
||||
const [show, toggleShow] = useToggle(false)
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
|
||||
127
src/oj/api.ts
@@ -19,6 +19,7 @@ function filterResult(result: Problem) {
|
||||
rate: getACRate(result.accepted_number, result.submission_number),
|
||||
status: "",
|
||||
author: result.created_by.username,
|
||||
allow_flowchart: result.allow_flowchart,
|
||||
}
|
||||
if (result.my_status === null || result.my_status === undefined) {
|
||||
newResult.status = "not_test"
|
||||
@@ -56,6 +57,14 @@ export async function getProblemList(
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthors(all = false) {
|
||||
return http.get("problem/author", {
|
||||
params: {
|
||||
all: all ? "1" : "0",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getRandomProblemID() {
|
||||
return http.get("pickone")
|
||||
}
|
||||
@@ -84,7 +93,7 @@ export function submitCode(data: SubmitCodePayload) {
|
||||
return http.post("submission", data)
|
||||
}
|
||||
|
||||
export function getSubmissions(params: SubmissionListPayload) {
|
||||
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
||||
return http.get(endpoint, { params })
|
||||
}
|
||||
@@ -249,13 +258,117 @@ export function getAIDetailData(start: string, end: string) {
|
||||
return http.get("ai/detail", { params: { start, end } })
|
||||
}
|
||||
|
||||
export function getAIWeeklyData(
|
||||
end: string,
|
||||
duration: string,
|
||||
) {
|
||||
return http.get("ai/weekly", { params: { end, duration } })
|
||||
export function getAIDurationData(end: string, duration: string) {
|
||||
return http.get("ai/duration", { params: { end, duration } })
|
||||
}
|
||||
|
||||
export function getAIHeatmapData() {
|
||||
return http.get("ai/heatmap")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 流程图相关API ====================
|
||||
|
||||
export function submitFlowchart(data: {
|
||||
problem_id: number
|
||||
mermaid_code: string
|
||||
flowchart_data: any // 这个是压缩之后的,元数据太长了
|
||||
}) {
|
||||
return http.post("flowchart/submission", data)
|
||||
}
|
||||
|
||||
export function getFlowchartSubmission(id: string) {
|
||||
return http.get("flowchart/submission", {
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export function getFlowchartSubmissions(params: {
|
||||
username?: string
|
||||
problem_id?: string
|
||||
myself?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
}) {
|
||||
return http.get("flowchart/submissions", { params })
|
||||
}
|
||||
|
||||
export function retryFlowchartSubmission(submissionId: string) {
|
||||
return http.post("flowchart/submission/retry", {
|
||||
submission_id: submissionId,
|
||||
})
|
||||
}
|
||||
|
||||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||||
return http.get("flowchart/submission/current", {
|
||||
params: { problem_id: problemId },
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 题单相关API ====================
|
||||
|
||||
export function getProblemSetList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword = "",
|
||||
difficulty = "",
|
||||
status = "",
|
||||
) {
|
||||
return http.get("problemset", {
|
||||
params: {
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
difficulty,
|
||||
status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getProblemSetDetail(id: number) {
|
||||
return http.get(`problemset/${id}`)
|
||||
}
|
||||
|
||||
export function getProblemSetProblems(problemSetId: number) {
|
||||
return http.get(`problemset/${problemSetId}/problems`)
|
||||
}
|
||||
|
||||
export function joinProblemSet(problemSetId: number) {
|
||||
return http.post("problemset/progress", {
|
||||
problemset_id: problemSetId,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProblemSetProgress(
|
||||
problemSetId: number,
|
||||
problemId: number,
|
||||
submissionId: string,
|
||||
) {
|
||||
return http.put("problemset/progress", {
|
||||
problemset_id: problemSetId,
|
||||
problem_id: problemId,
|
||||
submission_id: submissionId,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户徽章列表
|
||||
export function getUserBadges(username?: string) {
|
||||
return http.get("user/badges", { params: username ? { username } : {} })
|
||||
}
|
||||
|
||||
// 获取题单徽章列表
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
return http.get(`problemset/${problemSetId}/badges`)
|
||||
}
|
||||
|
||||
// 获取题单用户进度列表
|
||||
export function getProblemSetUserProgress(
|
||||
problemSetId: number,
|
||||
params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
class_name?: string
|
||||
completion_status?: "" | "completed" | "in_progress" | "not_started"
|
||||
},
|
||||
) {
|
||||
return http.get(`problemset/${problemSetId}/users_progress`, { params })
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { STORAGE_KEY } from "~/utils/constants"
|
||||
import storage from "~/utils/storage"
|
||||
import { Code } from "~/utils/types"
|
||||
|
||||
export const code = reactive<Code>({
|
||||
value: "",
|
||||
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3",
|
||||
})
|
||||
|
||||
export const input = ref("")
|
||||
export const output = ref("")
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Problem } from "~/utils/types"
|
||||
|
||||
export const problem = ref<Problem | null>(null)
|
||||
73
src/oj/composables/syncStatus.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref, provide, inject } from "vue"
|
||||
|
||||
/**
|
||||
* 同步状态管理 composable
|
||||
* 使用 provide/inject 模式在组件树中共享状态
|
||||
*/
|
||||
|
||||
export interface SyncStatusState {
|
||||
hadConnection: boolean
|
||||
otherUser?: { name: string; isSuperAdmin: boolean }
|
||||
lastLeftUser?: { name: string; isSuperAdmin: boolean } // 保存离开之人的信息
|
||||
}
|
||||
|
||||
// 提供/注入的 key
|
||||
export const SYNC_STATUS_KEY = Symbol("syncStatus")
|
||||
|
||||
/**
|
||||
* 创建同步状态实例
|
||||
* 每次调用创建新的状态实例
|
||||
*/
|
||||
export function createSyncStatus() {
|
||||
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
|
||||
const hadConnection = ref(false)
|
||||
const lastLeftUser = ref<{ name: string; isSuperAdmin: boolean }>()
|
||||
|
||||
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
|
||||
// 如果之前有其他用户,现在没有了,说明用户离开了
|
||||
if (otherUser.value && !user) {
|
||||
lastLeftUser.value = otherUser.value
|
||||
}
|
||||
otherUser.value = user
|
||||
if (user) {
|
||||
hadConnection.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
otherUser.value = undefined
|
||||
hadConnection.value = false
|
||||
lastLeftUser.value = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
otherUser,
|
||||
hadConnection,
|
||||
lastLeftUser,
|
||||
setOtherUser,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供同步状态到子组件
|
||||
* 在父组件中调用
|
||||
*/
|
||||
export function provideSyncStatus() {
|
||||
const syncStatus = createSyncStatus()
|
||||
provide(SYNC_STATUS_KEY, syncStatus)
|
||||
return syncStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入同步状态
|
||||
* 在子组件中调用,获取父组件提供的状态
|
||||
*/
|
||||
export function injectSyncStatus() {
|
||||
const syncStatus =
|
||||
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)
|
||||
if (!syncStatus) {
|
||||
throw new Error("syncStatus must be provided by a parent component")
|
||||
}
|
||||
return syncStatus
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ContestRank } from "~/utils/types"
|
||||
import { ContestRank } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
rank: ContestRank
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { parseTime } from "utils/functions"
|
||||
import ContestType from "~/shared/components/ContestType.vue"
|
||||
import ContestType from "shared/components/ContestType.vue"
|
||||
|
||||
const contestStore = useContestStore()
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { ContestStatus } from "~/utils/constants"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const contestStore = useContestStore()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const contestMenuVisible = computed(() => {
|
||||
if (contestStore.isContestAdmin) return true
|
||||
if (!contestStore.isPrivate) {
|
||||
|
||||
231
src/oj/contest/components/LineChart.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="chart" v-if="showChart">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Line } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import { ContestRank } from "utils/types"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
)
|
||||
|
||||
interface Props {
|
||||
ranks: ContestRank[]
|
||||
problems: Array<{ id: number; title: string }>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showChart = computed(() => {
|
||||
const hasRanks = props.ranks.length > 0
|
||||
const hasProblems = props.problems.length >= 3
|
||||
return hasProblems && hasRanks
|
||||
})
|
||||
|
||||
// 预定义的颜色方案 - 更现代和可访问的颜色
|
||||
const colorPalette = [
|
||||
"#3B82F6", // 蓝色
|
||||
"#EF4444", // 红色
|
||||
"#10B981", // 绿色
|
||||
"#F59E0B", // 黄色
|
||||
"#8B5CF6", // 紫色
|
||||
"#EC4899", // 粉色
|
||||
"#06B6D4", // 青色
|
||||
"#84CC16", // 青绿色
|
||||
"#F97316", // 橙色
|
||||
"#6366F1", // 靛蓝色
|
||||
]
|
||||
|
||||
// 数据处理函数
|
||||
const processChartData = () => {
|
||||
if (!props.ranks || props.ranks.length === 0) {
|
||||
return {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 获取前10名用户的数据
|
||||
const topUsers = props.ranks.slice(0, 10)
|
||||
|
||||
// 获取所有题目ID(从所有用户的submission_info中收集)
|
||||
const allProblemIds = new Set<string>()
|
||||
topUsers.forEach((rank) => {
|
||||
Object.keys(rank.submission_info).forEach((problemId) => {
|
||||
allProblemIds.add(problemId)
|
||||
})
|
||||
})
|
||||
|
||||
// 按题目ID排序
|
||||
const problemIds = Array.from(allProblemIds).sort()
|
||||
|
||||
// 创建题目标签
|
||||
const labels = problemIds.map((id) => {
|
||||
if (props.problems) {
|
||||
const problem = props.problems.find((p) => p.id.toString() === id)
|
||||
return problem ? problem.title : `题目${id}`
|
||||
}
|
||||
return `题目${id}`
|
||||
})
|
||||
|
||||
// 找到所有用户中最早的提交时间
|
||||
let earliestTime = Infinity
|
||||
topUsers.forEach((rank) => {
|
||||
Object.values(rank.submission_info).forEach((submissionInfo) => {
|
||||
if (submissionInfo.is_ac && submissionInfo.ac_time < earliestTime) {
|
||||
earliestTime = submissionInfo.ac_time
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 如果没有找到任何通过记录,使用0作为基准
|
||||
if (earliestTime === Infinity) {
|
||||
earliestTime = 0
|
||||
}
|
||||
|
||||
// 为每个用户创建数据集
|
||||
const datasets = topUsers.map((rank, userIndex) => {
|
||||
const userData = problemIds.map((problemId) => {
|
||||
const submissionInfo = rank.submission_info[problemId]
|
||||
if (!submissionInfo || !submissionInfo.is_ac) {
|
||||
return null
|
||||
}
|
||||
return submissionInfo.ac_time - earliestTime
|
||||
})
|
||||
|
||||
const actualRank = userIndex + 1
|
||||
const colorIndex = userIndex % colorPalette.length
|
||||
const color = colorPalette[colorIndex]
|
||||
|
||||
return {
|
||||
label: `第${actualRank}名: ${rank.user.username}`,
|
||||
data: userData,
|
||||
borderColor: color,
|
||||
backgroundColor: color + "20",
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
spanGaps: false,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
}
|
||||
}
|
||||
|
||||
// 监听数据变化,重新处理
|
||||
watch(
|
||||
() => [props.ranks, props.problems],
|
||||
() => {
|
||||
if (props.ranks && props.ranks.length > 0) {
|
||||
// 数据变化时重新处理
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
const chartData = computed(() => {
|
||||
return processChartData()
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "top" as const,
|
||||
maxHeight: 80,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
usePointStyle: true,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function (context: any) {
|
||||
return `题目: ${context[0].label}`
|
||||
},
|
||||
label: function (context: any) {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label
|
||||
|
||||
if (value === null) {
|
||||
return `${label}: 未通过`
|
||||
}
|
||||
|
||||
const hours = Math.floor(value / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
const seconds = Math.floor(value % 60)
|
||||
|
||||
let timeStr = ""
|
||||
if (hours > 0) timeStr += `${hours}小时`
|
||||
if (minutes > 0) timeStr += `${minutes}分钟`
|
||||
if (seconds > 0 || timeStr === "") timeStr += `${seconds}秒`
|
||||
|
||||
return `${label}: +${timeStr}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "相对通过时间",
|
||||
},
|
||||
min: 0,
|
||||
ticks: {
|
||||
callback: function (value: any) {
|
||||
const hours = Math.floor(value / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
const seconds = Math.floor(value % 60)
|
||||
|
||||
if (hours > 0) return `+${hours}h${minutes}m`
|
||||
if (minutes > 0) return `+${minutes}m${seconds}s`
|
||||
return `+${seconds}s`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 500px;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useContestStore } from "../store/contest"
|
||||
import ContestInfo from "./components/ContestInfo.vue"
|
||||
import ContestMenu from "./components/ContestMenu.vue"
|
||||
@@ -12,6 +12,8 @@ const props = defineProps<{
|
||||
const contestStore = useContestStore()
|
||||
const message = useMessage()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const password = ref("")
|
||||
|
||||
async function check() {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { NTag } from "naive-ui"
|
||||
import { getContestList } from "oj/api"
|
||||
import { duration, parseTime } from "utils/functions"
|
||||
import { Contest } from "utils/types"
|
||||
import ContestTitle from "~/shared/components/ContestTitle.vue"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { toggleLogin } from "~/shared/composables/modal"
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { CONTEST_STATUS, ContestType } from "~/utils/constants"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { CONTEST_STATUS, ContestType } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const authStore = useAuthModalStore()
|
||||
|
||||
interface ContestQuery {
|
||||
keyword: string
|
||||
@@ -22,9 +24,9 @@ interface ContestQuery {
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ContestQuery>({
|
||||
keyword: "",
|
||||
status: "",
|
||||
tag: "",
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
status: useRouteQuery("status", "").value,
|
||||
tag: useRouteQuery("tag", "").value,
|
||||
})
|
||||
|
||||
const data = ref<Contest[]>([])
|
||||
@@ -95,7 +97,6 @@ async function listContests() {
|
||||
total.value = res.data.total
|
||||
}
|
||||
|
||||
|
||||
function search(value: string) {
|
||||
query.keyword = value
|
||||
}
|
||||
@@ -107,24 +108,20 @@ function clear() {
|
||||
onMounted(listContests)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
listContests,
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watchDebounced(() => query.keyword, listContests, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit, query.status, query.tag],
|
||||
listContests,
|
||||
)
|
||||
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
|
||||
|
||||
function rowProps(row: Contest) {
|
||||
return {
|
||||
style: "cursor: pointer",
|
||||
onClick() {
|
||||
if (!userStore.isAuthed && row.contest_type === ContestType.private) {
|
||||
toggleLogin(true)
|
||||
authStore.openLoginModal()
|
||||
} else {
|
||||
router.push("/contest/" + row.id)
|
||||
}
|
||||
@@ -138,26 +135,30 @@ function rowProps(row: Contest) {
|
||||
<n-form :show-feedback="false" label-placement="left" inline>
|
||||
<n-form-item label="比赛状态">
|
||||
<n-select
|
||||
class="select"
|
||||
style="width: 120px"
|
||||
:options="options"
|
||||
v-model:value="query.status"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="标签">
|
||||
<n-select class="select" :options="tags" v-model:value="query.tag" />
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
:options="tags"
|
||||
v-model:value="query.tag"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form :show-feedback="false" label-placement="left" inline>
|
||||
<n-form-item>
|
||||
<n-input
|
||||
class="input"
|
||||
style="width: 180px"
|
||||
clearable
|
||||
v-model:value="query.keyword"
|
||||
placeholder="比赛标题"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-flex>
|
||||
<n-flex :wrap="false">
|
||||
<n-button @click="search(query.keyword)">搜索</n-button>
|
||||
<n-button @click="clear" quaternary>重置</n-button>
|
||||
</n-flex>
|
||||
@@ -177,13 +178,3 @@ function rowProps(row: Contest) {
|
||||
:total="total"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ProblemFiltered } from "utils/types"
|
||||
import ProblemStatus from "~/oj/problem/components/ProblemStatus.vue"
|
||||
import { useContestStore } from "~/oj/store/contest"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
|
||||
const props = defineProps<{ contestID: string }>()
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import { Icon } from "@iconify/vue"
|
||||
import { NButton, useThemeVars } from "naive-ui"
|
||||
import { getContestProblems, getContestRank } from "oj/api"
|
||||
import { secondsToDuration } from "utils/functions"
|
||||
import { useContestStore } from "~/oj/store/contest"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { ContestStatus } from "~/utils/constants"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import { ContestRank, ProblemFiltered } from "~/utils/types"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { ContestRank, ProblemFiltered } from "utils/types"
|
||||
import AcAndSubmission from "../components/AcAndSubmission.vue"
|
||||
import LineChart from "../components/LineChart.vue"
|
||||
|
||||
interface Props {
|
||||
contestID: string
|
||||
@@ -26,7 +27,7 @@ const total = ref(0)
|
||||
const data = ref<ContestRank[]>([])
|
||||
const chart = ref<ContestRank[]>([])
|
||||
const problems = ref<ProblemFiltered[]>([])
|
||||
const [autoRefresh] = useToggle()
|
||||
const [autoRefresh] = useToggle(true)
|
||||
const { resume, pause } = useIntervalFn(
|
||||
() => {
|
||||
query.page = 1
|
||||
@@ -82,13 +83,13 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
|
||||
align: "center",
|
||||
render: (row) => h(AcAndSubmission, { rank: row }),
|
||||
},
|
||||
// {
|
||||
// title: "总时间",
|
||||
// key: "total_time",
|
||||
// width: 120,
|
||||
// align: "center",
|
||||
// render: (row) => secondsToDuration(row.total_time),
|
||||
// },
|
||||
{
|
||||
title: "总时间",
|
||||
key: "total_time",
|
||||
width: 120,
|
||||
align: "center",
|
||||
render: (row) => secondsToDuration(row.total_time),
|
||||
},
|
||||
])
|
||||
|
||||
async function listRanks() {
|
||||
@@ -167,11 +168,15 @@ async function addColumns() {
|
||||
const status = row.submission_info[problem.id]
|
||||
if (status.is_first_ac) {
|
||||
backgroundColor = theme.value.primaryColor
|
||||
color = theme.value.baseColor
|
||||
} else if (status.is_ac) {
|
||||
backgroundColor = "#dff0d8"
|
||||
const success = theme.value.successColor
|
||||
backgroundColor = success + "50"
|
||||
color = theme.value.textColorBase
|
||||
} else {
|
||||
backgroundColor = theme.value.warningColor
|
||||
color = theme.value.errorColor
|
||||
const error = theme.value.errorColor
|
||||
backgroundColor = error + "50"
|
||||
color = theme.value.textColorBase
|
||||
}
|
||||
}
|
||||
return { style: { backgroundColor, color } }
|
||||
@@ -203,6 +208,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 排名变化图表 -->
|
||||
<LineChart :ranks="chart" :problems="problems" v-if="chart.length > 0" />
|
||||
|
||||
<!-- 排名表格 -->
|
||||
<n-data-table
|
||||
striped
|
||||
:single-line="false"
|
||||
|
||||
7
src/oj/flowchart/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import FlowchartEditor from "shared/components/FlowchartEditor/index.vue"
|
||||
</script>
|
||||
<template>
|
||||
<FlowchartEditor />
|
||||
</template>
|
||||
<style scoped></style>
|
||||