Compare commits
56 Commits
c16059a2ee
...
389393b70d
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
3
.env
3
.env
@@ -2,4 +2,5 @@ PUBLIC_ENV=dev
|
|||||||
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
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_OJ_URL=http://localhost:8000
|
||||||
PUBLIC_CODE_URL=http://localhost:3000
|
PUBLIC_CODE_URL=http://localhost:3000
|
||||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||||
|
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
||||||
@@ -2,4 +2,5 @@ PUBLIC_ENV=xuyue.cc
|
|||||||
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99
|
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_OJ_URL=https://oj.xuyue.cc
|
||||||
PUBLIC_CODE_URL=https://code.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
|
||||||
@@ -3,4 +3,5 @@ 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_OJ_URL=http://10.13.114.114:81
|
||||||
PUBLIC_CODE_URL=http://10.13.114.114:82
|
PUBLIC_CODE_URL=http://10.13.114.114:82
|
||||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
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=wss://signaling.xuyue.cc
|
||||||
@@ -3,4 +3,5 @@ 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_OJ_URL=http://10.13.114.114:81
|
||||||
PUBLIC_CODE_URL=http://10.13.114.114:82
|
PUBLIC_CODE_URL=http://10.13.114.114:82
|
||||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
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=wss://signaling.xuyue.cc
|
||||||
438
docs/API接口对比分析.md
Normal file
438
docs/API接口对比分析.md
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
# API接口对比分析
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### 最近更新(2025-10)
|
||||||
|
- ✨ **AI分析功能增强**:完善了AI智能分析模块的文档说明
|
||||||
|
- 详细说明了4个AI相关接口的功能和参数
|
||||||
|
- 新增等级系统说明(S/A/B/C),包含特殊规则
|
||||||
|
- 补充了时间范围选择功能
|
||||||
|
- 说明了流式响应的实现方式
|
||||||
|
- 前端组件从 `WeeklyChart.vue` 升级为 `DurationChart.vue`(混合图表)
|
||||||
|
- 🔧 **数据缓存优化**:后端AI接口增加了缓存机制,提升性能
|
||||||
|
- 🐛 **修正等级系统说明**:更正了等级阈值(A级:前35%,B级:前75%),并补充了小规模参与惩罚规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、前端已使用的API接口
|
||||||
|
|
||||||
|
### 1. 用户认证相关(shared/api.ts)
|
||||||
|
- `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/compile_spj` - 编译Special Judge
|
||||||
|
- `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(目前只有图片上传)
|
||||||
|
- **Special Judge编译**: compile_spj(高级题目功能)
|
||||||
|
|
||||||
|
### 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` - 获取所有判题服务器列表及状态
|
||||||
|
|
||||||
|
**结论**:
|
||||||
|
此接口是系统内部通信接口,前端完全不需要调用。类似的内部接口还可能存在于分布式系统的其他服务间通信中。
|
||||||
|
|
||||||
@@ -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. **路由权限检查是异步的**,需要等待用户信息加载完成
|
|
||||||
41
docs/图表.md
Normal file
41
docs/图表.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
📊 可以添加的图表类型
|
||||||
|
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
|
||||||
|
展示内容: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
|
||||||
|
价值: 全面评估进步情况
|
||||||
@@ -9,11 +9,14 @@
|
|||||||
<script>
|
<script>
|
||||||
window.localStorage.setItem("maxkbMaskTip", true)
|
window.localStorage.setItem("maxkbMaskTip", true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<% if (process.env.PUBLIC_MAXKB_URL) { %>
|
||||||
<script
|
<script
|
||||||
async
|
async
|
||||||
defer
|
defer
|
||||||
src="<%= process.env.PUBLIC_MAXKB_URL %>"
|
src="<%= process.env.PUBLIC_MAXKB_URL %>"
|
||||||
></script>
|
></script>
|
||||||
|
<% } %>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
1238
package-lock.json
generated
1238
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -13,6 +13,7 @@
|
|||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@vueuse/core": "^13.9.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
|
"@vueuse/router": "^13.9.0",
|
||||||
"@wangeditor-next/editor": "^5.6.46",
|
"@wangeditor-next/editor": "^5.6.46",
|
||||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
@@ -27,20 +28,22 @@
|
|||||||
"naive-ui": "^2.43.1",
|
"naive-ui": "^2.43.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"query-string": "^9.3.1",
|
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-codemirror": "^6.1.1",
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"y-codemirror.next": "^0.3.5",
|
||||||
|
"y-webrtc": "^10.3.0",
|
||||||
|
"yjs": "^13.6.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.0",
|
"@iconify/vue": "^5.0.0",
|
||||||
"@rsbuild/core": "^1.5.13",
|
"@rsbuild/core": "^1.5.13",
|
||||||
"@rsbuild/plugin-vue": "^1.1.2",
|
"@rsbuild/plugin-vue": "^1.1.2",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.6.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.3",
|
||||||
"unplugin-auto-import": "^20.2.0",
|
"unplugin-auto-import": "^20.2.0",
|
||||||
"unplugin-vue-components": "^29.1.0"
|
"unplugin-vue-components": "^29.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Monaco";
|
font-family: "Monaco";
|
||||||
src: url(/Monaco.ttf);
|
src: url(/Monaco.ttf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-editor-preview .md-editor-code .md-editor-code-head {
|
||||||
|
z-index: 100 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-editor-preview h1 {
|
||||||
|
font-size: 1.6rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-editor-preview h2 {
|
||||||
|
font-size: 1.4rem !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export default defineConfig(({ envMode }) => {
|
|||||||
headers: { Referer: url },
|
headers: { Referer: url },
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebSocket 代理配置(开发环境 Daphne 在 8001 端口)
|
||||||
|
const wsProxyConfig = {
|
||||||
|
target: url.replace(':8000', ':8001').replace('http:', 'ws:'), // http://localhost:8001 → ws://localhost:8001
|
||||||
|
ws: true, // 启用 WebSocket 代理
|
||||||
|
changeOrigin: true,
|
||||||
|
logLevel: 'debug' as const, // 显示代理日志
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
plugins: [pluginVue()],
|
plugins: [pluginVue()],
|
||||||
tools: {
|
tools: {
|
||||||
@@ -72,14 +80,68 @@ export default defineConfig(({ envMode }) => {
|
|||||||
performance: {
|
performance: {
|
||||||
chunkSplit: {
|
chunkSplit: {
|
||||||
strategy: "split-by-module",
|
strategy: "split-by-module",
|
||||||
|
override: {
|
||||||
|
cacheGroups: {
|
||||||
|
// ===== 核心框架层 (100+) =====
|
||||||
|
// Vue 生态 - 框架基础,最高优先级
|
||||||
|
vue: {
|
||||||
|
test: /[\\/]node_modules[\\/](vue|vue-router|pinia|@vue|@vueuse)[\\/]/,
|
||||||
|
name: "vendor-vue",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
// ===== UI 层 (90+) =====
|
||||||
|
// Naive UI 及其依赖 - 核心 UI 框架
|
||||||
|
ui: {
|
||||||
|
test: /[\\/]node_modules[\\/](naive-ui|@css-render|css-render|seemly|vooks|vueuc|treemate|vdirs|evtd)[\\/]/,
|
||||||
|
name: "vendor-ui",
|
||||||
|
priority: 90,
|
||||||
|
},
|
||||||
|
// ===== 编辑器层 (70-80) =====
|
||||||
|
// CodeMirror - 代码编辑器(使用最频繁)
|
||||||
|
editor: {
|
||||||
|
test: /[\\/]node_modules[\\/](codemirror|@codemirror|vue-codemirror|y-codemirror\.next)[\\/]/,
|
||||||
|
name: "vendor-editor",
|
||||||
|
priority: 80,
|
||||||
|
},
|
||||||
|
// Markdown 编辑器
|
||||||
|
mdeditor: {
|
||||||
|
test: /[\\/]node_modules[\\/]md-editor-v3[\\/]/,
|
||||||
|
name: "vendor-mdeditor",
|
||||||
|
priority: 75,
|
||||||
|
},
|
||||||
|
// WangEditor - 富文本编辑器
|
||||||
|
wangeditor: {
|
||||||
|
test: /[\\/]node_modules[\\/]@wangeditor-next[\\/]/,
|
||||||
|
name: "vendor-wangeditor",
|
||||||
|
priority: 70,
|
||||||
|
},
|
||||||
|
// ===== 功能库层 (50-60) =====
|
||||||
|
// Chart.js - 图表库(按需加载)
|
||||||
|
charts: {
|
||||||
|
test: /[\\/]node_modules[\\/](chart\.js|vue-chartjs|@kurkle|canvas-confetti)[\\/]/,
|
||||||
|
name: "vendor-charts",
|
||||||
|
priority: 60,
|
||||||
|
},
|
||||||
|
// ===== 通用层 (10) =====
|
||||||
|
// 其他常用库 - 兜底分组
|
||||||
|
common: {
|
||||||
|
test: /[\\/]node_modules[\\/]/,
|
||||||
|
name: "vendor-common",
|
||||||
|
priority: 10,
|
||||||
|
minChunks: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
// 移除 console.log(生产环境)
|
||||||
|
removeConsole: ["log"],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"~": "./src",
|
|
||||||
utils: "./src/utils",
|
utils: "./src/utils",
|
||||||
oj: "./src/oj",
|
oj: "./src/oj",
|
||||||
admin: "./src/admin",
|
admin: "./src/admin",
|
||||||
|
shared: "./src/shared",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
@@ -87,6 +149,7 @@ export default defineConfig(({ envMode }) => {
|
|||||||
proxy: {
|
proxy: {
|
||||||
"/api": proxyConfig,
|
"/api": proxyConfig,
|
||||||
"/public": proxyConfig,
|
"/public": proxyConfig,
|
||||||
|
"/ws": wsProxyConfig, // WebSocket 使用单独的代理配置
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/App.vue
39
src/App.vue
@@ -1,17 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<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 { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||||
import "normalize.css"
|
import "normalize.css"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
hljs.registerLanguage("c", c)
|
|
||||||
hljs.registerLanguage("python", python)
|
|
||||||
hljs.registerLanguage("cpp", cpp)
|
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
|
|
||||||
|
// 延迟加载 highlight.js,避免阻塞首屏
|
||||||
|
const hljsInstance = ref<any>(null)
|
||||||
|
const loadHighlightJS = async () => {
|
||||||
|
if (hljsInstance.value) return hljsInstance.value
|
||||||
|
|
||||||
|
const hljs = (await import("highlight.js/lib/core")).default
|
||||||
|
const c = (await import("highlight.js/lib/languages/c")).default
|
||||||
|
const cpp = (await import("highlight.js/lib/languages/cpp")).default
|
||||||
|
const python = (await import("highlight.js/lib/languages/python")).default
|
||||||
|
|
||||||
|
hljs.registerLanguage("c", c)
|
||||||
|
hljs.registerLanguage("python", python)
|
||||||
|
hljs.registerLanguage("cpp", cpp)
|
||||||
|
|
||||||
|
hljsInstance.value = hljs
|
||||||
|
return hljs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在空闲时预加载
|
||||||
|
onMounted(() => {
|
||||||
|
if ("requestIdleCallback" in window) {
|
||||||
|
requestIdleCallback(() => loadHighlightJS())
|
||||||
|
} else {
|
||||||
|
setTimeout(() => loadHighlightJS(), 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
provide("hljs", hljsInstance)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -19,7 +40,7 @@ const isDark = useDark()
|
|||||||
:theme="isDark ? darkTheme : null"
|
:theme="isDark ? darkTheme : null"
|
||||||
:locale="zhCN"
|
:locale="zhCN"
|
||||||
:date-locale="dateZhCN"
|
:date-locale="dateZhCN"
|
||||||
:hljs="hljs"
|
:hljs="hljsInstance"
|
||||||
>
|
>
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteAnnouncement } from "~/admin/api"
|
import { deleteAnnouncement } from "admin/api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
announcementID: number
|
announcementID: number
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import { AnnouncementEdit } from "~/utils/types"
|
import { AnnouncementEdit } from "utils/types"
|
||||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch } from "naive-ui"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Announcement } from "~/utils/types"
|
import { Announcement } from "utils/types"
|
||||||
import { editAnnouncement, getAnnouncementList } from "../api"
|
import { editAnnouncement, getAnnouncementList } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Tutorial,
|
Tutorial,
|
||||||
User,
|
User,
|
||||||
WebsiteConfig,
|
WebsiteConfig,
|
||||||
} from "~/utils/types"
|
} from "utils/types"
|
||||||
|
|
||||||
export function getBaseInfo() {
|
export function getBaseInfo() {
|
||||||
return http.get("admin/dashboard_info")
|
return http.get("admin/dashboard_info")
|
||||||
@@ -25,8 +25,8 @@ export async function getProblemList(
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
keyword: string,
|
keyword: string,
|
||||||
|
author?: string,
|
||||||
contestID?: string,
|
contestID?: string,
|
||||||
ruleType?: "ACM" | "OI",
|
|
||||||
) {
|
) {
|
||||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||||
const res = await http.get(endpoint, {
|
const res = await http.get(endpoint, {
|
||||||
@@ -35,8 +35,8 @@ export async function getProblemList(
|
|||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
keyword,
|
keyword,
|
||||||
|
author,
|
||||||
contest_id: contestID,
|
contest_id: contestID,
|
||||||
rule_type: ruleType,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -86,9 +86,10 @@ export function getUserList(
|
|||||||
limit = 10,
|
limit = 10,
|
||||||
type = "",
|
type = "",
|
||||||
keyword: string,
|
keyword: string,
|
||||||
|
orderBy = "",
|
||||||
) {
|
) {
|
||||||
return http.get("admin/user", {
|
return http.get("admin/user", {
|
||||||
params: { paging: true, offset, limit, keyword, type },
|
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,3 +257,32 @@ export function deleteTutorial(id: number) {
|
|||||||
export function setTutorialVisibility(id: number, is_public: boolean) {
|
export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||||
return http.put("admin/tutorial/visibility", { id, is_public })
|
return http.put("admin/tutorial/visibility", { id, is_public })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将竞赛题目转为公开题目
|
||||||
|
export function makeProblemPublic(id: number, display_id: string) {
|
||||||
|
return http.post("admin/contest_problem/make_public", {
|
||||||
|
id,
|
||||||
|
display_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 比赛辅助检查
|
||||||
|
export function getACMHelperList(contest_id: number) {
|
||||||
|
return http.get("admin/contest/acm_helper", {
|
||||||
|
params: { contest_id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateACMHelperChecked(
|
||||||
|
contest_id: number,
|
||||||
|
rank_id: number,
|
||||||
|
problem_id: string,
|
||||||
|
checked: boolean,
|
||||||
|
) {
|
||||||
|
return http.put("admin/contest/acm_helper", {
|
||||||
|
contest_id,
|
||||||
|
rank_id,
|
||||||
|
problem_id,
|
||||||
|
checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { NButton } from "naive-ui"
|
import { NButton } from "naive-ui"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Comment } from "~/utils/types"
|
import { Comment } from "utils/types"
|
||||||
import { getCommentList } from "../api"
|
import { getCommentList } from "../api"
|
||||||
import CommentActions from "./components/CommentActions.vue"
|
import CommentActions from "./components/CommentActions.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteComment } from "~/admin/api"
|
import { deleteComment } from "admin/api"
|
||||||
|
|
||||||
const props = defineProps<{ commentID: number }>()
|
const props = defineProps<{ commentID: number }>()
|
||||||
const emit = defineEmits(["deleted"])
|
const emit = defineEmits(["deleted"])
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Contest } from "~/utils/types"
|
import { Contest } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contest: Contest
|
contest: Contest
|
||||||
@@ -20,12 +20,30 @@ function goEditProblems() {
|
|||||||
params: { contestID: props.contest.id },
|
params: { contestID: props.contest.id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goACMHelper() {
|
||||||
|
router.push({
|
||||||
|
name: "admin contest helper",
|
||||||
|
params: { contestID: props.contest.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isACM = computed(() => props.contest.rule_type === "ACM")
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
||||||
题目
|
题目
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="isACM"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
secondary
|
||||||
|
@click="goACMHelper"
|
||||||
|
>
|
||||||
|
审核
|
||||||
|
</n-button>
|
||||||
<n-button size="small" type="info" secondary @click="goEdit">
|
<n-button size="small" type="info" secondary @click="goEdit">
|
||||||
编辑
|
编辑
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatISO } from "date-fns"
|
import { formatISO } from "date-fns"
|
||||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { BlankContest } from "~/utils/types"
|
import { BlankContest } from "utils/types"
|
||||||
import { createContest, editContest, getContest } from "../api"
|
import { createContest, editContest, getContest } from "../api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
323
src/admin/contest/helper.vue
Normal file
323
src/admin/contest/helper.vue
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
<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 { isDesktop } 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 submissions = ref<HelperItem[]>([])
|
||||||
|
const contestStartTime = ref<Date | null>(null)
|
||||||
|
const query = reactive({
|
||||||
|
username: "",
|
||||||
|
problemId: "",
|
||||||
|
checked: "all",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查状态选项
|
||||||
|
const checkedOptions = [
|
||||||
|
{ label: "全部", value: "all" },
|
||||||
|
{ label: "已检查", value: "checked" },
|
||||||
|
{ label: "未检查", value: "unchecked" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 代码查看模态框
|
||||||
|
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||||
|
const currentSubmission = ref<any>(null)
|
||||||
|
|
||||||
|
// 格式化 AC 时间(ac_time 是相对于比赛开始的秒数)
|
||||||
|
function formatACTime(relativeSeconds: number) {
|
||||||
|
if (!contestStartTime.value) return "-"
|
||||||
|
const acTime = new Date(
|
||||||
|
contestStartTime.value.getTime() + relativeSeconds * 1000,
|
||||||
|
)
|
||||||
|
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换检查状态
|
||||||
|
async function toggleChecked(item: HelperItem) {
|
||||||
|
const newChecked = !item.checked
|
||||||
|
try {
|
||||||
|
await updateACMHelperChecked(
|
||||||
|
Number(props.contestID),
|
||||||
|
item.id,
|
||||||
|
item.problem_id,
|
||||||
|
newChecked,
|
||||||
|
)
|
||||||
|
// 更新本地状态
|
||||||
|
item.checked = newChecked
|
||||||
|
item.ac_info.checked = newChecked
|
||||||
|
|
||||||
|
// 强制触发响应式更新
|
||||||
|
submissions.value = [...submissions.value]
|
||||||
|
|
||||||
|
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.data || "操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量标记为已检查
|
||||||
|
async function markAllAsChecked() {
|
||||||
|
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
|
||||||
|
if (unchecked.length === 0) {
|
||||||
|
message.info("没有需要标记的提交")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingMsg = message.loading("正在标记...", { duration: 0 })
|
||||||
|
try {
|
||||||
|
for (const item of unchecked) {
|
||||||
|
await updateACMHelperChecked(
|
||||||
|
Number(props.contestID),
|
||||||
|
item.id,
|
||||||
|
item.problem_id,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
item.checked = true
|
||||||
|
item.ac_info.checked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制触发响应式更新
|
||||||
|
submissions.value = [...submissions.value]
|
||||||
|
|
||||||
|
loadingMsg.destroy()
|
||||||
|
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
||||||
|
} catch (err: any) {
|
||||||
|
loadingMsg.destroy()
|
||||||
|
message.error(err.data || "批量操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤后的提交列表
|
||||||
|
const filteredSubmissions = computed(() => {
|
||||||
|
return submissions.value.filter((item) => {
|
||||||
|
if (query.username && !item.username.includes(query.username)) return false
|
||||||
|
if (query.problemId && !item.problem_display_id.includes(query.problemId))
|
||||||
|
return false
|
||||||
|
if (query.checked === "checked" && !item.checked) return false
|
||||||
|
if (query.checked === "unchecked" && item.checked) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const stats = computed(() => {
|
||||||
|
const total = submissions.value.length
|
||||||
|
const checked = submissions.value.filter((item) => item.checked).length
|
||||||
|
const unchecked = total - checked
|
||||||
|
return { total, checked, unchecked }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 查看代码 - 获取该用户在该题目的 AC 提交
|
||||||
|
async function viewSubmission(item: HelperItem) {
|
||||||
|
try {
|
||||||
|
// 查询该用户在该竞赛该题目的 AC 提交
|
||||||
|
const res = await getSubmissions({
|
||||||
|
username: item.username,
|
||||||
|
problem_id: item.problem_display_id,
|
||||||
|
contest_id: props.contestID,
|
||||||
|
result: "0", // ACCEPTED
|
||||||
|
language: "",
|
||||||
|
page: 1,
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.data.results.length === 0) {
|
||||||
|
message.warning("未找到该用户的 AC 提交")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取提交详情
|
||||||
|
const submissionListItem = res.data.results[0]
|
||||||
|
const detailRes = await getSubmission(submissionListItem.id)
|
||||||
|
|
||||||
|
// 手动添加 contest 字段(ACM模式下后端不返回此字段)
|
||||||
|
currentSubmission.value = {
|
||||||
|
...detailRes.data,
|
||||||
|
contest: Number(props.contestID),
|
||||||
|
problem_display_id: item.problem_display_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCodePanel(true)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.data || "加载提交失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
// 先获取比赛信息,获取开始时间
|
||||||
|
const contestRes = await getContest(props.contestID)
|
||||||
|
contestStartTime.value = new Date(contestRes.data.start_time)
|
||||||
|
|
||||||
|
// 再获取 AC 提交列表
|
||||||
|
const { data } = await getACMHelperList(Number(props.contestID))
|
||||||
|
submissions.value = data
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.data || "加载失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumn<HelperItem>[] = [
|
||||||
|
{
|
||||||
|
title: "用户名",
|
||||||
|
key: "username",
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "题目",
|
||||||
|
key: "problem_display_id",
|
||||||
|
width: 100,
|
||||||
|
render: (row) => h(NTag, { type: "info" }, () => row.problem_display_id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AC时间",
|
||||||
|
key: "ac_time",
|
||||||
|
width: 180,
|
||||||
|
render: (row) => formatACTime(row.ac_info.ac_time),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "错误次数",
|
||||||
|
key: "error_number",
|
||||||
|
width: 100,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{
|
||||||
|
type: row.ac_info.error_number > 0 ? "warning" : "success",
|
||||||
|
size: "small",
|
||||||
|
},
|
||||||
|
() => row.ac_info.error_number,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "已检查",
|
||||||
|
key: "checked",
|
||||||
|
width: 100,
|
||||||
|
render: (row) =>
|
||||||
|
h(NCheckbox, {
|
||||||
|
checked: row.checked,
|
||||||
|
onUpdateChecked: () => toggleChecked(row),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 100,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
type: "primary",
|
||||||
|
secondary: true,
|
||||||
|
onClick: () => viewSubmission(row),
|
||||||
|
},
|
||||||
|
() => "查看代码",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-flex vertical>
|
||||||
|
<n-flex justify="space-between" align="center">
|
||||||
|
<n-flex align="center">
|
||||||
|
<h2 style="margin: 0">比赛辅助检查</h2>
|
||||||
|
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag>
|
||||||
|
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag>
|
||||||
|
<n-tag type="warning" size="large">
|
||||||
|
未检查: {{ stats.unchecked }}
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="stats.unchecked === 0"
|
||||||
|
@click="markAllAsChecked"
|
||||||
|
>
|
||||||
|
标记全部为已检查
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-alert type="info" style="margin-bottom: 16px">
|
||||||
|
<template #header>使用说明</template>
|
||||||
|
此工具用于赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选"已检查"。
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-flex align="center" style="margin-bottom: 16px">
|
||||||
|
<n-input
|
||||||
|
v-model:value="query.username"
|
||||||
|
placeholder="筛选用户名"
|
||||||
|
style="width: 150px"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<n-input
|
||||||
|
v-model:value="query.problemId"
|
||||||
|
placeholder="筛选题目"
|
||||||
|
style="width: 150px"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.checked"
|
||||||
|
:options="checkedOptions"
|
||||||
|
style="width: 120px"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-data-table
|
||||||
|
:columns="columns"
|
||||||
|
:data="filteredSubmissions"
|
||||||
|
:pagination="{ pageSize: 20 }"
|
||||||
|
:bordered="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="codePanel"
|
||||||
|
preset="card"
|
||||||
|
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||||
|
:content-style="{ overflow: 'auto' }"
|
||||||
|
title="代码详情"
|
||||||
|
>
|
||||||
|
<SubmissionDetail
|
||||||
|
v-if="currentSubmission"
|
||||||
|
:submission="currentSubmission"
|
||||||
|
:problemID="currentSubmission.problem_display_id"
|
||||||
|
:submissionID="currentSubmission.id"
|
||||||
|
hideList
|
||||||
|
/>
|
||||||
|
</n-modal>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.n-data-table) {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSwitch, NTag } from "naive-ui"
|
import { NSwitch, NTag } from "naive-ui"
|
||||||
import ContestTitle from "~/shared/components/ContestTitle.vue"
|
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||||
import ContestType from "~/shared/components/ContestType.vue"
|
import ContestType from "shared/components/ContestType.vue"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { CONTEST_STATUS } from "~/utils/constants"
|
import { CONTEST_STATUS } from "utils/constants"
|
||||||
import { Contest } from "~/utils/types"
|
import { Contest } from "utils/types"
|
||||||
import { editContest, getContestList } from "../api"
|
import { editContest, getContestList } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ const columns: DataTableColumn<Contest>[] = [
|
|||||||
{
|
{
|
||||||
title: "选项",
|
title: "选项",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 140,
|
width: 220,
|
||||||
render: (row) => h(Actions, { contest: row }),
|
render: (row) => h(Actions, { contest: row }),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteContestProblem, deleteProblem } from "~/admin/api"
|
import {
|
||||||
import download from "~/utils/download"
|
deleteContestProblem,
|
||||||
|
deleteProblem,
|
||||||
|
makeProblemPublic,
|
||||||
|
} from "admin/api"
|
||||||
|
import download from "utils/download"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
problemID: number
|
problemID: number
|
||||||
problemDisplayID: string
|
problemDisplayID: string
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits(["deleted"])
|
const emit = defineEmits(["updated"])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
const isContestProblem = computed(
|
||||||
|
() => route.name === "admin contest problem list",
|
||||||
|
)
|
||||||
|
|
||||||
|
const showMakePublicModal = ref(false)
|
||||||
|
const newDisplayID = ref("")
|
||||||
|
|
||||||
async function handleDeleteProblem() {
|
async function handleDeleteProblem() {
|
||||||
try {
|
try {
|
||||||
if (route.name === "admin contest problem list") {
|
if (route.name === "admin contest problem list") {
|
||||||
@@ -21,7 +32,7 @@ async function handleDeleteProblem() {
|
|||||||
await deleteProblem(props.problemID)
|
await deleteProblem(props.problemID)
|
||||||
}
|
}
|
||||||
message.success("删除成功")
|
message.success("删除成功")
|
||||||
emit("deleted")
|
emit("updated")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.data === "Can't delete the problem as it has submissions") {
|
if (err.data === "Can't delete the problem as it has submissions") {
|
||||||
message.error("这道题有提交之后,就不能被删除")
|
message.error("这道题有提交之后,就不能被删除")
|
||||||
@@ -53,6 +64,33 @@ function goCheck() {
|
|||||||
}
|
}
|
||||||
window.open(data.href, "_blank")
|
window.open(data.href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMakePublicModal() {
|
||||||
|
newDisplayID.value = ""
|
||||||
|
showMakePublicModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMakePublic() {
|
||||||
|
if (!newDisplayID.value.trim()) {
|
||||||
|
message.error("请输入新的题目编号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
|
||||||
|
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||||
|
showMakePublicModal.value = false
|
||||||
|
emit("updated") // 刷新列表
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.data === "Duplicate display ID") {
|
||||||
|
message.error("该题目编号已存在,请使用其他编号")
|
||||||
|
} else if (err.data === "Already be a public problem") {
|
||||||
|
message.error("该题目已经是公开题目")
|
||||||
|
} else {
|
||||||
|
message.error("转换失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
@@ -62,6 +100,19 @@ function goCheck() {
|
|||||||
<n-button size="small" secondary type="info" @click="goCheck">
|
<n-button size="small" secondary type="info" @click="goCheck">
|
||||||
查看
|
查看
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-tooltip v-if="isContestProblem">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
secondary
|
||||||
|
type="warning"
|
||||||
|
@click="openMakePublicModal"
|
||||||
|
>
|
||||||
|
公开
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
将此竞赛题目转为公开题目
|
||||||
|
</n-tooltip>
|
||||||
<n-popconfirm @positive-click="handleDeleteProblem">
|
<n-popconfirm @positive-click="handleDeleteProblem">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button secondary size="small" type="error">删除</n-button>
|
<n-button secondary size="small" type="error">删除</n-button>
|
||||||
@@ -75,4 +126,35 @@ function goCheck() {
|
|||||||
下载测试用例
|
下载测试用例
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showMakePublicModal"
|
||||||
|
preset="card"
|
||||||
|
title="转为公开题目"
|
||||||
|
style="width: 500px"
|
||||||
|
>
|
||||||
|
<n-space vertical>
|
||||||
|
<p>
|
||||||
|
将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。
|
||||||
|
</p>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="新的题目编号" required>
|
||||||
|
<n-input
|
||||||
|
v-model:value="newDisplayID"
|
||||||
|
placeholder="例如: 1001"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleMakePublic"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
|
||||||
|
</n-alert>
|
||||||
|
</n-space>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="showMakePublicModal = false">取消</n-button>
|
||||||
|
<n-button type="primary" @click="handleMakePublic">确认</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { addProblemForContest } from "~/admin/api"
|
import { addProblemForContest } from "admin/api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
problemID: number
|
problemID: number
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { getProblemList } from "~/admin/api"
|
import { getProblemList } from "admin/api"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { AdminProblemFiltered } from "~/utils/types"
|
import { AdminProblemFiltered } from "utils/types"
|
||||||
import AddButton from "./AddButton.vue"
|
import AddButton from "./AddButton.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getProblemTagList } from "~/shared/api"
|
import { getProblemTagList } from "shared/api"
|
||||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import {
|
import {
|
||||||
CODE_TEMPLATES,
|
CODE_TEMPLATES,
|
||||||
LANGUAGE_SHOW_VALUE,
|
LANGUAGE_SHOW_VALUE,
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
} from "~/utils/constants"
|
} from "utils/constants"
|
||||||
import download from "~/utils/download"
|
import download from "utils/download"
|
||||||
import { unique } from "~/utils/functions"
|
import { unique } from "utils/functions"
|
||||||
import { BlankProblem, LANGUAGE, Tag } from "~/utils/types"
|
import { BlankProblem, LANGUAGE, Tag } from "utils/types"
|
||||||
import {
|
import {
|
||||||
createContestProblem,
|
createContestProblem,
|
||||||
createProblem,
|
createProblem,
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "../api"
|
} from "../api"
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(
|
const CodeEditor = defineAsyncComponent(
|
||||||
() => import("~/shared/components/CodeEditor.vue"),
|
() => import("shared/components/CodeEditor.vue"),
|
||||||
)
|
)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -70,6 +70,8 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
|||||||
rule_type: "ACM",
|
rule_type: "ACM",
|
||||||
hint: "",
|
hint: "",
|
||||||
source: "",
|
source: "",
|
||||||
|
prompt: "",
|
||||||
|
answers: [],
|
||||||
io_mode: {
|
io_mode: {
|
||||||
io_mode: "Standard IO",
|
io_mode: "Standard IO",
|
||||||
input: "input.txt",
|
input: "input.txt",
|
||||||
@@ -90,10 +92,11 @@ const tags = useLocalStorage<Tags>(STORAGE_KEY.ADMIN_PROBLEM_TAGS, {
|
|||||||
upload: [],
|
upload: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
// 这三个用的少,就不缓存本地了
|
// 这几个用的少,就不缓存本地了
|
||||||
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
||||||
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
|
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
|
||||||
const currentActiveTemplate = ref<LANGUAGE>("C")
|
const currentActiveTemplate = ref<LANGUAGE>("Python3")
|
||||||
|
const currentActiveAnswer = ref<LANGUAGE>("Python3")
|
||||||
|
|
||||||
// 给 TextEditor 用
|
// 给 TextEditor 用
|
||||||
const [ready, toggleReady] = useToggle(false)
|
const [ready, toggleReady] = useToggle(false)
|
||||||
@@ -148,6 +151,15 @@ async function getProblemDetail() {
|
|||||||
problem.value.rule_type = data.rule_type
|
problem.value.rule_type = data.rule_type
|
||||||
problem.value.hint = data.hint
|
problem.value.hint = data.hint
|
||||||
problem.value.source = data.source
|
problem.value.source = data.source
|
||||||
|
problem.value.prompt = data.prompt
|
||||||
|
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
|
problem.value.io_mode = data.io_mode
|
||||||
if (problem.value.contest_id) {
|
if (problem.value.contest_id) {
|
||||||
problem.value.contest_id = problem.value.contest_id
|
problem.value.contest_id = problem.value.contest_id
|
||||||
@@ -300,11 +312,18 @@ function filterHint() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterAnswers() {
|
||||||
|
problem.value.answers = problem.value.answers.filter(
|
||||||
|
(ans) => ans.code.trim() !== "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const notCompleted = detectProblemCompletion()
|
const notCompleted = detectProblemCompletion()
|
||||||
if (notCompleted) return
|
if (notCompleted) return
|
||||||
filterHint()
|
filterHint()
|
||||||
getTemplate()
|
getTemplate()
|
||||||
|
filterAnswers()
|
||||||
const api = {
|
const api = {
|
||||||
"admin problem create": createProblem,
|
"admin problem create": createProblem,
|
||||||
"admin problem edit": editProblem,
|
"admin problem edit": editProblem,
|
||||||
@@ -372,6 +391,19 @@ watch(
|
|||||||
problem.value.tags = uniqueTags
|
problem.value.tags = uniqueTags
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
watch(
|
||||||
|
() => problem.value.languages,
|
||||||
|
(langs) => {
|
||||||
|
const answers = langs.map((lang) => {
|
||||||
|
const existing = problem.value.answers.find(
|
||||||
|
(ans) => ans.language === lang,
|
||||||
|
)
|
||||||
|
return existing || { language: lang, code: "" }
|
||||||
|
})
|
||||||
|
problem.value.answers = answers
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -459,39 +491,72 @@ watch(
|
|||||||
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
||||||
添加用例
|
添加用例
|
||||||
</n-button>
|
</n-button>
|
||||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示" />
|
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
|
||||||
<n-form>
|
<n-form>
|
||||||
<n-form-item label="题目的来源">
|
<n-form-item label="题目的来源(选填)">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="problem.source"
|
v-model:value="problem.source"
|
||||||
placeholder="比如来自某道题的改编等,或者网上的资料"
|
placeholder="比如来自某道题的改编等,或者网上的资料"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
<n-form-item label="本题的考察知识点(选填)">
|
||||||
<n-form v-if="needTemplate">
|
<n-input
|
||||||
<n-form-item label="编写代码模板">
|
v-model:value="problem.prompt"
|
||||||
<n-tabs
|
placeholder="这里的内容是方便喂给 AI 进行辅助分析的"
|
||||||
type="segment"
|
/>
|
||||||
default-value="C"
|
|
||||||
class="template box"
|
|
||||||
v-model:value="currentActiveTemplate"
|
|
||||||
>
|
|
||||||
<n-tab-pane
|
|
||||||
v-for="(lang, index) in problem.languages"
|
|
||||||
:key="index"
|
|
||||||
:name="lang"
|
|
||||||
>
|
|
||||||
<CodeEditor
|
|
||||||
v-model:value="template[lang]"
|
|
||||||
:language="lang"
|
|
||||||
:font-size="16"
|
|
||||||
height="200px"
|
|
||||||
/>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
|
||||||
|
<n-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="200px"
|
||||||
|
/>
|
||||||
|
</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="200px"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
<n-alert
|
<n-alert
|
||||||
class="box"
|
class="box"
|
||||||
v-if="problem.test_case_score.length"
|
v-if="problem.test_case_score.length"
|
||||||
@@ -519,7 +584,7 @@ watch(
|
|||||||
</n-alert>
|
</n-alert>
|
||||||
<n-space style="margin-bottom: 100px" justify="space-between">
|
<n-space style="margin-bottom: 100px" justify="space-between">
|
||||||
<n-form inline label-placement="left" :show-feedback="false">
|
<n-form inline label-placement="left" :show-feedback="false">
|
||||||
<n-form-item label="语言">
|
<n-form-item label="编程语言">
|
||||||
<n-checkbox-group v-model:value="problem.languages">
|
<n-checkbox-group v-model:value="problem.languages">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-checkbox
|
<n-checkbox
|
||||||
@@ -554,7 +619,7 @@ watch(
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button text>温馨提醒</n-button>
|
<n-button text>温馨提醒</n-button>
|
||||||
</template>
|
</template>
|
||||||
【测试用例】最好要有10个,要考虑边界情况,不要跟【测试样例】一模一样
|
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<div>
|
<div>
|
||||||
<n-upload
|
<n-upload
|
||||||
@@ -594,8 +659,4 @@ watch(
|
|||||||
.addSamples {
|
.addSamples {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template {
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch } from "naive-ui"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { usePagination } from "~/shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { AdminProblemFiltered } from "~/utils/types"
|
import { AdminProblemFiltered } from "utils/types"
|
||||||
import { getProblemList, toggleProblemVisible } from "../api"
|
import { getProblemList, toggleProblemVisible } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
import Modal from "./components/Modal.vue"
|
import Modal from "./components/Modal.vue"
|
||||||
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
|
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contestID?: string
|
contestID?: string
|
||||||
@@ -34,11 +36,13 @@ const problems = ref<AdminProblemFiltered[]>([])
|
|||||||
|
|
||||||
interface ProblemQuery {
|
interface ProblemQuery {
|
||||||
keyword: string
|
keyword: string
|
||||||
|
author: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用分页 composable
|
// 使用分页 composable
|
||||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||||
keyword: "",
|
keyword: useRouteQuery("keyword", "").value,
|
||||||
|
author: useRouteQuery("author", "").value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||||
@@ -55,6 +59,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
{
|
{
|
||||||
title: "可见",
|
title: "可见",
|
||||||
key: "visible",
|
key: "visible",
|
||||||
|
minWidth: 80,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(NSwitch, {
|
h(NSwitch, {
|
||||||
value: row.visible,
|
value: row.visible,
|
||||||
@@ -66,17 +71,16 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
{
|
{
|
||||||
title: "选项",
|
title: "选项",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 260,
|
width: 330,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(Actions, {
|
h(Actions, {
|
||||||
problemID: row.id,
|
problemID: row.id,
|
||||||
problemDisplayID: row._id,
|
problemDisplayID: row._id,
|
||||||
onDeleted: listProblems,
|
onUpdated: listProblems,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async function listProblems() {
|
async function listProblems() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
@@ -84,6 +88,7 @@ async function listProblems() {
|
|||||||
offset,
|
offset,
|
||||||
query.limit,
|
query.limit,
|
||||||
query.keyword,
|
query.keyword,
|
||||||
|
query.author,
|
||||||
props.contestID,
|
props.contestID,
|
||||||
)
|
)
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
@@ -115,17 +120,13 @@ async function selectProblems() {
|
|||||||
onMounted(listProblems)
|
onMounted(listProblems)
|
||||||
|
|
||||||
// 监听搜索关键词变化(防抖)
|
// 监听搜索关键词变化(防抖)
|
||||||
watchDebounced(
|
watchDebounced(() => query.keyword, listProblems, {
|
||||||
() => query.keyword,
|
debounce: 500,
|
||||||
listProblems,
|
maxWait: 1000,
|
||||||
{ debounce: 500, maxWait: 1000 },
|
})
|
||||||
)
|
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
// 监听其他查询条件变化
|
||||||
watch(
|
watch(() => [query.page, query.limit, query.author], listProblems)
|
||||||
() => [query.page, query.limit],
|
|
||||||
listProblems,
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -151,8 +152,17 @@ watch(
|
|||||||
>
|
>
|
||||||
从题库中选择
|
从题库中选择
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-flex align="center" v-if="!props.contestID">
|
||||||
|
<span>出题人</span>
|
||||||
|
<AuthorSelect v-model:value="query.author" all />
|
||||||
|
</n-flex>
|
||||||
<div>
|
<div>
|
||||||
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" />
|
<n-input
|
||||||
|
v-model:value="query.keyword"
|
||||||
|
placeholder="输入标题关键字"
|
||||||
|
clearable
|
||||||
|
@clear="clearQuery"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NTag } from "naive-ui"
|
import { NButton, NTag } from "naive-ui"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Server } from "~/utils/types"
|
import { Server } from "utils/types"
|
||||||
import {
|
import {
|
||||||
deleteJudgeServer,
|
deleteJudgeServer,
|
||||||
editWebsite,
|
editWebsite,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton } from "naive-ui"
|
import { NButton } from "naive-ui"
|
||||||
import { getRank } from "oj/api"
|
import { getRank } from "oj/api"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { getACRate } from "~/utils/functions"
|
import { getACRate } from "utils/functions"
|
||||||
import { Rank } from "~/utils/types"
|
import { Rank } from "utils/types"
|
||||||
import { getBaseInfo, randomUser10 } from "../api"
|
import { getBaseInfo, randomUser10 } from "../api"
|
||||||
|
|
||||||
const userCount = ref(0)
|
const userCount = ref(0)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { deleteTutorial } from "~/admin/api"
|
import { deleteTutorial } from "admin/api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tutorialID: number
|
tutorialID: number
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||||
import MarkdownEditor from "~/shared/components/MarkdownEditor.vue"
|
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||||
import { Tutorial } from "~/utils/types"
|
import { Tutorial } from "utils/types"
|
||||||
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch } from "naive-ui"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Tutorial } from "~/utils/types"
|
import { Tutorial } from "utils/types"
|
||||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { editUser } from "~/admin/api"
|
import { editUser } from "admin/api"
|
||||||
import { User } from "~/utils/types"
|
import { User } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants"
|
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||||
import { getUserRole } from "~/utils/functions"
|
import { getUserRole } from "utils/functions"
|
||||||
import { User } from "~/utils/types"
|
import { User } from "utils/types"
|
||||||
|
import TextCopy from "shared/components/TextCopy.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User
|
||||||
@@ -30,6 +31,6 @@ const isNotRegularUser = computed(
|
|||||||
: "仅自己"
|
: "仅自己"
|
||||||
}}
|
}}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
{{ props.user.username }}
|
<TextCopy>{{ props.user.username }}</TextCopy>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DataTableRowKey, SelectOption } from "naive-ui"
|
import { DataTableRowKey, SelectOption } from "naive-ui"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { usePagination } from "~/shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { User } from "~/utils/types"
|
import { User } from "utils/types"
|
||||||
import {
|
import {
|
||||||
deleteUsers,
|
deleteUsers,
|
||||||
editUser,
|
editUser,
|
||||||
@@ -13,19 +13,23 @@ import {
|
|||||||
} from "../api"
|
} from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
import Name from "./components/Name.vue"
|
import Name from "./components/Name.vue"
|
||||||
import { 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()
|
const message = useMessage()
|
||||||
|
|
||||||
interface UserQuery {
|
interface UserQuery {
|
||||||
keyword: string
|
keyword: string
|
||||||
type: string
|
type: string
|
||||||
|
orderBy: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用分页 composable
|
// 使用分页 composable
|
||||||
const { query, clearQuery } = usePagination<UserQuery>({
|
const { query, clearQuery } = usePagination<UserQuery>({
|
||||||
keyword: "",
|
keyword: useRouteQuery("keyword", "").value,
|
||||||
type: "",
|
type: useRouteQuery("type", "").value,
|
||||||
|
orderBy: useRouteQuery("orderBy", "").value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -37,6 +41,12 @@ const adminOptions = [
|
|||||||
{ label: "管理员", value: USER_TYPE.ADMIN },
|
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ label: "默认排序", value: "" },
|
||||||
|
{ label: "最近登录", value: "-last_login" },
|
||||||
|
{ label: "最早登录", value: "last_login" },
|
||||||
|
]
|
||||||
const [create, toggleCreate] = useToggle(false)
|
const [create, toggleCreate] = useToggle(false)
|
||||||
const password = ref("")
|
const password = ref("")
|
||||||
const userIDs = ref<DataTableRowKey[]>([])
|
const userIDs = ref<DataTableRowKey[]>([])
|
||||||
@@ -56,6 +66,7 @@ const columns: DataTableColumn<User>[] = [
|
|||||||
title: "密码",
|
title: "密码",
|
||||||
key: "raw_password",
|
key: "raw_password",
|
||||||
width: 100,
|
width: 100,
|
||||||
|
render: (row) => h(TextCopy, () => row.raw_password),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
@@ -72,12 +83,17 @@ const columns: DataTableColumn<User>[] = [
|
|||||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||||
: "从未登录",
|
: "从未登录",
|
||||||
},
|
},
|
||||||
{ title: "真名", key: "real_name", width: 100 },
|
{
|
||||||
|
title: "真名",
|
||||||
|
key: "real_name",
|
||||||
|
width: 100,
|
||||||
|
render: (row) => h(TextCopy, () => row.real_name),
|
||||||
|
},
|
||||||
{ title: "邮箱", key: "email", width: 200 },
|
{ title: "邮箱", key: "email", width: 200 },
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
title: "选项",
|
title: "选项",
|
||||||
width: 260,
|
width: 280,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(Actions, {
|
h(Actions, {
|
||||||
user: row,
|
user: row,
|
||||||
@@ -101,11 +117,16 @@ const problemPermissionOptions: SelectOption[] = [
|
|||||||
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async function listUsers() {
|
async function listUsers() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await 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
|
total.value = res.data.total
|
||||||
users.value = res.data.results
|
users.value = res.data.results
|
||||||
}
|
}
|
||||||
@@ -198,17 +219,10 @@ async function handleEditUser() {
|
|||||||
onMounted(listUsers)
|
onMounted(listUsers)
|
||||||
|
|
||||||
// 监听搜索关键词变化(防抖)
|
// 监听搜索关键词变化(防抖)
|
||||||
watchDebounced(
|
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
|
||||||
() => query.keyword,
|
|
||||||
listUsers,
|
|
||||||
{ debounce: 500, maxWait: 1000 },
|
|
||||||
)
|
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
// 监听其他查询条件变化
|
||||||
watch(
|
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||||
() => [query.page, query.limit, query.type],
|
|
||||||
listUsers,
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -231,6 +245,12 @@ watch(
|
|||||||
确定删除选中的用户吗?删除后无法恢复!
|
确定删除选中的用户吗?删除后无法恢复!
|
||||||
</n-popconfirm>
|
</n-popconfirm>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.orderBy"
|
||||||
|
:options="sortOptions"
|
||||||
|
placeholder="排序方式"
|
||||||
|
style="width: 120px"
|
||||||
|
/>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="query.type"
|
v-model:value="query.type"
|
||||||
:options="adminOptions"
|
:options="adminOptions"
|
||||||
@@ -238,7 +258,12 @@ watch(
|
|||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<n-input style="width: 200px" v-model:value="query.keyword" />
|
<n-input
|
||||||
|
style="width: 200px"
|
||||||
|
v-model:value="query.keyword"
|
||||||
|
clearable
|
||||||
|
@clear="clearQuery"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
|
|||||||
readonly PUBLIC_CODE_URL: string
|
readonly PUBLIC_CODE_URL: string
|
||||||
readonly PUBLIC_JUDGE0_URL: string
|
readonly PUBLIC_JUDGE0_URL: string
|
||||||
readonly PUBLIC_ICONIFY_URL: string
|
readonly PUBLIC_ICONIFY_URL: string
|
||||||
|
readonly PUBLIC_SIGNALING_URL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
31
src/main.ts
31
src/main.ts
@@ -2,22 +2,6 @@ import { addAPIProvider } from "@iconify/vue"
|
|||||||
import { createPinia } from "pinia"
|
import { createPinia } from "pinia"
|
||||||
import { createRouter, createWebHistory } from "vue-router"
|
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_KEY } from "utils/constants"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
|
|
||||||
@@ -82,21 +66,6 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
ChartJS.register(
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
BarElement,
|
|
||||||
BarController,
|
|
||||||
ArcElement,
|
|
||||||
LineElement,
|
|
||||||
LineController,
|
|
||||||
PointElement,
|
|
||||||
Colors,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
)
|
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|||||||
@@ -1,50 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid :cols="isDesktop ? 5: 1" :x-gap="20">
|
<n-spin :show="aiStore.loading.fetching">
|
||||||
<n-gi :span="2">
|
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20" :y-gap="20">
|
||||||
<n-flex vertical size="large">
|
<n-gi :span="2">
|
||||||
<n-flex align="center" justify="space-between">
|
<n-flex vertical size="large">
|
||||||
<n-h3 style="margin: 0;">请选择时间范围,智能分析学习情况</n-h3>
|
<n-flex align="center" justify="space-between">
|
||||||
<n-select
|
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
||||||
style="width: 140px"
|
<n-select
|
||||||
:options="options"
|
style="width: 140px"
|
||||||
v-model:value="aiStore.duration"
|
:options="options"
|
||||||
/>
|
v-model:value="aiStore.duration"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
<Overview />
|
||||||
|
<n-grid :cols="2" :x-gap="20" :y-gap="20">
|
||||||
|
<n-gi :span="isDesktop ? 1 : 2">
|
||||||
|
<DifficultyGradeChart />
|
||||||
|
</n-gi>
|
||||||
|
<n-gi :span="isDesktop ? 1 : 2">
|
||||||
|
<TagsRadarChart />
|
||||||
|
</n-gi>
|
||||||
|
<n-gi :span="isDesktop ? 1 : 2">
|
||||||
|
<RankDistributionChart />
|
||||||
|
</n-gi>
|
||||||
|
<n-gi :span="isDesktop ? 1 : 2">
|
||||||
|
<TimeActivityHeatmap />
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
<SolvedTable />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<Details :start="start" :end="end" />
|
</n-gi>
|
||||||
</n-flex>
|
<n-gi :span="3">
|
||||||
</n-gi>
|
<n-flex vertical size="large">
|
||||||
<n-gi :span="3">
|
<Heatmap />
|
||||||
<n-flex vertical size="large">
|
<ProgressChart />
|
||||||
<Heatmap />
|
<EfficiencyChart />
|
||||||
<WeeklyChart :end="end" />
|
<DurationChart />
|
||||||
<AI v-if="aiStore.detailsData.solved.length" />
|
<AI v-if="aiStore.detailsData.solved.length >= 10" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
<n-gi :span="5">
|
||||||
|
<AI
|
||||||
|
v-if="
|
||||||
|
aiStore.detailsData.solved.length > 0 &&
|
||||||
|
aiStore.detailsData.solved.length < 10
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-spin>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { formatISO, sub, type Duration } from "date-fns"
|
import { formatISO, sub, type Duration } from "date-fns"
|
||||||
import WeeklyChart from "./components/WeeklyChart.vue"
|
import TagsRadarChart from "./components/TagsRadarChart.vue"
|
||||||
import Details from "./components/Details.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 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 AI from "./components/AI.vue"
|
||||||
|
import SolvedTable from "./components/SolvedTable.vue"
|
||||||
import { useAIStore } from "../store/ai"
|
import { useAIStore } from "../store/ai"
|
||||||
|
import { DURATION_OPTIONS } from "utils/constants"
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
|
||||||
const start = ref("")
|
const options = [...DURATION_OPTIONS]
|
||||||
const end = ref("")
|
|
||||||
|
|
||||||
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 subOptions = computed<Duration>(() => {
|
const subOptions = computed<Duration>(() => {
|
||||||
let dur = options.find((it) => it.value === aiStore.duration) ?? options[0]
|
let dur = options.find((it) => it.value === aiStore.duration) ?? options[0]
|
||||||
@@ -54,11 +78,25 @@ const subOptions = computed<Duration>(() => {
|
|||||||
return { [unit]: parseInt(n) } as Duration
|
return { [unit]: parseInt(n) } as Duration
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateRange() {
|
const start = computed(() => {
|
||||||
const current = new Date()
|
const current = new Date()
|
||||||
end.value = formatISO(current)
|
return formatISO(sub(current, subOptions.value))
|
||||||
start.value = formatISO(sub(current, subOptions.value))
|
})
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => aiStore.duration, updateRange, { immediate: true })
|
const end = computed(() => {
|
||||||
|
return formatISO(new Date())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取热力图数据(仅一次)
|
||||||
|
onMounted(() => {
|
||||||
|
aiStore.fetchHeatmapData()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => aiStore.duration,
|
||||||
|
() => {
|
||||||
|
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-spin :show="aiStore.loading.ai">
|
<n-card size="small">
|
||||||
<div class="container">
|
<template #header>
|
||||||
<MdPreview :model-value="aiStore.mdContent" />
|
<div class="cool-title">
|
||||||
</div>
|
<span class="title-text">AI 帮你分析</span>
|
||||||
</n-spin>
|
</div>
|
||||||
|
</template>
|
||||||
|
<n-spin :show="aiStore.loading.ai">
|
||||||
|
<div class="container">
|
||||||
|
<MdPreview :model-value="aiStore.mdContent" />
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAIStore } from "~/oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
watch(
|
watch(
|
||||||
() => [aiStore.loading.details, aiStore.loading.weekly],
|
() => aiStore.loading.fetching,
|
||||||
(newVal) => {
|
(isLoading) => {
|
||||||
if (newVal.every((val) => val === false)) {
|
if (!isLoading) {
|
||||||
aiStore.fetchAIAnalysis()
|
aiStore.fetchAIAnalysis()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -22,7 +29,41 @@ watch(
|
|||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<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 {
|
.container {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
:deep(.md-editor-preview h1) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
</style>
|
</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
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
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">) => {
|
||||||
|
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">[]) => {
|
||||||
|
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
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<ChartOptions<"line">>(() => {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: "index",
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 0,
|
||||||
|
minRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: "linear",
|
||||||
|
position: "left",
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "平均提交次数(次/题)",
|
||||||
|
font: {
|
||||||
|
size: 13,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
callback: function (value: string | number) {
|
||||||
|
return Number(value).toFixed(1)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: "linear",
|
||||||
|
position: "right",
|
||||||
|
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,43 +1,251 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chart">
|
<n-card title="过去一年的提交热力图" size="small">
|
||||||
<n-h1 class="title">过去一年的提交次数热力图</n-h1>
|
<template #header-extra>
|
||||||
<n-heatmap
|
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
||||||
:loading="!data.length"
|
</template>
|
||||||
:color-theme="getRandomColorTheme()"
|
<n-spin :show="aiStore.loading.heatmap">
|
||||||
size="large"
|
<div class="heatmap-container" ref="containerRef">
|
||||||
:data="data"
|
<svg
|
||||||
:tooltip="{ placement: 'top' }"
|
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||||
>
|
preserveAspectRatio="xMinYMin meet"
|
||||||
<template #tooltip="{ timestamp, value }">
|
class="heatmap-svg"
|
||||||
<div>{{ new Date(timestamp).toLocaleDateString() }}</div>
|
>
|
||||||
<div>提交次数: {{ value }}</div>
|
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
|
||||||
</template>
|
<text :x="label.x" :y="10" class="label" font-size="10">
|
||||||
</n-heatmap>
|
{{ label.text }}
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type HeatmapData } from "naive-ui"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { getAIHeatmapData } from "~/oj/api"
|
import { parseTime } from "utils/functions"
|
||||||
|
|
||||||
const data = ref<HeatmapData>([])
|
const aiStore = useAIStore()
|
||||||
|
const containerRef = ref<HTMLElement>()
|
||||||
|
|
||||||
function getRandomColorTheme() {
|
const CELL_SIZE = 12
|
||||||
const themes = ["green", "blue", "orange", "purple", "red"] as const
|
const CELL_GAP = 3
|
||||||
return themes[Math.floor(Math.random() * themes.length)]
|
const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
||||||
}
|
const DAY_WIDTH = 20
|
||||||
|
const MONTH_HEIGHT = 20
|
||||||
|
const RIGHT_PADDING = 5
|
||||||
|
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||||
|
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||||
|
|
||||||
onMounted(async () => {
|
const getColor = (count: number) =>
|
||||||
const res = await getAIHeatmapData()
|
count === 0
|
||||||
data.value = res.data
|
? 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>
|
const svgWidth = computed(
|
||||||
.chart {
|
() =>
|
||||||
margin: 0 auto;
|
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;
|
const tooltip = ref<{
|
||||||
font-size: 20px;
|
x: number
|
||||||
font-weight: bold;
|
y: number
|
||||||
|
date: string
|
||||||
|
text: string
|
||||||
|
count: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const tooltipStyle = computed(() => ({
|
||||||
|
left: `${tooltip.value?.x}px`,
|
||||||
|
top: `${tooltip.value?.y}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const getTooltipText = (count: number) =>
|
||||||
|
count === 0 ? "没有提交记录" : `提交了 ${count} 次`
|
||||||
|
|
||||||
|
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||||
|
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||||
|
const containerRect = containerRef.value?.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (containerRect) {
|
||||||
|
tooltip.value = {
|
||||||
|
x: rect.left - containerRect.left + rect.width / 2,
|
||||||
|
y: rect.top - containerRect.top - 10,
|
||||||
|
date: parseTime(cell.date, "YYYY年M月D日"),
|
||||||
|
text: getTooltipText(cell.count),
|
||||||
|
count: cell.count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
tooltip.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.heatmap-container {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
fill: currentColor;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
stroke: rgba(0, 0, 0, 0.05);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover {
|
||||||
|
stroke: rgba(0, 0, 0, 0.3);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
animation: fade-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-date {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-count {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-count.active {
|
||||||
|
color: #7bc96f;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, calc(-100% - 5px));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
63
src/oj/ai/components/Overview.vue
Normal file
63
src/oj/ai/components/Overview.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<n-alert
|
||||||
|
:show-icon="false"
|
||||||
|
type="success"
|
||||||
|
v-if="aiStore.detailsData.solved.length"
|
||||||
|
>
|
||||||
|
<span>{{ durationLabel }},</span>
|
||||||
|
<span>你一共解决 </span>
|
||||||
|
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
|
||||||
|
<span> 道题</span>
|
||||||
|
<span v-if="aiStore.detailsData.contest_count > 0">
|
||||||
|
,并且参加
|
||||||
|
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
|
||||||
|
次比赛
|
||||||
|
</span>
|
||||||
|
<span>,综合评价给到</span>
|
||||||
|
<Grade :grade="aiStore.detailsData.grade" />
|
||||||
|
<span>{{ greeting }}</span>
|
||||||
|
</n-alert>
|
||||||
|
<n-flex vertical size="large" v-else>
|
||||||
|
<n-alert type="error" title="你还没有完成任何题目">
|
||||||
|
开始解题,看看你的学习能力吧!
|
||||||
|
</n-alert>
|
||||||
|
<AI />
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Grade from "./Grade.vue"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import AI from "./AI.vue"
|
||||||
|
|
||||||
|
const aiStore = useAIStore()
|
||||||
|
|
||||||
|
const durationLabel = computed(() => {
|
||||||
|
if (aiStore.duration.includes("hours")) {
|
||||||
|
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
|
||||||
|
} else if (aiStore.duration.includes("days")) {
|
||||||
|
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
|
||||||
|
} else if (
|
||||||
|
aiStore.duration.includes("weeks") ||
|
||||||
|
aiStore.duration.includes("months")
|
||||||
|
) {
|
||||||
|
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
|
||||||
|
} else {
|
||||||
|
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const greeting = computed(() => {
|
||||||
|
return {
|
||||||
|
S: "要不试试高难度题目?",
|
||||||
|
A: "你很棒,继续保持!",
|
||||||
|
B: "请再接再厉!",
|
||||||
|
C: "你还需要努力!",
|
||||||
|
}[aiStore.detailsData.grade]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.charming {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
271
src/oj/ai/components/ProgressChart.vue
Normal file
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
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>
|
||||||
68
src/oj/ai/components/SolvedTable.vue
Normal file
68
src/oj/ai/components/SolvedTable.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<n-data-table
|
||||||
|
v-if="solvedProblems.length"
|
||||||
|
striped
|
||||||
|
:data="solvedProblems"
|
||||||
|
:columns="columns"
|
||||||
|
:max-height="isDesktop ? 1500 : 500"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { NButton } from "naive-ui"
|
||||||
|
import TagTitle from "./TagTitle.vue"
|
||||||
|
import { SolvedProblem } from "utils/types"
|
||||||
|
import { useAIStore } from "oj/store/ai"
|
||||||
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const aiStore = useAIStore()
|
||||||
|
|
||||||
|
const solvedProblems = computed(() => aiStore.detailsData.solved)
|
||||||
|
|
||||||
|
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||||
|
{
|
||||||
|
title: "完成的题目",
|
||||||
|
key: "problem.title",
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
onClick: () => {
|
||||||
|
if (row.problem.contest_id) {
|
||||||
|
router.push(
|
||||||
|
"/contest/" +
|
||||||
|
row.problem.contest_id +
|
||||||
|
"/problem/" +
|
||||||
|
row.problem.display_id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
router.push("/problem/" + row.problem.display_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (row.problem.contest_id) {
|
||||||
|
return h(TagTitle, { problem: row.problem })
|
||||||
|
} else {
|
||||||
|
return row.problem.display_id + " " + row.problem.title
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
|
||||||
|
key: "rank",
|
||||||
|
width: 100,
|
||||||
|
align: "center",
|
||||||
|
render: (row) => row.rank + " / " + row.ac_count,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "等级",
|
||||||
|
key: "grade",
|
||||||
|
width: 100,
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
177
src/oj/ai/components/StreakStats.vue
Normal file
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">
|
||||||
|
<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
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
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,11 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { NTag } from "naive-ui"
|
import { NTag } from "naive-ui"
|
||||||
import { getAnnouncement, getAnnouncementList } from "~/oj/api"
|
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { Announcement } from "~/utils/types"
|
import { Announcement } from "utils/types"
|
||||||
import TitleWithTag from "./components/TitleWithTag.vue"
|
import TitleWithTag from "./components/TitleWithTag.vue"
|
||||||
|
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ export async function getProblemList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAuthors(all = false) {
|
||||||
|
return http.get("problem/author", {
|
||||||
|
params: {
|
||||||
|
all: all ? "1" : "0",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getRandomProblemID() {
|
export function getRandomProblemID() {
|
||||||
return http.get("pickone")
|
return http.get("pickone")
|
||||||
}
|
}
|
||||||
@@ -84,7 +92,7 @@ export function submitCode(data: SubmitCodePayload) {
|
|||||||
return http.post("submission", data)
|
return http.post("submission", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSubmissions(params: SubmissionListPayload) {
|
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||||
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
||||||
return http.get(endpoint, { params })
|
return http.get(endpoint, { params })
|
||||||
}
|
}
|
||||||
@@ -249,13 +257,10 @@ export function getAIDetailData(start: string, end: string) {
|
|||||||
return http.get("ai/detail", { params: { start, end } })
|
return http.get("ai/detail", { params: { start, end } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAIWeeklyData(
|
export function getAIDurationData(end: string, duration: string) {
|
||||||
end: string,
|
return http.get("ai/duration", { params: { end, duration } })
|
||||||
duration: string,
|
|
||||||
) {
|
|
||||||
return http.get("ai/weekly", { params: { end, duration } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAIHeatmapData() {
|
export function getAIHeatmapData() {
|
||||||
return http.get("ai/heatmap")
|
return http.get("ai/heatmap")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { STORAGE_KEY } from "~/utils/constants"
|
import { STORAGE_KEY } from "utils/constants"
|
||||||
import storage from "~/utils/storage"
|
import storage from "utils/storage"
|
||||||
import { Code } from "~/utils/types"
|
import { Code } from "utils/types"
|
||||||
|
|
||||||
export const code = reactive<Code>({
|
export const code = reactive<Code>({
|
||||||
value: "",
|
value: "",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { Problem } from "~/utils/types"
|
import { Problem } from "utils/types"
|
||||||
|
|
||||||
export const problem = ref<Problem | null>(null)
|
export const problem = ref<Problem | null>(null)
|
||||||
|
|||||||
51
src/oj/composables/syncStatus.ts
Normal file
51
src/oj/composables/syncStatus.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// 同步状态管理 composable
|
||||||
|
|
||||||
|
export interface SyncStatusState {
|
||||||
|
otherUser?: { name: string; isSuperAdmin: boolean }
|
||||||
|
hadConnection: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供/注入的 key
|
||||||
|
export const SYNC_STATUS_KEY = Symbol("syncStatus")
|
||||||
|
|
||||||
|
// 创建同步状态
|
||||||
|
export function createSyncStatus() {
|
||||||
|
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
|
||||||
|
const hadConnection = ref(false)
|
||||||
|
|
||||||
|
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
|
||||||
|
otherUser.value = user
|
||||||
|
if (user) {
|
||||||
|
hadConnection.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
otherUser.value = undefined
|
||||||
|
hadConnection.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
otherUser,
|
||||||
|
hadConnection,
|
||||||
|
setOtherUser,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供同步状态
|
||||||
|
export function provideSyncStatus() {
|
||||||
|
const syncStatus = createSyncStatus()
|
||||||
|
provide(SYNC_STATUS_KEY, syncStatus)
|
||||||
|
return syncStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入同步状态
|
||||||
|
export function injectSyncStatus() {
|
||||||
|
const syncStatus =
|
||||||
|
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)
|
||||||
|
if (!syncStatus) {
|
||||||
|
throw new Error("syncStatus must be provided by a parent component")
|
||||||
|
}
|
||||||
|
return syncStatus
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ContestRank } from "~/utils/types"
|
import { ContestRank } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rank: ContestRank
|
rank: ContestRank
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import ContestType from "~/shared/components/ContestType.vue"
|
import ContestType from "shared/components/ContestType.vue"
|
||||||
|
|
||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { ContestStatus } from "~/utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { useContestStore } from "../store/contest"
|
import { useContestStore } from "../store/contest"
|
||||||
import ContestInfo from "./components/ContestInfo.vue"
|
import ContestInfo from "./components/ContestInfo.vue"
|
||||||
import ContestMenu from "./components/ContestMenu.vue"
|
import ContestMenu from "./components/ContestMenu.vue"
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import { NTag } from "naive-ui"
|
import { NTag } from "naive-ui"
|
||||||
import { getContestList } from "oj/api"
|
import { getContestList } from "oj/api"
|
||||||
import { duration, parseTime } from "utils/functions"
|
import { duration, parseTime } from "utils/functions"
|
||||||
import { Contest } from "utils/types"
|
import { Contest } from "utils/types"
|
||||||
import ContestTitle from "~/shared/components/ContestTitle.vue"
|
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { toggleLogin } from "~/shared/composables/modal"
|
import { toggleLogin } from "shared/composables/modal"
|
||||||
import { usePagination } from "~/shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { CONTEST_STATUS, ContestType } from "~/utils/constants"
|
import { CONTEST_STATUS, ContestType } from "utils/constants"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -22,9 +23,9 @@ interface ContestQuery {
|
|||||||
|
|
||||||
// 使用分页 composable
|
// 使用分页 composable
|
||||||
const { query, clearQuery } = usePagination<ContestQuery>({
|
const { query, clearQuery } = usePagination<ContestQuery>({
|
||||||
keyword: "",
|
keyword: useRouteQuery("keyword", "").value,
|
||||||
status: "",
|
status: useRouteQuery("status", "").value,
|
||||||
tag: "",
|
tag: useRouteQuery("tag", "").value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = ref<Contest[]>([])
|
const data = ref<Contest[]>([])
|
||||||
@@ -95,7 +96,6 @@ async function listContests() {
|
|||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function search(value: string) {
|
function search(value: string) {
|
||||||
query.keyword = value
|
query.keyword = value
|
||||||
}
|
}
|
||||||
@@ -107,17 +107,13 @@ function clear() {
|
|||||||
onMounted(listContests)
|
onMounted(listContests)
|
||||||
|
|
||||||
// 监听搜索关键词变化(防抖)
|
// 监听搜索关键词变化(防抖)
|
||||||
watchDebounced(
|
watchDebounced(() => query.keyword, listContests, {
|
||||||
() => query.keyword,
|
debounce: 500,
|
||||||
listContests,
|
maxWait: 1000,
|
||||||
{ debounce: 500, maxWait: 1000 },
|
})
|
||||||
)
|
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
// 监听其他查询条件变化
|
||||||
watch(
|
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
|
||||||
() => [query.page, query.limit, query.status, query.tag],
|
|
||||||
listContests,
|
|
||||||
)
|
|
||||||
|
|
||||||
function rowProps(row: Contest) {
|
function rowProps(row: Contest) {
|
||||||
return {
|
return {
|
||||||
@@ -138,26 +134,30 @@ function rowProps(row: Contest) {
|
|||||||
<n-form :show-feedback="false" label-placement="left" inline>
|
<n-form :show-feedback="false" label-placement="left" inline>
|
||||||
<n-form-item label="比赛状态">
|
<n-form-item label="比赛状态">
|
||||||
<n-select
|
<n-select
|
||||||
class="select"
|
style="width: 120px"
|
||||||
:options="options"
|
:options="options"
|
||||||
v-model:value="query.status"
|
v-model:value="query.status"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="标签">
|
<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-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-form :show-feedback="false" label-placement="left" inline>
|
<n-form :show-feedback="false" label-placement="left" inline>
|
||||||
<n-form-item>
|
<n-form-item>
|
||||||
<n-input
|
<n-input
|
||||||
class="input"
|
style="width: 180px"
|
||||||
clearable
|
clearable
|
||||||
v-model:value="query.keyword"
|
v-model:value="query.keyword"
|
||||||
placeholder="比赛标题"
|
placeholder="比赛标题"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item>
|
<n-form-item>
|
||||||
<n-flex>
|
<n-flex :wrap="false">
|
||||||
<n-button @click="search(query.keyword)">搜索</n-button>
|
<n-button @click="search(query.keyword)">搜索</n-button>
|
||||||
<n-button @click="clear" quaternary>重置</n-button>
|
<n-button @click="clear" quaternary>重置</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -177,13 +177,3 @@ function rowProps(row: Contest) {
|
|||||||
:total="total"
|
:total="total"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.select {
|
|
||||||
width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ProblemFiltered } from "utils/types"
|
import { ProblemFiltered } from "utils/types"
|
||||||
import ProblemStatus from "~/oj/problem/components/ProblemStatus.vue"
|
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
|
||||||
import { useContestStore } from "~/oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
|
|
||||||
const props = defineProps<{ contestID: string }>()
|
const props = defineProps<{ contestID: string }>()
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { Icon } from "@iconify/vue"
|
|||||||
import { NButton, useThemeVars } from "naive-ui"
|
import { NButton, useThemeVars } from "naive-ui"
|
||||||
import { getContestProblems, getContestRank } from "oj/api"
|
import { getContestProblems, getContestRank } from "oj/api"
|
||||||
import { secondsToDuration } from "utils/functions"
|
import { secondsToDuration } from "utils/functions"
|
||||||
import { useContestStore } from "~/oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { ContestStatus } from "~/utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { ContestRank, ProblemFiltered } from "~/utils/types"
|
import { ContestRank, ProblemFiltered } from "utils/types"
|
||||||
import AcAndSubmission from "../components/AcAndSubmission.vue"
|
import AcAndSubmission from "../components/AcAndSubmission.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -167,11 +167,15 @@ async function addColumns() {
|
|||||||
const status = row.submission_info[problem.id]
|
const status = row.submission_info[problem.id]
|
||||||
if (status.is_first_ac) {
|
if (status.is_first_ac) {
|
||||||
backgroundColor = theme.value.primaryColor
|
backgroundColor = theme.value.primaryColor
|
||||||
|
color = theme.value.baseColor
|
||||||
} else if (status.is_ac) {
|
} else if (status.is_ac) {
|
||||||
backgroundColor = "#dff0d8"
|
const success = theme.value.successColor
|
||||||
|
backgroundColor = success + "50"
|
||||||
|
color = theme.value.textColorBase
|
||||||
} else {
|
} else {
|
||||||
backgroundColor = theme.value.warningColor
|
const error = theme.value.errorColor
|
||||||
color = theme.value.errorColor
|
backgroundColor = error + "50"
|
||||||
|
color = theme.value.textColorBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { style: { backgroundColor, color } }
|
return { style: { backgroundColor, color } }
|
||||||
|
|||||||
@@ -1,78 +1,114 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid :cols="2" :x-gap="24" v-if="!!tutorial.id">
|
<div>
|
||||||
<n-gi :span="1">
|
<!-- 桌面端布局 -->
|
||||||
<n-flex vertical>
|
<n-grid :cols="5" :x-gap="16" v-if="tutorial.id && isDesktop">
|
||||||
<n-flex align="center">
|
<n-gi :span="1">
|
||||||
<n-button text :disabled="step == 1" @click="prev">
|
<n-card title="教程目录" :bordered="false" size="small">
|
||||||
<Icon :width="30" icon="pepicons-pencil:arrow-left"></Icon>
|
<n-list hoverable clickable>
|
||||||
</n-button>
|
<n-list-item
|
||||||
<n-dropdown size="large" :options="menu" trigger="click">
|
v-for="(item, index) in titles"
|
||||||
<n-button tertiary style="flex: 1" size="large">
|
:key="item.id"
|
||||||
<n-flex align="center">
|
@click="goToLesson(index + 1)"
|
||||||
<span style="font-weight: bold">
|
>
|
||||||
{{ title }}
|
<n-text
|
||||||
</span>
|
:type="step === index + 1 ? 'primary' : undefined"
|
||||||
<Icon :width="24" icon="proicons:chevron-down"></Icon>
|
:strong="step === index + 1"
|
||||||
</n-flex>
|
>
|
||||||
</n-button>
|
{{ index + 1 }}. {{ item.title }}
|
||||||
</n-dropdown>
|
</n-text>
|
||||||
<n-button text :disabled="step == titles.length" @click="next">
|
</n-list-item>
|
||||||
<Icon :width="30" icon="pepicons-pencil:arrow-right"></Icon>
|
</n-list>
|
||||||
</n-button>
|
</n-card>
|
||||||
</n-flex>
|
</n-gi>
|
||||||
<n-flex vertical size="large">
|
|
||||||
|
<n-gi :span="tutorial.code ? 2 : 4">
|
||||||
|
<n-card
|
||||||
|
:title="`第 ${step} 课:${titles[step - 1]?.title}`"
|
||||||
|
:bordered="false"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
<MdPreview
|
<MdPreview
|
||||||
preview-theme="vuepress"
|
preview-theme="vuepress"
|
||||||
:theme="isDark ? 'dark' : 'light'"
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
:model-value="tutorial.content"
|
:model-value="tutorial.content"
|
||||||
/>
|
/>
|
||||||
<n-flex justify="space-between">
|
</n-card>
|
||||||
<div style="flex: 1">
|
</n-gi>
|
||||||
<n-button
|
|
||||||
block
|
<n-gi :span="2" v-if="tutorial.code">
|
||||||
style="height: 40px"
|
<n-card title="示例代码" :bordered="false" size="small">
|
||||||
v-if="step !== 1"
|
<CodeEditor language="Python3" v-model="tutorial.code" />
|
||||||
@click="prev"
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<!-- 手机端布局 -->
|
||||||
|
<template v-if="tutorial.id && !isDesktop">
|
||||||
|
<n-tabs type="line" animated v-model:value="activeTab">
|
||||||
|
<n-tab-pane name="catalog" tab="目录">
|
||||||
|
<n-list hoverable clickable>
|
||||||
|
<n-list-item
|
||||||
|
v-for="(item, index) in titles"
|
||||||
|
:key="item.id"
|
||||||
|
@click="goToLesson(index + 1)"
|
||||||
|
>
|
||||||
|
<n-text
|
||||||
|
:type="step === index + 1 ? 'primary' : undefined"
|
||||||
|
:strong="step === index + 1"
|
||||||
>
|
>
|
||||||
上一课时
|
{{ index + 1 }}. {{ item.title }}
|
||||||
</n-button>
|
</n-text>
|
||||||
</div>
|
</n-list-item>
|
||||||
<div style="flex: 1">
|
</n-list>
|
||||||
<n-button
|
</n-tab-pane>
|
||||||
block
|
|
||||||
style="height: 40px"
|
<n-tab-pane name="content" :tab="`第 ${step} 课`">
|
||||||
v-if="step !== titles.length"
|
<MdPreview
|
||||||
@click="next"
|
preview-theme="vuepress"
|
||||||
>
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
下一课时
|
:model-value="tutorial.content"
|
||||||
</n-button>
|
/>
|
||||||
</div>
|
</n-tab-pane>
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
|
||||||
|
<CodeEditor language="Python3" v-model="tutorial.code" />
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
|
||||||
|
<n-divider style="margin: 12px 0" />
|
||||||
|
|
||||||
|
<n-flex align="center" justify="space-between">
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
type="primary"
|
||||||
|
:disabled="isFirstLesson"
|
||||||
|
@click="goToPrevLesson"
|
||||||
|
>
|
||||||
|
← 上一课
|
||||||
|
</n-button>
|
||||||
|
<n-text>{{ step }} / {{ titles.length }}</n-text>
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
type="primary"
|
||||||
|
:disabled="isLastLesson"
|
||||||
|
@click="goToNextLesson"
|
||||||
|
>
|
||||||
|
下一课 →
|
||||||
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-gi>
|
</template>
|
||||||
<n-gi :span="1">
|
</div>
|
||||||
<n-flex vertical>
|
|
||||||
<CodeEditor
|
|
||||||
v-show="!!tutorial.code"
|
|
||||||
language="Python3"
|
|
||||||
v-model="tutorial.code"
|
|
||||||
/>
|
|
||||||
</n-flex>
|
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
|
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
import { Tutorial } from "~/utils/types"
|
import { Tutorial } from "utils/types"
|
||||||
import { getTutorial, getTutorials } from "../api"
|
import { getTutorial, getTutorials } from "../api"
|
||||||
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(
|
const CodeEditor = defineAsyncComponent(
|
||||||
() => import("~/shared/components/CodeEditor.vue"),
|
() => import("shared/components/CodeEditor.vue"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
@@ -94,23 +130,28 @@ const tutorial = ref<Partial<Tutorial>>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const titles = ref<{ id: number; title: string }[]>([])
|
const titles = ref<{ id: number; title: string }[]>([])
|
||||||
const title = computed(
|
const activeTab = ref("content")
|
||||||
() => `第 ${step.value} 课:${titles.value[step.value - 1].title}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const menu = computed<DropdownOption[]>(() => {
|
const isFirstLesson = computed(() => step.value === 1)
|
||||||
return titles.value.map((item, index) => {
|
const isLastLesson = computed(() => step.value === titles.value.length)
|
||||||
const id = (index + 1).toString().padStart(2, "0")
|
|
||||||
const prefix = `第 ${index + 1} 课:`
|
function goToLesson(lessonNumber: number) {
|
||||||
return {
|
activeTab.value = "content"
|
||||||
key: id,
|
const dest = lessonNumber.toString().padStart(2, "0")
|
||||||
label: prefix + item.title,
|
router.push("/learn/" + dest)
|
||||||
props: {
|
}
|
||||||
onClick: () => router.push(`/learn/${id}`),
|
|
||||||
},
|
function goToPrevLesson() {
|
||||||
}
|
if (step.value > 1) {
|
||||||
})
|
goToLesson(step.value - 1)
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToNextLesson() {
|
||||||
|
if (step.value < titles.value.length) {
|
||||||
|
goToLesson(step.value + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const res1 = await getTutorials()
|
const res1 = await getTutorials()
|
||||||
@@ -121,18 +162,6 @@ async function init() {
|
|||||||
tutorial.value = res2.data
|
tutorial.value = res2.data
|
||||||
}
|
}
|
||||||
|
|
||||||
function next() {
|
|
||||||
if (step.value === titles.value.length) return
|
|
||||||
const dest = (step.value + 1).toString().padStart(2, "0")
|
|
||||||
router.push("/learn/" + dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
function prev() {
|
|
||||||
if (step.value === 1) return
|
|
||||||
const dest = (step.value - 1).toString().padStart(2, "0")
|
|
||||||
router.push("/learn/" + dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.params.step,
|
() => route.params.step,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -142,9 +171,3 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.md-editor-preview .md-editor-code .md-editor-code-head {
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -2,46 +2,42 @@
|
|||||||
import { code } from "oj/composables/code"
|
import { code } from "oj/composables/code"
|
||||||
import { problem } from "oj/composables/problem"
|
import { problem } from "oj/composables/problem"
|
||||||
import { SOURCES } from "utils/constants"
|
import { SOURCES } from "utils/constants"
|
||||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import storage from "~/utils/storage"
|
import storage from "utils/storage"
|
||||||
|
import { LANGUAGE } from "utils/types"
|
||||||
import Form from "./Form.vue"
|
import Form from "./Form.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const contestID = !!route.params.contestID ? route.params.contestID : null
|
|
||||||
|
|
||||||
|
const contestID = route.params.contestID || null
|
||||||
const storageKey = computed(
|
const storageKey = computed(
|
||||||
() =>
|
() =>
|
||||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
|
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (storage.get(storageKey.value)) {
|
|
||||||
code.value = storage.get(storageKey.value)
|
|
||||||
} else {
|
|
||||||
code.value =
|
|
||||||
problem.value!.template[code.language] || SOURCES[code.language]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const editorHeight = computed(() =>
|
const editorHeight = computed(() =>
|
||||||
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
|
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
|
||||||
)
|
)
|
||||||
|
|
||||||
function changeCode(v: string) {
|
onMounted(() => {
|
||||||
|
const savedCode = storage.get(storageKey.value)
|
||||||
|
code.value =
|
||||||
|
savedCode ||
|
||||||
|
problem.value!.template[code.language] ||
|
||||||
|
SOURCES[code.language]
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeCode = (v: string) => {
|
||||||
storage.set(storageKey.value, v)
|
storage.set(storageKey.value, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeLanguage(v: string) {
|
const changeLanguage = (v: LANGUAGE) => {
|
||||||
if (
|
const savedCode = storage.get(storageKey.value)
|
||||||
storage.get(storageKey.value) &&
|
code.value =
|
||||||
storageKey.value.split("_").pop() === v
|
savedCode && storageKey.value.split("_").pop() === v
|
||||||
) {
|
? savedCode
|
||||||
code.value = storage.get(storageKey.value)
|
: problem.value!.template[code.language] || SOURCES[code.language]
|
||||||
} else {
|
|
||||||
code.value =
|
|
||||||
problem.value!.template[code.language] || SOURCES[code.language]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -50,11 +46,9 @@ function changeLanguage(v: string) {
|
|||||||
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
v-model:value="code.value"
|
v-model:value="code.value"
|
||||||
@update:model-value="changeCode"
|
|
||||||
:language="code.language"
|
:language="code.language"
|
||||||
:height="editorHeight"
|
:height="editorHeight"
|
||||||
|
@update:model-value="changeCode"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
import { code, input, output } from "oj/composables/code"
|
import { code, input, output } from "oj/composables/code"
|
||||||
import { problem } from "oj/composables/problem"
|
import { problem } from "oj/composables/problem"
|
||||||
import { SOURCES } from "utils/constants"
|
import { SOURCES } from "utils/constants"
|
||||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import storage from "utils/storage"
|
||||||
import storage from "~/utils/storage"
|
import { createTestSubmission } from "utils/judge"
|
||||||
import Form from "./Form.vue"
|
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||||
|
import type { DropdownOption } from "naive-ui"
|
||||||
|
import { copyToClipboard } from "utils/functions"
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const contestID = !!route.params.contestID ? route.params.contestID : null
|
const contestID = !!route.params.contestID ? route.params.contestID : null
|
||||||
|
|
||||||
@@ -24,10 +27,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const editorHeight = computed(() =>
|
|
||||||
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
|
|
||||||
)
|
|
||||||
|
|
||||||
function changeCode(v: string) {
|
function changeCode(v: string) {
|
||||||
storage.set(storageKey.value, v)
|
storage.set(storageKey.value, v)
|
||||||
}
|
}
|
||||||
@@ -43,23 +42,62 @@ function changeLanguage(v: string) {
|
|||||||
problem.value!.template[code.language] || SOURCES[code.language]
|
problem.value!.template[code.language] || SOURCES[code.language]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
const success = await copyToClipboard(code.value)
|
||||||
|
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
code.value = problem.value!.template[code.language] || SOURCES[code.language]
|
||||||
|
storage.remove(storageKey.value)
|
||||||
|
message.success("代码重置成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCode = async () => {
|
||||||
|
const res = await createTestSubmission(code, input.value)
|
||||||
|
output.value = res.output
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||||
|
(it) => ({
|
||||||
|
label: () =>
|
||||||
|
h("div", { style: "display: flex; align-items: center;" }, [
|
||||||
|
h("img", {
|
||||||
|
src: `/${it}.svg`,
|
||||||
|
style: { width: "16px", height: "16px", marginRight: "8px" },
|
||||||
|
}),
|
||||||
|
LANGUAGE_SHOW_VALUE[it],
|
||||||
|
]),
|
||||||
|
value: it,
|
||||||
|
}),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical>
|
<n-flex vertical style="height: calc(100vh - 92px)">
|
||||||
<Form
|
|
||||||
:storage-key="storageKey"
|
|
||||||
@change-language="changeLanguage"
|
|
||||||
with-test
|
|
||||||
/>
|
|
||||||
<n-split direction="horizontal" :min="1 / 3" :max="4 / 5">
|
<n-split direction="horizontal" :min="1 / 3" :max="4 / 5">
|
||||||
<template #1>
|
<template #1>
|
||||||
<CodeEditor
|
<n-flex vertical>
|
||||||
v-model:value="code.value"
|
<n-flex align="center">
|
||||||
@update:model-value="changeCode"
|
<n-select
|
||||||
:language="code.language"
|
v-model:value="code.language"
|
||||||
:height="editorHeight"
|
style="width: 120px"
|
||||||
/>
|
:options="languageOptions"
|
||||||
|
@update:value="changeLanguage"
|
||||||
|
/>
|
||||||
|
<n-button @click="copy">复制代码</n-button>
|
||||||
|
<n-button @click="reset">重置代码</n-button>
|
||||||
|
<n-button type="primary" secondary @click="runCode">
|
||||||
|
运行代码
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<CodeEditor
|
||||||
|
v-model:value="code.value"
|
||||||
|
@update:model-value="changeCode"
|
||||||
|
:language="code.language"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<template #2>
|
<template #2>
|
||||||
<n-split
|
<n-split
|
||||||
@@ -1,146 +1,203 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import copyText from "copy-text-to-clipboard"
|
import { copyToClipboard } from "utils/functions"
|
||||||
import { code, input, output } from "oj/composables/code"
|
import { code } from "oj/composables/code"
|
||||||
import { problem } from "oj/composables/problem"
|
import { problem } from "oj/composables/problem"
|
||||||
|
import { injectSyncStatus } from "oj/composables/syncStatus"
|
||||||
|
import { SYNC_MESSAGES } from "shared/composables/sync"
|
||||||
import { LANGUAGE_SHOW_VALUE, SOURCES, STORAGE_KEY } from "utils/constants"
|
import { LANGUAGE_SHOW_VALUE, SOURCES, STORAGE_KEY } from "utils/constants"
|
||||||
import { isDesktop, isMobile } from "~/shared/composables/breakpoints"
|
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { createTestSubmission } from "~/utils/judge"
|
import storage from "utils/storage"
|
||||||
import storage from "~/utils/storage"
|
import { LANGUAGE } from "utils/types"
|
||||||
import { LANGUAGE } from "~/utils/types"
|
|
||||||
import Submit from "./Submit.vue"
|
import Submit from "./Submit.vue"
|
||||||
|
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||||
|
import IconButton from "shared/components/IconButton.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
storageKey: string
|
storageKey: string
|
||||||
withTest?: boolean
|
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
withTest: false,
|
isConnected: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 注入同步状态
|
||||||
|
const syncStatus = injectSyncStatus()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
changeLanguage: [v: LANGUAGE]
|
||||||
|
toggleSync: [v: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const emit = defineEmits(["changeLanguage"])
|
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
||||||
|
const statisticPanel = ref(false)
|
||||||
|
|
||||||
function copy() {
|
// 计算属性
|
||||||
copyText(code.value)
|
const isContestMode = computed(() => route.name === "contest problem")
|
||||||
message.success("代码复制成功")
|
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
||||||
|
const showSyncFeature = computed(
|
||||||
|
() => isDesktop.value && userStore.isAuthed && !isContestMode.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const menu = computed<DropdownOption[]>(() => [
|
||||||
|
{ label: "去自测猫", key: "goTestCat", show: isMobile.value },
|
||||||
|
{ label: "复制代码", key: "copy" },
|
||||||
|
{ label: "重置代码", key: "reset" },
|
||||||
|
])
|
||||||
|
|
||||||
|
const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||||
|
(it) => ({
|
||||||
|
label: () =>
|
||||||
|
h("div", { style: "display: flex; align-items: center;" }, [
|
||||||
|
h("img", {
|
||||||
|
src: `/${it}.svg`,
|
||||||
|
style: { width: "16px", height: "16px", marginRight: "8px" },
|
||||||
|
}),
|
||||||
|
LANGUAGE_SHOW_VALUE[it],
|
||||||
|
]),
|
||||||
|
value: it,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
const success = await copyToClipboard(code.value)
|
||||||
|
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
const reset = () => {
|
||||||
code.value = problem.value!.template[code.language] || SOURCES[code.language]
|
code.value = problem.value!.template[code.language] || SOURCES[code.language]
|
||||||
storage.remove(props.storageKey)
|
storage.remove(props.storageKey)
|
||||||
message.success("代码重置成功")
|
message.success("代码重置成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
function goSubmissions() {
|
const changeLanguage = (v: LANGUAGE) => {
|
||||||
const name = !!route.params.contestID ? "contest submissions" : "submissions"
|
|
||||||
router.push({ name, query: { problem: problem.value!._id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function goEdit() {
|
|
||||||
let data = router.resolve("/admin/problem/edit/" + problem.value!.id)
|
|
||||||
if (problem.value!.contest) {
|
|
||||||
data = router.resolve(
|
|
||||||
`/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
window.open(data.href, "_blank")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
const res = await createTestSubmission(code, input.value)
|
|
||||||
output.value = res.output
|
|
||||||
}
|
|
||||||
|
|
||||||
const menu = computed<DropdownOption[]>(() => [
|
|
||||||
{ label: "提交信息", key: "submissions", show: isMobile.value },
|
|
||||||
{ label: "自测猫", key: "test", show: isMobile.value },
|
|
||||||
{ label: "复制代码", key: "copy" },
|
|
||||||
{ label: "重置代码", key: "reset" },
|
|
||||||
])
|
|
||||||
|
|
||||||
const options: DropdownOption[] = problem.value!.languages.map((it) => ({
|
|
||||||
label: () => [
|
|
||||||
h("img", {
|
|
||||||
src: `/${it}.svg`,
|
|
||||||
style: {
|
|
||||||
width: "16px",
|
|
||||||
height: "16px",
|
|
||||||
marginRight: "8px",
|
|
||||||
transform: "translateY(3px)",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
LANGUAGE_SHOW_VALUE[it],
|
|
||||||
],
|
|
||||||
value: it,
|
|
||||||
}))
|
|
||||||
|
|
||||||
async function select(key: string) {
|
|
||||||
switch (key) {
|
|
||||||
case "submissions":
|
|
||||||
goSubmissions()
|
|
||||||
break
|
|
||||||
case "reset":
|
|
||||||
reset()
|
|
||||||
break
|
|
||||||
case "copy":
|
|
||||||
copy()
|
|
||||||
break
|
|
||||||
case "test":
|
|
||||||
window.open(import.meta.env.PUBLIC_CODE_URL, "_blank")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeLanguage(v: LANGUAGE) {
|
|
||||||
storage.set(STORAGE_KEY.LANGUAGE, v)
|
storage.set(STORAGE_KEY.LANGUAGE, v)
|
||||||
emit("changeLanguage", v)
|
emit("changeLanguage", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
function gotoTestCat() {
|
const goTestCat = () => {
|
||||||
const url = import.meta.env.PUBLIC_CODE_URL
|
window.open(import.meta.env.PUBLIC_CODE_URL, "_blank")
|
||||||
window.open(url, "_blank")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goSubmissions = () => {
|
||||||
|
const name = route.params.contestID ? "contest submissions" : "submissions"
|
||||||
|
router.push({ name, query: { problem: problem.value!._id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const goEdit = () => {
|
||||||
|
const url = problem.value!.contest
|
||||||
|
? `/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}`
|
||||||
|
: `/admin/problem/edit/${problem.value!.id}`
|
||||||
|
window.open(router.resolve(url).href, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuSelect = (key: string) => {
|
||||||
|
const actions: Record<string, () => void> = {
|
||||||
|
reset,
|
||||||
|
copy,
|
||||||
|
goTestCat,
|
||||||
|
}
|
||||||
|
actions[key]?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSync = () => {
|
||||||
|
syncEnabled.value = !syncEnabled.value
|
||||||
|
emit("toggleSync", syncEnabled.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
resetSyncStatus: () => {
|
||||||
|
syncEnabled.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-select
|
<n-select
|
||||||
class="language"
|
|
||||||
v-model:value="code.language"
|
v-model:value="code.language"
|
||||||
|
style="width: 120px"
|
||||||
|
:size="buttonSize"
|
||||||
|
:options="languageOptions"
|
||||||
@update:value="changeLanguage"
|
@update:value="changeLanguage"
|
||||||
:size="isDesktop ? 'medium' : 'small'"
|
|
||||||
:options="options"
|
|
||||||
/>
|
/>
|
||||||
<n-button v-if="withTest" @click="reset">重置代码</n-button>
|
|
||||||
<n-button v-if="withTest" type="primary" secondary @click="test">
|
|
||||||
运行代码
|
|
||||||
</n-button>
|
|
||||||
<n-flex align="center" v-if="!withTest">
|
|
||||||
<Submit />
|
|
||||||
<n-button v-if="isDesktop" @click="gotoTestCat">自测猫</n-button>
|
|
||||||
<n-button v-if="isDesktop" @click="goSubmissions">提交信息</n-button>
|
|
||||||
<n-dropdown size="large" :options="menu" @select="select">
|
|
||||||
<n-button :size="isDesktop ? 'medium' : 'small'">操作</n-button>
|
|
||||||
</n-dropdown>
|
|
||||||
<n-button
|
|
||||||
v-if="isDesktop && userStore.isSuperAdmin"
|
|
||||||
type="warning"
|
|
||||||
@click="goEdit"
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
<Submit />
|
||||||
.language {
|
|
||||||
width: 120px;
|
<n-button
|
||||||
}
|
v-if="!userStore.isSuperAdmin && userStore.showSubmissions"
|
||||||
</style>
|
:size="buttonSize"
|
||||||
|
@click="goSubmissions"
|
||||||
|
>
|
||||||
|
提交信息
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button
|
||||||
|
v-if="userStore.isSuperAdmin"
|
||||||
|
:size="buttonSize"
|
||||||
|
@click="statisticPanel = true"
|
||||||
|
>
|
||||||
|
{{ isDesktop ? "统计信息" : "统计" }}
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-button v-if="isDesktop" @click="goTestCat">自测猫</n-button>
|
||||||
|
|
||||||
|
<n-dropdown size="large" :options="menu" @select="handleMenuSelect">
|
||||||
|
<n-button :size="buttonSize">操作</n-button>
|
||||||
|
</n-dropdown>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
v-if="isDesktop && userStore.isSuperAdmin"
|
||||||
|
icon="streamline-ultimate-color:file-code-edit"
|
||||||
|
tip="编辑题目"
|
||||||
|
@click="goEdit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="showSyncFeature">
|
||||||
|
<IconButton
|
||||||
|
:icon="
|
||||||
|
syncEnabled
|
||||||
|
? 'streamline-ultimate-color:flash-off'
|
||||||
|
: 'streamline-ultimate-color:monitor-flash'
|
||||||
|
"
|
||||||
|
:tip="syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF"
|
||||||
|
:type="syncEnabled ? 'warning' : 'default'"
|
||||||
|
@click="toggleSync"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 同步状态标签 -->
|
||||||
|
<template v-if="props.isConnected">
|
||||||
|
<n-tag v-if="syncStatus.otherUser.value" type="info">
|
||||||
|
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
v-if="
|
||||||
|
userStore.isSuperAdmin &&
|
||||||
|
!syncStatus.otherUser.value &&
|
||||||
|
syncStatus.hadConnection.value
|
||||||
|
"
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
{{ SYNC_MESSAGES.STUDENT_LEFT }}
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-if="userStore.isSuperAdmin"
|
||||||
|
v-model:show="statisticPanel"
|
||||||
|
preset="card"
|
||||||
|
title="提交记录的统计"
|
||||||
|
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||||
|
:content-style="{ overflow: 'auto' }"
|
||||||
|
>
|
||||||
|
<StatisticsPanel :problem="problem!._id" username="" />
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -109,8 +109,8 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { problem } from "oj/composables/problem"
|
import { problem } from "oj/composables/problem"
|
||||||
import { DIFFICULTY } from "utils/constants"
|
import { DIFFICULTY } from "utils/constants"
|
||||||
import { createComment, getComment, getCommentStatistics } from "~/oj/api"
|
import { createComment, getComment, getCommentStatistics } from "oj/api"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showStatistics?: boolean
|
showStatistics?: boolean
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { code } from "oj/composables/code"
|
|||||||
import { problem } from "oj/composables/problem"
|
import { problem } from "oj/composables/problem"
|
||||||
import { createTestSubmission } from "utils/judge"
|
import { createTestSubmission } from "utils/judge"
|
||||||
import { Problem, ProblemStatus } from "utils/types"
|
import { Problem, ProblemStatus } from "utils/types"
|
||||||
import Copy from "~/shared/components/Copy.vue"
|
import Copy from "shared/components/Copy.vue"
|
||||||
|
|
||||||
type Sample = Problem["samples"][number] & {
|
type Sample = Problem["samples"][number] & {
|
||||||
id: number
|
id: number
|
||||||
@@ -17,6 +17,18 @@ type Sample = Problem["samples"][number] & {
|
|||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const style = computed(() => "color: " + theme.value.primaryColor)
|
const style = computed(() => "color: " + theme.value.primaryColor)
|
||||||
|
|
||||||
|
// 判断用户是否尝试过但未通过
|
||||||
|
// my_status === 0: 已通过
|
||||||
|
// my_status !== 0 && my_status !== null: 尝试过但未通过
|
||||||
|
// my_status === null: 从未尝试
|
||||||
|
const hasTriedButNotPassed = computed(() => {
|
||||||
|
return (
|
||||||
|
problem.value?.my_status !== undefined &&
|
||||||
|
problem.value?.my_status !== null &&
|
||||||
|
problem.value?.my_status !== 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const samples = ref<Sample[]>(
|
const samples = ref<Sample[]>(
|
||||||
problem.value!.samples.map((sample, index) => ({
|
problem.value!.samples.map((sample, index) => ({
|
||||||
...sample,
|
...sample,
|
||||||
@@ -89,13 +101,24 @@ function type(status: ProblemStatus) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="problem" class="problemContent">
|
<div v-if="problem" class="problemContent">
|
||||||
|
<!-- 已通过 -->
|
||||||
<n-alert
|
<n-alert
|
||||||
class="success"
|
class="status-alert"
|
||||||
v-if="problem.my_status === 0"
|
v-if="problem.my_status === 0"
|
||||||
type="success"
|
type="success"
|
||||||
title="🎉 本 题 已 经 被 你 解 决 啦"
|
title="🎉 本 题 已 经 被 你 解 决 啦"
|
||||||
/>
|
>
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<!-- 尝试过但未通过 -->
|
||||||
|
<n-alert
|
||||||
|
class="status-alert"
|
||||||
|
v-else-if="hasTriedButNotPassed"
|
||||||
|
type="warning"
|
||||||
|
title="💪 你已经尝试过这道题,但还没有通过"
|
||||||
|
>
|
||||||
|
不要放弃!仔细检查代码逻辑,或者寻求 AI 的帮助获取灵感。
|
||||||
|
</n-alert>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-tag>{{ problem._id }}</n-tag>
|
<n-tag>{{ problem._id }}</n-tag>
|
||||||
<h2 class="problemTitle">{{ problem.title }}</h2>
|
<h2 class="problemTitle">{{ problem.title }}</h2>
|
||||||
@@ -181,116 +204,138 @@ function type(status: ProblemStatus) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.problemContent .problemTitle {
|
.problemContent {
|
||||||
|
--border-color-light: rgb(239, 239, 245);
|
||||||
|
--bg-code-light: rgb(250, 250, 252);
|
||||||
|
--bg-code-dark: rgb(24, 24, 28);
|
||||||
|
--bg-table-light: rgba(250, 250, 252, 1);
|
||||||
|
--bg-table-dark: rgba(38, 38, 42, 1);
|
||||||
|
--border-color-dark: rgba(255, 255, 255, 0.09);
|
||||||
|
--blockquote-color: #7b7b7b;
|
||||||
|
--blockquote-border: #bbbec4;
|
||||||
|
--link-color: #18a058;
|
||||||
|
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .title {
|
.title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content {
|
.testcase {
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: pre;
|
||||||
|
font-family: "Monaco", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .testcase {
|
/* 针对 v-html 渲染内容的深度样式 */
|
||||||
font-size: 14px;
|
.content :deep(p) {
|
||||||
white-space: pre;
|
|
||||||
font-family: "Monaco";
|
|
||||||
}
|
|
||||||
|
|
||||||
.problemContent .success {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.problemContent .content > p {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > blockquote {
|
.content :deep(blockquote) {
|
||||||
border-left: 3px solid #bbbec4;
|
border-left: 3px solid var(--blockquote-border);
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
color: #7b7b7b;
|
color: var(--blockquote-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > pre {
|
.content :deep(pre) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: rgb(250, 250, 252);
|
background-color: var(--bg-code-light);
|
||||||
border: 1px solid rgb(239, 239, 245);
|
border: 1px solid var(--border-color-light);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition:
|
transition:
|
||||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
background-color var(--transition),
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
border-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > pre > code {
|
.content :deep(pre code) {
|
||||||
font-family: "Monaco";
|
font-family: "Monaco", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .problemContent .content > pre {
|
.dark .content :deep(pre) {
|
||||||
background-color: rgb(24, 24, 28);
|
background-color: var(--bg-code-dark);
|
||||||
border-color: rgba(255, 255, 255, 0.09);
|
border-color: var(--border-color-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > table {
|
.content :deep(table) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
border: 1px solid rgba(239, 239, 245, 1);
|
border: 1px solid var(--border-color-light);
|
||||||
transition:
|
transition:
|
||||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
background-color var(--transition),
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
border-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > table th {
|
.content :deep(table th) {
|
||||||
background-color: rgba(250, 250, 252, 1);
|
background-color: var(--bg-table-light);
|
||||||
|
padding: 8px;
|
||||||
transition:
|
transition:
|
||||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
background-color var(--transition),
|
||||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
border-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .problemContent .content > table th {
|
.dark .content :deep(table th) {
|
||||||
background-color: rgba(38, 38, 42, 1);
|
background-color: var(--bg-table-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > table td {
|
.content :deep(table td) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > table td,
|
.content :deep(table td),
|
||||||
.problemContent .content > table th {
|
.content :deep(table th) {
|
||||||
border-right: 1px solid rgba(239, 239, 245, 1);
|
border-right: 1px solid var(--border-color-light);
|
||||||
border-bottom: 1px solid rgba(239, 239, 245, 1);
|
border-bottom: 1px solid var(--border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > table th:last-child,
|
.content :deep(table th:last-child),
|
||||||
.problemContent .content > table td:last-child {
|
.content :deep(table td:last-child) {
|
||||||
border-right: 0px solid rgba(239, 239, 245, 1);
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > table tr:last-child td {
|
.content :deep(table tr:last-child td) {
|
||||||
border-bottom: 0px solid rgba(239, 239, 245, 1);
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content > p > code {
|
.content :deep(p code) {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
margin: 0px 4px;
|
margin: 0 4px;
|
||||||
background-color: rgba(27, 31, 35, 0.05);
|
background-color: rgba(27, 31, 35, 0.05);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
font-family: "Monaco", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content img {
|
.content :deep(img) {
|
||||||
max-width: 100% !important;
|
max-width: 100%;
|
||||||
height: 100% !important;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.problemContent .content a {
|
.content :deep(a) {
|
||||||
color: #18a058;
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content :deep(a:hover) {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
89
src/oj/problem/components/ProblemEditor.vue
Normal file
89
src/oj/problem/components/ProblemEditor.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { code } from "oj/composables/code"
|
||||||
|
import { problem } from "oj/composables/problem"
|
||||||
|
import { provideSyncStatus } from "oj/composables/syncStatus"
|
||||||
|
import { SOURCES } from "utils/constants"
|
||||||
|
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
||||||
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
|
import storage from "utils/storage"
|
||||||
|
import { LANGUAGE } from "utils/types"
|
||||||
|
import Form from "./Form.vue"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
|
||||||
|
|
||||||
|
const sync = ref(false)
|
||||||
|
// 提供同步状态给子组件使用
|
||||||
|
const syncStatus = provideSyncStatus()
|
||||||
|
|
||||||
|
const contestID = route.params.contestID || null
|
||||||
|
const storageKey = computed(
|
||||||
|
() =>
|
||||||
|
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const editorHeight = computed(() =>
|
||||||
|
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedCode = storage.get(storageKey.value)
|
||||||
|
code.value =
|
||||||
|
savedCode ||
|
||||||
|
problem.value!.template[code.language] ||
|
||||||
|
SOURCES[code.language]
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeCode = (v: string) => {
|
||||||
|
storage.set(storageKey.value, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeLanguage = (v: LANGUAGE) => {
|
||||||
|
const savedCode = storage.get(storageKey.value)
|
||||||
|
code.value =
|
||||||
|
savedCode && storageKey.value.split("_").pop() === v
|
||||||
|
? savedCode
|
||||||
|
: problem.value!.template[code.language] || SOURCES[code.language]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSync = (value: boolean) => {
|
||||||
|
sync.value = value
|
||||||
|
if (!value) {
|
||||||
|
syncStatus.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSyncClosed = () => {
|
||||||
|
sync.value = false
|
||||||
|
syncStatus.reset()
|
||||||
|
formRef.value?.resetSyncStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSyncStatusChange = (status: {
|
||||||
|
otherUser?: { name: string; isSuperAdmin: boolean }
|
||||||
|
}) => {
|
||||||
|
syncStatus.setOtherUser(status.otherUser)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-flex vertical>
|
||||||
|
<Form
|
||||||
|
ref="formRef"
|
||||||
|
:storage-key="storageKey"
|
||||||
|
:is-connected="sync"
|
||||||
|
@change-language="changeLanguage"
|
||||||
|
@toggle-sync="toggleSync"
|
||||||
|
/>
|
||||||
|
<SyncCodeEditor
|
||||||
|
v-model:value="code.value"
|
||||||
|
:sync="sync"
|
||||||
|
:problem="problem!._id"
|
||||||
|
:language="code.language"
|
||||||
|
:height="editorHeight"
|
||||||
|
@update:model-value="changeCode"
|
||||||
|
@sync-closed="handleSyncClosed"
|
||||||
|
@sync-status-change="handleSyncStatusChange"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
@@ -4,8 +4,19 @@ import { problem } from "oj/composables/problem"
|
|||||||
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
||||||
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
|
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
|
||||||
import { Pie } from "vue-chartjs"
|
import { Pie } from "vue-chartjs"
|
||||||
import { getProblemBeatRate } from "~/oj/api"
|
import {
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
Chart as ChartJS,
|
||||||
|
ArcElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Colors,
|
||||||
|
} from "chart.js"
|
||||||
|
import { getProblemBeatRate } from "oj/api"
|
||||||
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
|
// 仅注册饼图所需的 Chart.js 组件
|
||||||
|
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
|
||||||
|
|
||||||
const beatRate = ref("0")
|
const beatRate = ref("0")
|
||||||
|
|
||||||
@@ -55,7 +66,7 @@ const numbers = computed(() => {
|
|||||||
{
|
{
|
||||||
icon: "streamline-emojis:sparkles",
|
icon: "streamline-emojis:sparkles",
|
||||||
title: parseFloat(beatRate.value),
|
title: parseFloat(beatRate.value),
|
||||||
content: "你击败的用户",
|
content: "击败用户",
|
||||||
int: false,
|
int: false,
|
||||||
suffix: "%",
|
suffix: "%",
|
||||||
},
|
},
|
||||||
@@ -109,7 +120,7 @@ onMounted(getBeatRate)
|
|||||||
<n-gi v-for="item in numbers" :key="item.content">
|
<n-gi v-for="item in numbers" :key="item.content">
|
||||||
<n-card hoverable>
|
<n-card hoverable>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<Icon :icon="item.icon" width="40" />
|
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
|
||||||
<div>
|
<div>
|
||||||
<n-h2 class="number">
|
<n-h2 class="number">
|
||||||
<n-number-animation
|
<n-number-animation
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { NButton } from "naive-ui"
|
import { NButton } from "naive-ui"
|
||||||
import { getSubmissions, getRankOfProblem } from "~/oj/api"
|
import { getSubmissions, getRankOfProblem } from "oj/api"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useConfigStore } from "~/shared/store/config"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||||
import { LANGUAGE_SHOW_VALUE } from "~/utils/constants"
|
import { parseTime } from "utils/functions"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { Submission } from "utils/types"
|
||||||
import { Submission } from "~/utils/types"
|
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -75,16 +73,9 @@ const query = reactive({
|
|||||||
page: 1,
|
page: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const showList = computed(() => {
|
|
||||||
if (!userStore.isAuthed) return false
|
|
||||||
else if (userStore.isSuperAdmin) return true
|
|
||||||
else return configStore.config.submission_list_show_all
|
|
||||||
})
|
|
||||||
|
|
||||||
const errorMsg = computed(() => {
|
const errorMsg = computed(() => {
|
||||||
if (!userStore.isAuthed) return "请先登录"
|
if (!userStore.isAuthed) return "请先登录"
|
||||||
else if (!configStore.config.submission_list_show_all)
|
else if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
|
||||||
return "提交列表已被管理员关闭"
|
|
||||||
else return ""
|
else return ""
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,7 +93,6 @@ async function listSubmissions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getRankOfThisProblem() {
|
async function getRankOfThisProblem() {
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const res = await getRankOfProblem(<string>route.params.problemID ?? "")
|
const res = await getRankOfProblem(<string>route.params.problemID ?? "")
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -122,19 +112,25 @@ onMounted(() => {
|
|||||||
watch(query, listSubmissions)
|
watch(query, listSubmissions)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-alert class="tip" type="error" v-if="!showList" :title="errorMsg" />
|
<n-alert
|
||||||
|
class="tip"
|
||||||
|
type="error"
|
||||||
|
v-if="!userStore.showSubmissions || !userStore.isAuthed"
|
||||||
|
:title="errorMsg"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-if="!loading && route.name === 'problem' && userStore.isAuthed">
|
<template v-if="!loading && route.name === 'problem' && userStore.isAuthed">
|
||||||
<template v-if="class_name">
|
<template v-if="class_name">
|
||||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<div>
|
<span>
|
||||||
本道题你在班上排名第 <b>{{ rank }}</b
|
本道题你在班上排名第 <b>{{ rank }}</b
|
||||||
>,你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
|
>,你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
|
||||||
</div>
|
</span>
|
||||||
<n-button
|
<n-button
|
||||||
v-if="showList"
|
secondary
|
||||||
|
v-if="userStore.showSubmissions"
|
||||||
@click="
|
@click="
|
||||||
router.push({
|
router.push({
|
||||||
name: 'submissions',
|
name: 'submissions',
|
||||||
@@ -153,16 +149,20 @@ watch(query, listSubmissions)
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-alert class="tip" type="error" :show-icon="false" v-else>
|
<n-alert
|
||||||
|
class="tip"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
v-if="rank === -1 && class_ac_count > 0"
|
||||||
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-flex>
|
<n-flex align="center">
|
||||||
<div>
|
<span>
|
||||||
本道题你还没有解决,
|
本道题你还没有解决,你们班共有
|
||||||
<div v-if="class_name">你们班</div>
|
<b>{{ class_ac_count }}</b> 人答案正确
|
||||||
共有 <b>{{ class_ac_count }}</b> 人答案正确
|
</span>
|
||||||
</div>
|
|
||||||
<n-button
|
<n-button
|
||||||
v-if="showList"
|
v-if="userStore.showSubmissions"
|
||||||
secondary
|
secondary
|
||||||
@click="
|
@click="
|
||||||
router.push({
|
router.push({
|
||||||
@@ -187,14 +187,13 @@ watch(query, listSubmissions)
|
|||||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<div>
|
<span>
|
||||||
本道题你在全服排名第 <b>{{ rank }}</b
|
本道题你在全服排名第 <b>{{ rank }}</b
|
||||||
>,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
>,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||||
</div>
|
</span>
|
||||||
<div></div>
|
|
||||||
<n-button
|
<n-button
|
||||||
secondary
|
secondary
|
||||||
v-if="showList"
|
v-if="userStore.showSubmissions"
|
||||||
@click="
|
@click="
|
||||||
router.push({
|
router.push({
|
||||||
name: 'submissions',
|
name: 'submissions',
|
||||||
@@ -212,15 +211,19 @@ watch(query, listSubmissions)
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-alert class="tip" type="error" :show-icon="false" v-else>
|
<n-alert
|
||||||
|
class="tip"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
v-if="rank === -1 && all_ac_count > 0"
|
||||||
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<div>
|
<span>
|
||||||
本道题你还没有解决,全服共有
|
本道题你还没有解决,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||||
<b>{{ all_ac_count }}</b> 人答案正确
|
</span>
|
||||||
</div>
|
|
||||||
<n-button
|
<n-button
|
||||||
v-if="showList"
|
v-if="userStore.showSubmissions"
|
||||||
secondary
|
secondary
|
||||||
@click="
|
@click="
|
||||||
router.push({
|
router.push({
|
||||||
@@ -242,8 +245,13 @@ watch(query, listSubmissions)
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="showList">
|
<template v-if="userStore.showSubmissions && userStore.isAuthed">
|
||||||
<n-data-table striped :columns="columns" :data="submissions" />
|
<n-data-table
|
||||||
|
v-if="submissions.length > 0"
|
||||||
|
striped
|
||||||
|
:columns="columns"
|
||||||
|
:data="submissions"
|
||||||
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
:total="total"
|
:total="total"
|
||||||
v-model:limit="query.limit"
|
v-model:limit="query.limit"
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import { problem } from "oj/composables/problem"
|
|||||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||||
import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions"
|
import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions"
|
||||||
import { Submission, SubmitCodePayload } from "utils/types"
|
import { Submission, SubmitCodePayload } from "utils/types"
|
||||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import {
|
||||||
|
useSubmissionWebSocket,
|
||||||
|
type SubmissionUpdate,
|
||||||
|
} from "shared/composables/websocket"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
const ProblemComment = defineAsyncComponent(
|
const ProblemComment = defineAsyncComponent(
|
||||||
() => import("./ProblemComment.vue"),
|
() => import("./ProblemComment.vue"),
|
||||||
@@ -39,7 +43,7 @@ const { start: showCommentPanel } = useTimeoutFn(
|
|||||||
{ immediate: false },
|
{ immediate: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
const { start: fetchSubmission } = useTimeoutFn(
|
const { start: fetchSubmission, stop: stopFetchSubmission } = useTimeoutFn(
|
||||||
async () => {
|
async () => {
|
||||||
const res = await getSubmission(submissionId.value)
|
const res = await getSubmission(submissionId.value)
|
||||||
submission.value = res.data
|
submission.value = res.data
|
||||||
@@ -57,45 +61,76 @@ const { start: fetchSubmission } = useTimeoutFn(
|
|||||||
{ immediate: false },
|
{ immediate: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WebSocket 消息处理器
|
||||||
|
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
||||||
|
console.log("[Submit] 收到提交更新:", data)
|
||||||
|
|
||||||
|
if (data.submission_id !== submissionId.value) {
|
||||||
|
console.log(`[Submit] 提交ID不匹配: 期望=${submissionId.value}, 实际=${data.submission_id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!submission.value) {
|
||||||
|
submission.value = {} as Submission
|
||||||
|
}
|
||||||
|
|
||||||
|
submission.value.result = data.result as Submission["result"]
|
||||||
|
|
||||||
|
// 判题完成,获取完整提交详情
|
||||||
|
if (data.status === "finished") {
|
||||||
|
console.log("[Submit] 判题完成,获取详细信息")
|
||||||
|
getSubmission(submissionId.value).then((res) => {
|
||||||
|
submission.value = res.data
|
||||||
|
submitted.value = false
|
||||||
|
|
||||||
|
// 判题完成后,15 分钟无新提交则断开 WebSocket 连接
|
||||||
|
// 考虑到学生换题间隔约 10 分钟,15 分钟可覆盖大部分场景
|
||||||
|
scheduleDisconnect(15 * 60 * 1000)
|
||||||
|
})
|
||||||
|
} else if (data.status === "error") {
|
||||||
|
console.log("[Submit] 判题出错")
|
||||||
|
submitted.value = false
|
||||||
|
// 判题出错后,15 分钟无新提交则断开 WebSocket 连接
|
||||||
|
scheduleDisconnect(15 * 60 * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 WebSocket(按需连接模式)
|
||||||
|
const {
|
||||||
|
connect,
|
||||||
|
subscribe,
|
||||||
|
scheduleDisconnect,
|
||||||
|
cancelScheduledDisconnect,
|
||||||
|
status: wsStatus,
|
||||||
|
} = useSubmissionWebSocket(handleSubmissionUpdate)
|
||||||
|
|
||||||
|
// 组件卸载时停止轮询
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopFetchSubmission()
|
||||||
|
})
|
||||||
|
|
||||||
const judging = computed(
|
const judging = computed(
|
||||||
() =>
|
() => submission.value?.result === SubmissionStatus.judging,
|
||||||
!!(
|
|
||||||
submission.value && submission.value.result === SubmissionStatus.judging
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const pending = computed(
|
const pending = computed(
|
||||||
() =>
|
() => submission.value?.result === SubmissionStatus.pending,
|
||||||
!!(
|
|
||||||
submission.value && submission.value.result === SubmissionStatus.pending
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitting = computed(
|
const submitting = computed(
|
||||||
() =>
|
() => submission.value?.result === SubmissionStatus.submitting,
|
||||||
!!(
|
|
||||||
submission.value &&
|
|
||||||
submission.value.result === SubmissionStatus.submitting
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitDisabled = computed(() => {
|
const submitDisabled = computed(() => {
|
||||||
if (!userStore.isAuthed) {
|
return (
|
||||||
return true
|
!userStore.isAuthed ||
|
||||||
}
|
code.value.trim() === "" ||
|
||||||
if (code.value.trim() === "") {
|
judging.value ||
|
||||||
return true
|
pending.value ||
|
||||||
}
|
submitting.value ||
|
||||||
if (judging.value || pending.value || submitting.value) {
|
submitted.value ||
|
||||||
return true
|
isPending.value
|
||||||
}
|
)
|
||||||
if (submitted.value) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (isPending.value) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitLabel = computed(() => {
|
const submitLabel = computed(() => {
|
||||||
@@ -115,43 +150,39 @@ const submitLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const msg = computed(() => {
|
const msg = computed(() => {
|
||||||
|
if (!submission.value) return ""
|
||||||
|
|
||||||
let msg = ""
|
let msg = ""
|
||||||
const result = submission.value && submission.value.result
|
const result = submission.value.result
|
||||||
|
|
||||||
if (
|
if (
|
||||||
result === SubmissionStatus.compile_error ||
|
result === SubmissionStatus.compile_error ||
|
||||||
result === SubmissionStatus.runtime_error
|
result === SubmissionStatus.runtime_error
|
||||||
) {
|
) {
|
||||||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
submission.value &&
|
if (submission.value.statistic_info?.err_info) {
|
||||||
submission.value.statistic_info &&
|
|
||||||
submission.value.statistic_info.err_info
|
|
||||||
) {
|
|
||||||
msg += submission.value.statistic_info.err_info
|
msg += submission.value.statistic_info.err_info
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
})
|
})
|
||||||
|
|
||||||
const infoTable = computed(() => {
|
const infoTable = computed(() => {
|
||||||
|
if (!submission.value?.info?.data?.length) return []
|
||||||
|
|
||||||
|
const result = submission.value.result
|
||||||
if (
|
if (
|
||||||
submission.value &&
|
result === SubmissionStatus.accepted ||
|
||||||
submission.value.result !== SubmissionStatus.accepted &&
|
result === SubmissionStatus.compile_error ||
|
||||||
submission.value.result !== SubmissionStatus.compile_error &&
|
result === SubmissionStatus.runtime_error
|
||||||
submission.value.result !== SubmissionStatus.runtime_error &&
|
|
||||||
submission.value.info &&
|
|
||||||
submission.value.info.data &&
|
|
||||||
submission.value.info.data.length
|
|
||||||
) {
|
) {
|
||||||
const data = submission.value.info.data
|
|
||||||
if (data.some((item) => item.result === 0)) {
|
|
||||||
return submission.value.info.data
|
|
||||||
} else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = submission.value.info.data
|
||||||
|
return data.some((item) => item.result === 0) ? data : []
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
||||||
@@ -175,9 +206,8 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!userStore.isAuthed) {
|
if (!userStore.isAuthed) return
|
||||||
return
|
|
||||||
}
|
|
||||||
const data: SubmitCodePayload = {
|
const data: SubmitCodePayload = {
|
||||||
problem_id: problem.value!.id,
|
problem_id: problem.value!.id,
|
||||||
language: code.language,
|
language: code.language,
|
||||||
@@ -186,35 +216,73 @@ async function submit() {
|
|||||||
if (contestID) {
|
if (contestID) {
|
||||||
data.contest_id = parseInt(contestID)
|
data.contest_id = parseInt(contestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
submission.value = { result: 9 } as Submission
|
submission.value = { result: 9 } as Submission
|
||||||
const res = await submitCode(data)
|
const res = await submitCode(data)
|
||||||
submissionId.value = res.data.submission_id
|
submissionId.value = res.data.submission_id
|
||||||
// 防止重复提交
|
console.log(`[Submit] 代码已提交: ID=${submissionId.value}`)
|
||||||
submitPending()
|
submitPending()
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
// 查询结果
|
|
||||||
fetchSubmission()
|
// 取消之前安排的断开倒计时(如果有新提交)
|
||||||
|
cancelScheduledDisconnect()
|
||||||
|
|
||||||
|
// 按需连接 WebSocket
|
||||||
|
if (wsStatus.value !== "connected") {
|
||||||
|
console.log(`[Submit] WebSocket 未连接,正在连接... 当前状态: ${wsStatus.value}`)
|
||||||
|
connect()
|
||||||
|
} else {
|
||||||
|
console.log("[Submit] WebSocket 已连接,直接订阅")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用 WebSocket 实时更新
|
||||||
|
if (wsStatus.value === "connected" || wsStatus.value === "connecting") {
|
||||||
|
// 等待连接完成后订阅
|
||||||
|
const checkConnection = setInterval(() => {
|
||||||
|
if (wsStatus.value === "connected") {
|
||||||
|
clearInterval(checkConnection)
|
||||||
|
console.log(`[Submit] 订阅提交更新: ID=${submissionId.value}`)
|
||||||
|
subscribe(submissionId.value)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
// 5 秒后如果还在判题中,降级到轮询作为保险
|
||||||
|
// 考虑到判题一般 1-3 秒完成,5 秒足以覆盖绝大部分情况
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkConnection)
|
||||||
|
if (
|
||||||
|
submission.value &&
|
||||||
|
(submission.value.result === SubmissionStatus.judging ||
|
||||||
|
submission.value.result === SubmissionStatus.pending ||
|
||||||
|
submission.value.result === 9)
|
||||||
|
) {
|
||||||
|
console.log("WebSocket 未及时响应,降级到轮询模式")
|
||||||
|
fetchSubmission()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
} else {
|
||||||
|
// WebSocket 连接失败,直接使用轮询
|
||||||
|
fetchSubmission()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => submission?.value?.result,
|
() => submission.value?.result,
|
||||||
(result) => {
|
(result) => {
|
||||||
if (result === SubmissionStatus.accepted) {
|
if (result !== SubmissionStatus.accepted) return
|
||||||
// 刷新题目状态
|
|
||||||
problem.value!.my_status = 0
|
// 刷新题目状态
|
||||||
// 放烟花
|
problem.value!.my_status = 0
|
||||||
confetti({
|
// 放烟花
|
||||||
particleCount: 300,
|
confetti({
|
||||||
startVelocity: 30,
|
particleCount: 300,
|
||||||
gravity: 0.5,
|
startVelocity: 30,
|
||||||
spread: 350,
|
gravity: 0.5,
|
||||||
origin: { x: 0.5, y: 0.4 },
|
spread: 350,
|
||||||
})
|
origin: { x: 0.5, y: 0.4 },
|
||||||
// 题目在第一次完成之后,弹出点评框
|
})
|
||||||
if (!contestID) {
|
// 题目在第一次完成之后,弹出点评框
|
||||||
showCommentPanel()
|
if (!contestID) showCommentPanel()
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getProblem } from "oj/api"
|
import { getProblem } from "oj/api"
|
||||||
import { ScreenMode } from "utils/constants"
|
import { ScreenMode } from "utils/constants"
|
||||||
import { isDesktop, isMobile } from "~/shared/composables/breakpoints"
|
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||||
import {
|
import {
|
||||||
bothAndProblem,
|
bothAndProblem,
|
||||||
resetScreenMode,
|
resetScreenMode,
|
||||||
screenMode,
|
screenMode,
|
||||||
} from "~/shared/composables/switchScreen"
|
} from "shared/composables/switchScreen"
|
||||||
import { problem } from "../composables/problem"
|
import { problem } from "../composables/problem"
|
||||||
|
|
||||||
const Editor = defineAsyncComponent(() => import("./components/Editor.vue"))
|
const ProblemEditor = defineAsyncComponent(
|
||||||
const EditorWithTest = defineAsyncComponent(
|
() => import("./components/ProblemEditor.vue"),
|
||||||
() => import("./components/EditorWithTest.vue"),
|
)
|
||||||
|
const ContestEditor = defineAsyncComponent(
|
||||||
|
() => import("./components/ContestEditor.vue"),
|
||||||
|
)
|
||||||
|
const EditorForTest = defineAsyncComponent(
|
||||||
|
() => import("./components/EditorForTest.vue"),
|
||||||
)
|
)
|
||||||
const ProblemContent = defineAsyncComponent(
|
const ProblemContent = defineAsyncComponent(
|
||||||
() => import("./components/ProblemContent.vue"),
|
() => import("./components/ProblemContent.vue"),
|
||||||
@@ -54,6 +59,8 @@ const tabOptions = computed(() => {
|
|||||||
|
|
||||||
const currentTab = ref("content")
|
const currentTab = ref("content")
|
||||||
|
|
||||||
|
const shouldUseProblemEditor = computed(() => route.name === "problem")
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => route.query.tab, () => tabOptions.value],
|
[() => route.query.tab, () => tabOptions.value],
|
||||||
([rawTab]) => {
|
([rawTab]) => {
|
||||||
@@ -125,7 +132,8 @@ watch(isMobile, (value) => {
|
|||||||
<ProblemContent />
|
<ProblemContent />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="editor" tab="编辑">
|
<n-tab-pane name="editor" tab="编辑">
|
||||||
<Editor />
|
<ProblemEditor v-if="shouldUseProblemEditor" />
|
||||||
|
<ContestEditor v-else />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="info" tab="统计">
|
<n-tab-pane name="info" tab="统计">
|
||||||
<ProblemInfo />
|
<ProblemInfo />
|
||||||
@@ -139,10 +147,11 @@ watch(isMobile, (value) => {
|
|||||||
</n-tabs>
|
</n-tabs>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi v-if="isDesktop && screenMode === ScreenMode.both">
|
<n-gi v-if="isDesktop && screenMode === ScreenMode.both">
|
||||||
<Editor />
|
<ProblemEditor v-if="shouldUseProblemEditor" />
|
||||||
|
<ContestEditor v-else />
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi v-if="isDesktop && screenMode === ScreenMode.code">
|
<n-gi v-if="isDesktop && screenMode === ScreenMode.code">
|
||||||
<EditorWithTest />
|
<EditorForTest />
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
<n-empty v-else :description="errMsg"></n-empty>
|
<n-empty v-else :description="errMsg"></n-empty>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { NSpace, NTag } from "naive-ui"
|
import { NSpace, NTag } from "naive-ui"
|
||||||
import { getProblemList } from "oj/api"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
|
import { getProblemList, getRandomProblemID } from "oj/api"
|
||||||
import { getTagColor } from "utils/functions"
|
import { getTagColor } from "utils/functions"
|
||||||
import { ProblemFiltered } from "utils/types"
|
import { ProblemFiltered } from "utils/types"
|
||||||
import { getProblemTagList } from "~/shared/api"
|
import { getProblemTagList } from "shared/api"
|
||||||
import Hitokoto from "~/shared/components/Hitokoto.vue"
|
import Hitokoto from "shared/components/Hitokoto.vue"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { usePagination } from "~/shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import ProblemStatus from "./components/ProblemStatus.vue"
|
import ProblemStatus from "./components/ProblemStatus.vue"
|
||||||
|
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||||
|
|
||||||
interface Tag {
|
interface Tag {
|
||||||
id: number
|
id: number
|
||||||
@@ -23,6 +25,7 @@ interface ProblemQuery {
|
|||||||
keyword: string
|
keyword: string
|
||||||
difficulty: string
|
difficulty: string
|
||||||
tag: string
|
tag: string
|
||||||
|
author: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const difficultyOptions = [
|
const difficultyOptions = [
|
||||||
@@ -42,9 +45,10 @@ const [showTag, toggleShowTag] = useToggle(isDesktop.value)
|
|||||||
|
|
||||||
// 使用分页 composable
|
// 使用分页 composable
|
||||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||||
keyword: "",
|
keyword: useRouteQuery("keyword", "").value,
|
||||||
difficulty: "",
|
difficulty: useRouteQuery("difficulty", "").value,
|
||||||
tag: "",
|
tag: useRouteQuery("tag", "").value,
|
||||||
|
author: useRouteQuery("author", "").value,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function listProblems() {
|
async function listProblems() {
|
||||||
@@ -54,6 +58,7 @@ async function listProblems() {
|
|||||||
keyword: query.keyword,
|
keyword: query.keyword,
|
||||||
tag: query.tag,
|
tag: query.tag,
|
||||||
difficulty: query.difficulty,
|
difficulty: query.difficulty,
|
||||||
|
author: query.author,
|
||||||
})
|
})
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
problems.value = res.results
|
problems.value = res.results
|
||||||
@@ -67,11 +72,6 @@ async function listTags() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function search(value: string) {
|
|
||||||
query.keyword = value
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseTag(tag: Tag) {
|
function chooseTag(tag: Tag) {
|
||||||
query.tag = tag.checked ? "" : tag.name
|
query.tag = tag.checked ? "" : tag.name
|
||||||
tags.value = tags.value.map((t) => {
|
tags.value = tags.value.map((t) => {
|
||||||
@@ -84,26 +84,21 @@ function chooseTag(tag: Tag) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
async function getRandom() {
|
||||||
clearQuery()
|
const res = await getRandomProblemID()
|
||||||
|
router.push("/problem/" + res.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// async function getRandom() {
|
|
||||||
// const res = await getRandomProblemID()
|
|
||||||
// router.push("/problem/" + res.data)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 监听搜索关键词变化(防抖)
|
// 监听搜索关键词变化(防抖)
|
||||||
watchDebounced(
|
watchDebounced(() => query.keyword, listProblems, {
|
||||||
() => query.keyword,
|
debounce: 500,
|
||||||
listProblems,
|
maxWait: 1000,
|
||||||
{ debounce: 500, maxWait: 1000 }
|
})
|
||||||
)
|
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
// 监听其他查询条件变化
|
||||||
watch(
|
watch(
|
||||||
() => [query.tag, query.difficulty, query.limit, query.page],
|
() => [query.tag, query.difficulty, query.limit, query.page, query.author],
|
||||||
listProblems
|
listProblems,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 监听标签变化,更新标签选中状态
|
// 监听标签变化,更新标签选中状态
|
||||||
@@ -117,14 +112,13 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 监听用户认证状态变化,只在认证完成且已登录时刷新问题列表
|
|
||||||
watch(
|
watch(
|
||||||
() => userStore.isFinished && userStore.isAuthed,
|
() => userStore.isFinished && userStore.isAuthed,
|
||||||
(isAuthenticatedAndFinished) => {
|
(isAuthenticatedAndFinished) => {
|
||||||
if (isAuthenticatedAndFinished) {
|
if (isAuthenticatedAndFinished) {
|
||||||
listProblems()
|
listProblems()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -202,45 +196,49 @@ function rowProps(row: ProblemFiltered) {
|
|||||||
<template>
|
<template>
|
||||||
<n-flex vertical size="large">
|
<n-flex vertical size="large">
|
||||||
<n-flex justify="space-between">
|
<n-flex justify="space-between">
|
||||||
<n-flex>
|
<n-space>
|
||||||
<div>
|
<n-form :show-feedback="false" inline label-placement="left">
|
||||||
<n-form :show-feedback="false" inline label-placement="left">
|
<n-form-item label="难度">
|
||||||
<n-form-item label="题目难度">
|
<n-select
|
||||||
<n-select
|
style="width: 120px"
|
||||||
style="width: 120px"
|
v-model:value="query.difficulty"
|
||||||
v-model:value="query.difficulty"
|
:options="difficultyOptions"
|
||||||
:options="difficultyOptions"
|
/>
|
||||||
/>
|
</n-form-item>
|
||||||
</n-form-item>
|
<n-form-item label="出题者">
|
||||||
<n-form-item>
|
<AuthorSelect v-model:value="query.author" />
|
||||||
<n-input
|
</n-form-item>
|
||||||
clearable
|
</n-form>
|
||||||
style="width: 200px"
|
<n-form :show-feedback="false" inline label-placement="left">
|
||||||
v-model:value="query.keyword"
|
<n-form-item>
|
||||||
placeholder="题号或者标题"
|
<n-input
|
||||||
/>
|
clearable
|
||||||
</n-form-item>
|
style="width: 200px"
|
||||||
</n-form>
|
v-model:value="query.keyword"
|
||||||
</div>
|
placeholder="编号或者标题"
|
||||||
<div>
|
/>
|
||||||
<n-form :show-feedback="false" inline label-placement="left">
|
</n-form-item>
|
||||||
<n-form-item>
|
<n-form-item>
|
||||||
<n-flex align="center">
|
<n-button @click="clearQuery" quaternary>重置</n-button>
|
||||||
<n-button @click="search(query.keyword)">搜索</n-button>
|
</n-form-item>
|
||||||
<n-button @click="clear" quaternary>重置</n-button>
|
<!-- <n-form-item>
|
||||||
<!-- <n-button @click="getRandom" quaternary>试试手气</n-button> -->
|
<n-button @click="getRandom" quaternary>随机</n-button>
|
||||||
</n-flex>
|
</n-form-item> -->
|
||||||
</n-form-item>
|
<n-form-item>
|
||||||
</n-form>
|
<n-button
|
||||||
</div>
|
@click="toggleShowTag()"
|
||||||
<n-button @click="toggleShowTag()" quaternary icon-placement="right">
|
quaternary
|
||||||
<template #icon>
|
icon-placement="right"
|
||||||
<Icon v-if="showTag" icon="ph:caret-down"></Icon>
|
>
|
||||||
<Icon v-else icon="ph:caret-up"></Icon>
|
<template #icon>
|
||||||
</template>
|
<Icon v-if="showTag" icon="ph:caret-down"></Icon>
|
||||||
标签
|
<Icon v-else icon="ph:caret-up"></Icon>
|
||||||
</n-button>
|
</template>
|
||||||
</n-flex>
|
标签
|
||||||
|
</n-button>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-space>
|
||||||
<Hitokoto v-if="isDesktop" />
|
<Hitokoto v-if="isDesktop" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-collapse-transition :show="showTag">
|
<n-collapse-transition :show="showTag">
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Bar } from "vue-chartjs"
|
import { Bar } from "vue-chartjs"
|
||||||
import { ChartType } from "~/utils/constants"
|
import {
|
||||||
import { Rank } from "~/utils/types"
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Colors,
|
||||||
|
} from "chart.js"
|
||||||
|
import { ChartType } from "utils/constants"
|
||||||
|
import { Rank } from "utils/types"
|
||||||
|
|
||||||
|
// 仅注册柱状图所需的 Chart.js 组件
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Colors,
|
||||||
|
)
|
||||||
|
|
||||||
const props = defineProps<{ rankData: Rank[]; type: ChartType }>()
|
const props = defineProps<{ rankData: Rank[]; type: ChartType }>()
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { NButton } from "naive-ui"
|
|||||||
import { getActivityRank, getRank } from "oj/api"
|
import { getActivityRank, getRank } from "oj/api"
|
||||||
import { getACRate } from "utils/functions"
|
import { getACRate } from "utils/functions"
|
||||||
import { Rank } from "utils/types"
|
import { Rank } from "utils/types"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { ChartType } from "~/utils/constants"
|
import { ChartType } from "utils/constants"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import Chart from "./components/Chart.vue"
|
import Chart from "./components/Chart.vue"
|
||||||
import Index from "./components/Index.vue"
|
import Index from "./components/Index.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { DetailsData, WeeklyData } from "~/utils/types"
|
import { DetailsData, DurationData } from "utils/types"
|
||||||
import { consumeJSONEventStream } from "~/utils/stream"
|
import { consumeJSONEventStream } from "utils/stream"
|
||||||
import { getAIDetailData, getAIWeeklyData } from "../api"
|
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api"
|
||||||
import { getCSRFToken } from "~/utils/functions"
|
import { getCSRFToken } from "utils/functions"
|
||||||
|
|
||||||
export const useAIStore = defineStore("ai", () => {
|
export const useAIStore = defineStore("ai", () => {
|
||||||
const duration = ref("months:6")
|
const duration = ref("months:6")
|
||||||
const weeklyData = ref<WeeklyData[]>([])
|
const durationData = ref<DurationData[]>([])
|
||||||
const detailsData = reactive<DetailsData>({
|
const detailsData = reactive<DetailsData>({
|
||||||
start: "",
|
start: "",
|
||||||
end: "",
|
end: "",
|
||||||
@@ -16,20 +16,17 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
contest_count: 0,
|
contest_count: 0,
|
||||||
solved: [],
|
solved: [],
|
||||||
})
|
})
|
||||||
|
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||||
|
|
||||||
const loading = reactive({
|
const loading = reactive({
|
||||||
details: false,
|
fetching: false, // 合并 details 和 duration 的 loading
|
||||||
weekly: false,
|
|
||||||
ai: false,
|
ai: false,
|
||||||
|
heatmap: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mdContent = ref("")
|
const mdContent = ref("")
|
||||||
|
|
||||||
async function fetchDetailsData(
|
async function fetchDetailsData(start: string, end: string) {
|
||||||
start: string,
|
|
||||||
end: string,
|
|
||||||
) {
|
|
||||||
loading.details = true
|
|
||||||
const res = await getAIDetailData(start, end)
|
const res = await getAIDetailData(start, end)
|
||||||
detailsData.start = res.data.start
|
detailsData.start = res.data.start
|
||||||
detailsData.end = res.data.end
|
detailsData.end = res.data.end
|
||||||
@@ -39,17 +36,34 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
detailsData.tags = res.data.tags
|
detailsData.tags = res.data.tags
|
||||||
detailsData.difficulty = res.data.difficulty
|
detailsData.difficulty = res.data.difficulty
|
||||||
detailsData.contest_count = res.data.contest_count
|
detailsData.contest_count = res.data.contest_count
|
||||||
loading.details = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWeeklyData(
|
async function fetchDurationData(end: string, duration: string) {
|
||||||
|
const res = await getAIDurationData(end, duration)
|
||||||
|
durationData.value = res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHeatmapData() {
|
||||||
|
loading.heatmap = true
|
||||||
|
const res = await getAIHeatmapData()
|
||||||
|
heatmapData.value = res.data
|
||||||
|
loading.heatmap = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAnalysisData(
|
||||||
|
start: string,
|
||||||
end: string,
|
end: string,
|
||||||
duration: string,
|
duration: string,
|
||||||
) {
|
) {
|
||||||
loading.weekly = true
|
loading.fetching = true
|
||||||
const res = await getAIWeeklyData(end, duration)
|
try {
|
||||||
weeklyData.value = res.data
|
await Promise.all([
|
||||||
loading.weekly = false
|
fetchDetailsData(start, end),
|
||||||
|
fetchDurationData(end, duration),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
loading.fetching = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let aiController: AbortController | null = null
|
let aiController: AbortController | null = null
|
||||||
@@ -78,7 +92,7 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
details: detailsData,
|
details: detailsData,
|
||||||
weekly: weeklyData.value,
|
duration: durationData.value,
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@@ -132,11 +146,12 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchWeeklyData,
|
fetchAnalysisData,
|
||||||
fetchDetailsData,
|
fetchHeatmapData,
|
||||||
fetchAIAnalysis,
|
fetchAIAnalysis,
|
||||||
weeklyData,
|
durationData,
|
||||||
detailsData,
|
detailsData,
|
||||||
|
heatmapData,
|
||||||
duration,
|
duration,
|
||||||
loading,
|
loading,
|
||||||
mdContent,
|
mdContent,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { formatISO, getTime, parseISO } from "date-fns"
|
import { formatISO, getTime, parseISO } from "date-fns"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { ContestStatus, ContestType } from "~/utils/constants"
|
import { ContestStatus, ContestType } from "utils/constants"
|
||||||
import { duration } from "~/utils/functions"
|
import { duration } from "utils/functions"
|
||||||
import { Contest, Problem } from "~/utils/types"
|
import { Contest, Problem } from "utils/types"
|
||||||
import {
|
import {
|
||||||
checkContestPassword,
|
checkContestPassword,
|
||||||
getContest,
|
getContest,
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
|
|
||||||
defineEmits(["click", "search"])
|
interface Props {
|
||||||
|
type: "题目" | "用户"
|
||||||
|
username?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emits = defineEmits(["click", "search", "filterClass"])
|
||||||
|
|
||||||
|
const showFilterClass = computed(() => {
|
||||||
|
return props.type === "用户" && props.username?.startsWith("ks")
|
||||||
|
})
|
||||||
|
|
||||||
|
function filterClass() {
|
||||||
|
const match = props.username!.match(/^ks\d{3,4}/)
|
||||||
|
const classname = match ? match[0] : ""
|
||||||
|
if (!classname) return
|
||||||
|
emits("filterClass", classname)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-button text type="info" @click="$emit('click')"><slot></slot></n-button>
|
<n-button text type="info" @click="$emit('click')"><slot></slot></n-button>
|
||||||
<n-button text @click="$emit('search')">
|
<n-tooltip>
|
||||||
<template #icon>
|
<template #trigger>
|
||||||
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
|
<n-button text @click="$emit('search')">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-button>
|
{{ "搜索" + props.type }}
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="showFilterClass">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button text @click="filterClass">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="openmoji:filter"></Icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
筛选班级
|
||||||
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-flex size="large" vertical>
|
|
||||||
<n-form :show-feedback="false" inline label-placement="left">
|
|
||||||
<n-form-item>
|
|
||||||
<n-input
|
|
||||||
placeholder="用户(可选)"
|
|
||||||
v-model:value="query.username"
|
|
||||||
style="width: 200px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item>
|
|
||||||
<n-input
|
|
||||||
placeholder="题号(可选)"
|
|
||||||
v-model:value="query.problem"
|
|
||||||
style="width: 200px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item>
|
|
||||||
<n-select
|
|
||||||
style="width: 120px"
|
|
||||||
v-model:value="query.duration"
|
|
||||||
:options="options"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item>
|
|
||||||
<n-button type="primary" @click="handleStatistics">统计</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<n-space v-if="count.total > 0" size="large">
|
|
||||||
<n-h1 style="margin-bottom: 0">
|
|
||||||
<n-gradient-text type="primary">
|
|
||||||
正确提交数:{{ count.accepted }}
|
|
||||||
</n-gradient-text>
|
|
||||||
</n-h1>
|
|
||||||
<n-h1 style="margin-bottom: 0">
|
|
||||||
<n-gradient-text type="info">
|
|
||||||
总提交数:{{ count.total }}
|
|
||||||
</n-gradient-text>
|
|
||||||
</n-h1>
|
|
||||||
<n-h1 style="margin-bottom: 0">
|
|
||||||
<n-gradient-text type="warning">
|
|
||||||
正确率:{{ count.rate }}
|
|
||||||
</n-gradient-text>
|
|
||||||
</n-h1>
|
|
||||||
</n-space>
|
|
||||||
<n-space v-if="count.total > 0" size="large">
|
|
||||||
<n-h1>
|
|
||||||
<n-gradient-text type="error">
|
|
||||||
回答正确的人数:{{ list.length }}
|
|
||||||
</n-gradient-text>
|
|
||||||
</n-h1>
|
|
||||||
<n-h1 v-if="person.count > 0">
|
|
||||||
<n-gradient-text type="warning">
|
|
||||||
班级人数:{{ person.count }}
|
|
||||||
</n-gradient-text>
|
|
||||||
</n-h1>
|
|
||||||
<n-h1 v-if="person.count > 0">
|
|
||||||
<n-gradient-text type="success">
|
|
||||||
班级完成度:{{ person.rate }}
|
|
||||||
</n-gradient-text>
|
|
||||||
</n-h1>
|
|
||||||
<n-flex v-if="listUnaccepted.length > 0" style="margin-top: 8px">
|
|
||||||
<n-button type="warning" @click="toggleUnaccepted(!unaccepted)">
|
|
||||||
{{ unaccepted ? "隐藏没有完成的" : "显示没有完成的" }}
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-space>
|
|
||||||
<n-h1 v-if="count.total === 0">
|
|
||||||
<n-gradient-text type="primary">暂无数据统计</n-gradient-text>
|
|
||||||
</n-h1>
|
|
||||||
<n-space v-if="unaccepted" size="large">
|
|
||||||
<n-h1>这{{ listUnaccepted.length }}位没有完成:</n-h1>
|
|
||||||
<n-h1 v-for="name in listUnaccepted" :key="name">
|
|
||||||
{{ name }}
|
|
||||||
</n-h1>
|
|
||||||
<n-text v-if="message">{{ message }}</n-text>
|
|
||||||
</n-space>
|
|
||||||
<n-data-table v-if="list.length" striped :columns="columns" :data="list" />
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { formatISO, sub, type Duration } from "date-fns"
|
|
||||||
import { getSubmissionStatistics } from "oj/api"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
problem: string
|
|
||||||
username: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const options: SelectOption[] = [
|
|
||||||
{ label: "10分钟内", value: "minutes:10" },
|
|
||||||
{ label: "20分钟内", value: "minutes:20" },
|
|
||||||
{ label: "30分钟内", value: "minutes:30" },
|
|
||||||
{ label: "本节课内", value: "hours:1" },
|
|
||||||
{ label: "两小时内", value: "hours:2" },
|
|
||||||
{ label: "一天内", value: "days:1" },
|
|
||||||
{ label: "一周内", value: "weeks:1" },
|
|
||||||
{ label: "一个月内", value: "months:1" },
|
|
||||||
{ label: "一年内", value: "years:1" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
|
||||||
{ title: "用户", key: "username" },
|
|
||||||
{ title: "提交数", key: "submission_count" },
|
|
||||||
{ title: "已解决", key: "accepted_count" },
|
|
||||||
{ title: "正确率", key: "correct_rate" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const query = reactive({
|
|
||||||
username: props.username,
|
|
||||||
problem: props.problem,
|
|
||||||
duration: options[0].value,
|
|
||||||
})
|
|
||||||
|
|
||||||
const count = reactive({
|
|
||||||
total: 0,
|
|
||||||
accepted: 0,
|
|
||||||
rate: 0,
|
|
||||||
})
|
|
||||||
const person = reactive({
|
|
||||||
count: 0,
|
|
||||||
rate: 0,
|
|
||||||
})
|
|
||||||
const list = ref([])
|
|
||||||
const listUnaccepted = ref([])
|
|
||||||
const [unaccepted, toggleUnaccepted] = useToggle()
|
|
||||||
const message = ref("")
|
|
||||||
|
|
||||||
const subOptions = computed<Duration>(() => {
|
|
||||||
let dur = options.find((it) => it.value === query.duration) ?? options[0]
|
|
||||||
const x = dur.value!.toString().split(":")
|
|
||||||
const unit = x[0]
|
|
||||||
const n = x[1]
|
|
||||||
return { [unit]: parseInt(n) }
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleStatistics() {
|
|
||||||
const current = Date.now()
|
|
||||||
const end = formatISO(current)
|
|
||||||
const start = formatISO(sub(current, subOptions.value))
|
|
||||||
const res = await getSubmissionStatistics(
|
|
||||||
{ start, end },
|
|
||||||
query.problem,
|
|
||||||
query.username,
|
|
||||||
)
|
|
||||||
count.total = res.data.submission_count
|
|
||||||
count.accepted = res.data.accepted_count
|
|
||||||
count.rate = res.data.correct_rate
|
|
||||||
list.value = res.data.data
|
|
||||||
listUnaccepted.value = res.data.data_unaccepted
|
|
||||||
person.count = res.data.person_count
|
|
||||||
person.rate = res.data.person_rate
|
|
||||||
|
|
||||||
toggleUnaccepted(false)
|
|
||||||
message.value = ""
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -3,11 +3,16 @@
|
|||||||
<n-button text type="info" @click="$emit('showCode')">
|
<n-button text type="info" @click="$emit('showCode')">
|
||||||
{{ props.submission.id.slice(0, 12) }}
|
{{ props.submission.id.slice(0, 12) }}
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button text @click="goto">
|
<n-tooltip>
|
||||||
<template #icon>
|
<template #trigger>
|
||||||
<Icon icon="streamline-emojis:backhand-index-pointing-right-1"></Icon>
|
<n-button text @click="goto">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="catppuccin:folder-debug"></Icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-button>
|
查看测试详情
|
||||||
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ props.submission.id.slice(0, 12) }}
|
{{ props.submission.id.slice(0, 12) }}
|
||||||
@@ -16,7 +21,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { SubmissionListItem } from "~/utils/types"
|
import { SubmissionListItem } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
submission: SubmissionListItem
|
submission: SubmissionListItem
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import qs from "query-string"
|
|
||||||
import { getSubmission } from "oj/api"
|
import { getSubmission } from "oj/api"
|
||||||
import {
|
import {
|
||||||
JUDGE_STATUS,
|
JUDGE_STATUS,
|
||||||
@@ -11,10 +10,11 @@ import {
|
|||||||
submissionMemoryFormat,
|
submissionMemoryFormat,
|
||||||
submissionTimeFormat,
|
submissionTimeFormat,
|
||||||
utoa,
|
utoa,
|
||||||
|
copyToClipboard,
|
||||||
} from "utils/functions"
|
} from "utils/functions"
|
||||||
import { Submission } from "utils/types"
|
import { Submission } from "utils/types"
|
||||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { isDesktop, isMobile } from "~/shared/composables/breakpoints"
|
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
submissionID: string
|
submissionID: string
|
||||||
@@ -24,6 +24,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
const submission = ref<Submission>()
|
const submission = ref<Submission>()
|
||||||
|
|
||||||
@@ -61,23 +62,38 @@ function copyToCat() {
|
|||||||
input: "",
|
input: "",
|
||||||
}
|
}
|
||||||
const base64 = utoa(JSON.stringify(data))
|
const base64 = utoa(JSON.stringify(data))
|
||||||
const url = qs.stringifyUrl({
|
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
|
||||||
url: import.meta.env.PUBLIC_CODE_URL,
|
|
||||||
query: {
|
|
||||||
share: base64,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
window.open(url, "_blank")
|
window.open(url, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToProblem() {
|
async function copyToProblem() {
|
||||||
router.push({
|
const success = await copyToClipboard(submission.value!.code)
|
||||||
name: "problem",
|
if (success) {
|
||||||
params: {
|
message.success("代码复制成功")
|
||||||
contestID: submission.value!.contest,
|
} else {
|
||||||
problemID: props.problemID,
|
message.error("代码复制失败")
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
// 判断是否是竞赛题目
|
||||||
|
const contestID = submission.value!.contest
|
||||||
|
if (contestID) {
|
||||||
|
// 竞赛题目
|
||||||
|
router.push({
|
||||||
|
name: "contest problem",
|
||||||
|
params: {
|
||||||
|
contestID: String(contestID),
|
||||||
|
problemID: props.problemID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 普通题目
|
||||||
|
router.push({
|
||||||
|
name: "problem",
|
||||||
|
params: {
|
||||||
|
problemID: props.problemID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NH2, NText } from "naive-ui"
|
import { NButton, NH2, NText } from "naive-ui"
|
||||||
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import { adminRejudge, getSubmissions, getTodaySubmissionCount } from "oj/api"
|
import { adminRejudge, getSubmissions, getTodaySubmissionCount } from "oj/api"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { LANGUAGE, SubmissionListItem } from "utils/types"
|
import { LANGUAGE, SubmissionListItem } from "utils/types"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||||
import { usePagination } from "~/shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { LANGUAGE_SHOW_VALUE } from "~/utils/constants"
|
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||||
import { renderTableTitle } from "~/utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
||||||
import StatisticsPanel from "./components/StatisticsPanel.vue"
|
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||||
import SubmissionLink from "./components/SubmissionLink.vue"
|
import SubmissionLink from "./components/SubmissionLink.vue"
|
||||||
import SubmissionDetail from "./detail.vue"
|
import SubmissionDetail from "./detail.vue"
|
||||||
|
import IconButton from "shared/components/IconButton.vue"
|
||||||
|
|
||||||
interface SubmissionQuery {
|
interface SubmissionQuery {
|
||||||
username: string
|
username: string
|
||||||
result: string
|
result: string
|
||||||
myself: boolean
|
myself: "0" | "1"
|
||||||
problem: string
|
problem: string
|
||||||
language: LANGUAGE | ""
|
language: LANGUAGE | ""
|
||||||
}
|
}
|
||||||
@@ -34,11 +36,11 @@ const todayCount = ref(0)
|
|||||||
|
|
||||||
// 使用分页 composable
|
// 使用分页 composable
|
||||||
const { query, clearQuery } = usePagination<SubmissionQuery>({
|
const { query, clearQuery } = usePagination<SubmissionQuery>({
|
||||||
username: "",
|
username: useRouteQuery("username", "").value,
|
||||||
result: "",
|
result: useRouteQuery("result", "").value,
|
||||||
myself: false,
|
myself: useRouteQuery("myself", "0").value,
|
||||||
problem: "",
|
problem: useRouteQuery("problem", "").value,
|
||||||
language: "",
|
language: useRouteQuery("language", "").value,
|
||||||
})
|
})
|
||||||
const submissionID = ref("")
|
const submissionID = ref("")
|
||||||
const problemDisplayID = ref("")
|
const problemDisplayID = ref("")
|
||||||
@@ -65,7 +67,6 @@ async function listSubmissions() {
|
|||||||
try {
|
try {
|
||||||
const res = await getSubmissions({
|
const res = await getSubmissions({
|
||||||
...query,
|
...query,
|
||||||
myself: query.myself ? "1" : "0",
|
|
||||||
offset,
|
offset,
|
||||||
problem_id: query.problem,
|
problem_id: query.problem,
|
||||||
contest_id: <string>route.params.contestID ?? "",
|
contest_id: <string>route.params.contestID ?? "",
|
||||||
@@ -92,7 +93,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
function search(username: string, problem: string) {
|
function search(username: string, problem: string) {
|
||||||
query.username = username
|
query.username = username
|
||||||
query.problem = problem
|
query.problem = problem
|
||||||
@@ -129,11 +129,10 @@ function showCodePanel(id: string, problem: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 监听用户名和题号变化(防抖)
|
// 监听用户名和题号变化(防抖)
|
||||||
watchDebounced(
|
watchDebounced(() => [query.username, query.problem], listSubmissions, {
|
||||||
() => [query.username, query.problem],
|
debounce: 500,
|
||||||
listSubmissions,
|
maxWait: 1000,
|
||||||
{ debounce: 500, maxWait: 1000 },
|
})
|
||||||
)
|
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
// 监听其他查询条件变化
|
||||||
watch(
|
watch(
|
||||||
@@ -161,7 +160,7 @@ const columns = computed(() => {
|
|||||||
{
|
{
|
||||||
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
||||||
key: "id",
|
key: "id",
|
||||||
minWidth: 160,
|
minWidth: 180,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(SubmissionLink, {
|
h(SubmissionLink, {
|
||||||
submission: row,
|
submission: row,
|
||||||
@@ -171,7 +170,7 @@ const columns = computed(() => {
|
|||||||
{
|
{
|
||||||
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
|
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
|
||||||
key: "status",
|
key: "status",
|
||||||
width: 160,
|
width: 140,
|
||||||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -182,6 +181,7 @@ const columns = computed(() => {
|
|||||||
h(
|
h(
|
||||||
ButtonWithSearch,
|
ButtonWithSearch,
|
||||||
{
|
{
|
||||||
|
type: "题目",
|
||||||
onClick: () => problemClicked(row),
|
onClick: () => problemClicked(row),
|
||||||
onSearch: () => (query.problem = row.problem),
|
onSearch: () => (query.problem = row.problem),
|
||||||
},
|
},
|
||||||
@@ -203,13 +203,16 @@ const columns = computed(() => {
|
|||||||
"streamline-emojis:smiling-face-with-sunglasses",
|
"streamline-emojis:smiling-face-with-sunglasses",
|
||||||
),
|
),
|
||||||
key: "username",
|
key: "username",
|
||||||
minWidth: 160,
|
minWidth: 200,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(
|
h(
|
||||||
ButtonWithSearch,
|
ButtonWithSearch,
|
||||||
{
|
{
|
||||||
|
type: "用户",
|
||||||
|
username: row.username,
|
||||||
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
||||||
onSearch: () => (query.username = row.username),
|
onSearch: () => (query.username = row.username),
|
||||||
|
onFilterClass: (classname: string) => (query.username = classname),
|
||||||
},
|
},
|
||||||
() => row.username,
|
() => row.username,
|
||||||
),
|
),
|
||||||
@@ -239,14 +242,21 @@ const columns = computed(() => {
|
|||||||
<n-flex vertical size="large">
|
<n-flex vertical size="large">
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-form :show-feedback="false" inline label-placement="left">
|
<n-form :show-feedback="false" inline label-placement="left">
|
||||||
<n-form-item label="提交状态">
|
<n-form-item v-if="isDesktop && userStore.isAuthed" label="只看自己">
|
||||||
|
<n-switch
|
||||||
|
v-model:value="query.myself"
|
||||||
|
checked-value="1"
|
||||||
|
unchecked-value="0"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="状态">
|
||||||
<n-select
|
<n-select
|
||||||
class="select"
|
class="select"
|
||||||
v-model:value="query.result"
|
v-model:value="query.result"
|
||||||
:options="resultOptions"
|
:options="resultOptions"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="编程语言">
|
<n-form-item label="语言" v-if="route.name !== 'contest submissions'">
|
||||||
<n-select
|
<n-select
|
||||||
class="select"
|
class="select"
|
||||||
v-model:value="query.language"
|
v-model:value="query.language"
|
||||||
@@ -271,25 +281,40 @@ const columns = computed(() => {
|
|||||||
placeholder="题号"
|
placeholder="题号"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item v-if="userStore.isAuthed" label="只看自己">
|
|
||||||
<n-switch v-model:value="query.myself" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-form :show-feedback="false" inline label-placement="left">
|
<n-form :show-feedback="false" inline label-placement="left">
|
||||||
|
<n-form-item v-if="isMobile && userStore.isAuthed" label="只看自己">
|
||||||
|
<n-switch
|
||||||
|
v-model:value="query.myself"
|
||||||
|
checked-value="1"
|
||||||
|
unchecked-value="0"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
<n-form-item>
|
<n-form-item>
|
||||||
<n-button @click="search(query.username, query.problem)">
|
<n-button @click="search(query.username, query.problem)">
|
||||||
搜索
|
搜索
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item
|
|
||||||
v-if="userStore.isSuperAdmin && route.name === 'submissions'"
|
|
||||||
>
|
|
||||||
<n-button @click="toggleStatisticPanel(true)">数据统计</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item>
|
<n-form-item>
|
||||||
<n-button @click="clear" quaternary>重置</n-button>
|
<n-button @click="clear" quaternary>重置</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item v-if="todayCount > 0">
|
<n-form-item
|
||||||
|
v-if="userStore.isSuperAdmin && route.name === 'submissions'"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="streamline-emojis:bar-chart"
|
||||||
|
tip="数据统计"
|
||||||
|
@click="toggleStatisticPanel(true)"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-form
|
||||||
|
:show-feedback="false"
|
||||||
|
inline
|
||||||
|
label-placement="left"
|
||||||
|
v-if="todayCount > 0"
|
||||||
|
>
|
||||||
|
<n-form-item>
|
||||||
<component :is="isDesktop ? NH2 : NText" class="todayCount">
|
<component :is="isDesktop ? NH2 : NText" class="todayCount">
|
||||||
<n-gradient-text>今日提交数:{{ todayCount }}</n-gradient-text>
|
<n-gradient-text>今日提交数:{{ todayCount }}</n-gradient-text>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { NH2, NH3 } from "naive-ui"
|
import { NH2, NH3 } from "naive-ui"
|
||||||
import { getProfile } from "~/shared/api"
|
import { getProfile } from "shared/api"
|
||||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
import { isDesktop } from "shared/composables/breakpoints"
|
||||||
import { durationToDays, parseTime } from "~/utils/functions"
|
import { durationToDays, parseTime } from "utils/functions"
|
||||||
import { Profile } from "~/utils/types"
|
import { Profile } from "utils/types"
|
||||||
import { getMetrics } from "../api"
|
import { getMetrics } from "../api"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -45,10 +45,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { getMessageList } from "oj/api"
|
import { getMessageList } from "oj/api"
|
||||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||||
import Copy from "~/shared/components/Copy.vue"
|
import Copy from "shared/components/Copy.vue"
|
||||||
import Pagination from "~/shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime } from "~/utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Message } from "~/utils/types"
|
import { Message } from "utils/types"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const messages = ref<Message[]>([])
|
const messages = ref<Message[]>([])
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { updateProfile, uploadAvatar } from "oj/api"
|
import { updateProfile, uploadAvatar } from "oj/api"
|
||||||
import { useUserStore } from "~/shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { RouteRecordRaw } from "vue-router"
|
|||||||
|
|
||||||
export const ojs: RouteRecordRaw = {
|
export const ojs: RouteRecordRaw = {
|
||||||
path: "/",
|
path: "/",
|
||||||
component: () => import("~/shared/layout/default.vue"),
|
component: () => import("shared/layout/default.vue"),
|
||||||
children: [
|
children: [
|
||||||
{ path: "", component: () => import("oj/problem/list.vue") },
|
{ path: "", component: () => import("oj/problem/list.vue") },
|
||||||
{
|
{
|
||||||
@@ -101,12 +101,12 @@ export const ojs: RouteRecordRaw = {
|
|||||||
|
|
||||||
export const admins: RouteRecordRaw = {
|
export const admins: RouteRecordRaw = {
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
component: () => import("~/shared/layout/admin.vue"),
|
component: () => import("shared/layout/admin.vue"),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
name: "admin home",
|
name: "admin home",
|
||||||
component: () => import("~/admin/setting/home.vue"),
|
component: () => import("admin/setting/home.vue"),
|
||||||
meta: { requiresSuperAdmin: true },
|
meta: { requiresSuperAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -124,7 +124,7 @@ export const admins: RouteRecordRaw = {
|
|||||||
{
|
{
|
||||||
path: "user/generate",
|
path: "user/generate",
|
||||||
name: "admin user generate",
|
name: "admin user generate",
|
||||||
component: () => import("~/admin/user/generate.vue"),
|
component: () => import("admin/user/generate.vue"),
|
||||||
meta: { requiresSuperAdmin: true },
|
meta: { requiresSuperAdmin: true },
|
||||||
},
|
},
|
||||||
// admin和super_admin都可以访问的路由 (需要题目权限)
|
// admin和super_admin都可以访问的路由 (需要题目权限)
|
||||||
@@ -187,6 +187,13 @@ export const admins: RouteRecordRaw = {
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { requiresSuperAdmin: true },
|
meta: { requiresSuperAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "contest/:contestID/helper",
|
||||||
|
name: "admin contest helper",
|
||||||
|
component: () => import("admin/contest/helper.vue"),
|
||||||
|
props: true,
|
||||||
|
meta: { requiresSuperAdmin: true },
|
||||||
|
},
|
||||||
// 只有super_admin可以访问的路由
|
// 只有super_admin可以访问的路由
|
||||||
{
|
{
|
||||||
path: "announcement/list",
|
path: "announcement/list",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import http from "utils/http"
|
import http from "utils/http"
|
||||||
import { Profile, Tag } from "~/utils/types"
|
import { Profile, Tag } from "utils/types"
|
||||||
|
|
||||||
export function login(data: { username: string; password: string }) {
|
export function login(data: { username: string; password: string }) {
|
||||||
return http.post("login", data)
|
return http.post("login", data)
|
||||||
|
|||||||
35
src/shared/components/AuthorSelect.vue
Normal file
35
src/shared/components/AuthorSelect.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<n-select
|
||||||
|
style="width: 140px"
|
||||||
|
v-model:value="author"
|
||||||
|
remote
|
||||||
|
@update:show="getAuthorOptions"
|
||||||
|
:options="authorOptions"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getAuthors } from "oj/api"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
all?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const { all = false } = defineProps<Props>()
|
||||||
|
|
||||||
|
const author = defineModel<string>("value")
|
||||||
|
|
||||||
|
const authorOptions = ref([{ label: "全部", value: "" }])
|
||||||
|
|
||||||
|
async function getAuthorOptions() {
|
||||||
|
authorOptions.value = [{ label: "全部", value: "" }]
|
||||||
|
const res = await getAuthors(all)
|
||||||
|
const remotes = res.data.map(
|
||||||
|
(item: { username: string; problem_count: number }) => ({
|
||||||
|
label: `${item.username} (${item.problem_count})`,
|
||||||
|
value: item.username,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
authorOptions.value = [...authorOptions.value, ...remotes]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -3,22 +3,11 @@ import { cpp } from "@codemirror/lang-cpp"
|
|||||||
import { python } from "@codemirror/lang-python"
|
import { python } from "@codemirror/lang-python"
|
||||||
import { EditorView } from "@codemirror/view"
|
import { EditorView } from "@codemirror/view"
|
||||||
import { Codemirror } from "vue-codemirror"
|
import { Codemirror } from "vue-codemirror"
|
||||||
import { LANGUAGE } from "~/utils/types"
|
import type { Extension } from "@codemirror/state"
|
||||||
|
import { LANGUAGE } from "utils/types"
|
||||||
import { oneDark } from "../themes/oneDark"
|
import { oneDark } from "../themes/oneDark"
|
||||||
import { smoothy } from "../themes/smoothy"
|
import { smoothy } from "../themes/smoothy"
|
||||||
|
|
||||||
const styleTheme = EditorView.baseTheme({
|
|
||||||
"& .cm-scroller": {
|
|
||||||
"font-family": "Monaco",
|
|
||||||
},
|
|
||||||
"&.cm-editor.cm-focused": {
|
|
||||||
outline: "none",
|
|
||||||
},
|
|
||||||
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
|
||||||
"font-family": "Monaco",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
language?: LANGUAGE
|
language?: LANGUAGE
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
@@ -28,32 +17,45 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
language: "C",
|
language: "Python3",
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
readonly: false,
|
readonly: false,
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { readonly, placeholder, height, fontSize } = toRefs(props)
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
|
|
||||||
const lang = computed(() => {
|
const styleTheme = EditorView.baseTheme({
|
||||||
if (props.language === "Python3" || props.language === "Python2") {
|
"& .cm-scroller": { "font-family": "Monaco" },
|
||||||
return python()
|
"&.cm-editor.cm-focused": { outline: "none" },
|
||||||
}
|
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
||||||
return cpp()
|
"font-family": "Monaco",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lang = computed((): Extension => {
|
||||||
|
return ["Python2", "Python3"].includes(props.language) ? python() : cpp()
|
||||||
|
})
|
||||||
|
|
||||||
|
const extensions = computed(() => [
|
||||||
|
styleTheme,
|
||||||
|
lang.value,
|
||||||
|
isDark.value ? oneDark : smoothy,
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Codemirror
|
<Codemirror
|
||||||
v-model="code"
|
v-model="code"
|
||||||
indentWithTab
|
indentWithTab
|
||||||
:extensions="[styleTheme, lang, isDark ? oneDark : smoothy]"
|
:extensions="extensions"
|
||||||
:disabled="props.readonly"
|
:disabled="readonly"
|
||||||
:tabSize="4"
|
:tab-size="4"
|
||||||
:placeholder="props.placeholder"
|
:placeholder="placeholder"
|
||||||
:style="{ height: props.height, fontSize: props.fontSize + 'px' }"
|
:style="{ height, fontSize: `${fontSize}px` }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ContestType } from "~/utils/constants"
|
import { ContestType } from "utils/constants"
|
||||||
import { Contest } from "~/utils/types"
|
import { Contest } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contest: Contest
|
contest: Contest
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import copy from "copy-text-to-clipboard"
|
import { copyToClipboard } from "utils/functions"
|
||||||
|
|
||||||
defineProps<{ value: string }>()
|
defineProps<{ value: string }>()
|
||||||
const [copied, toggle] = useToggle()
|
const [copied, toggle] = useToggle()
|
||||||
@@ -9,10 +9,12 @@ const { start } = useTimeoutFn(() => toggle(false), 1000, { immediate: false })
|
|||||||
const COPY = h(Icon, { icon: "twemoji:clipboard" })
|
const COPY = h(Icon, { icon: "twemoji:clipboard" })
|
||||||
const OK = h(Icon, { icon: "twemoji:check-mark-button" })
|
const OK = h(Icon, { icon: "twemoji:check-mark-button" })
|
||||||
|
|
||||||
function handleClick(value: string) {
|
async function handleClick(value: string) {
|
||||||
copy(value)
|
const success = await copyToClipboard(value)
|
||||||
toggle(true)
|
if (success) {
|
||||||
start()
|
toggle(true)
|
||||||
|
start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { RouterLink } from "vue-router"
|
import { RouterLink } from "vue-router"
|
||||||
import { isDesktop, isMobile } from "~/shared/composables/breakpoints"
|
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||||
import { toggleLogin, toggleSignup } from "~/shared/composables/modal"
|
import { toggleLogin, toggleSignup } from "shared/composables/modal"
|
||||||
import { screenMode, switchScreenMode } from "~/shared/composables/switchScreen"
|
import { screenMode, switchScreenMode } from "shared/composables/switchScreen"
|
||||||
import { avatar } from "~/utils/constants"
|
|
||||||
import { logout } from "../api"
|
import { logout } from "../api"
|
||||||
import { useConfigStore } from "../store/config"
|
import { useConfigStore } from "../store/config"
|
||||||
import { useUserStore } from "../store/user"
|
import { useUserStore } from "../store/user"
|
||||||
@@ -16,6 +15,36 @@ const configStore = useConfigStore()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const names = [
|
||||||
|
"man-with-chinese-cap-1",
|
||||||
|
"cat-face",
|
||||||
|
"china",
|
||||||
|
"chicken",
|
||||||
|
"eyes",
|
||||||
|
"elephant",
|
||||||
|
"hear-no-evil-monkey",
|
||||||
|
"panda-face",
|
||||||
|
"penguin-1",
|
||||||
|
"rooster",
|
||||||
|
"star-struck-1",
|
||||||
|
"tomato",
|
||||||
|
"rocket",
|
||||||
|
"sparkles",
|
||||||
|
"money-bag",
|
||||||
|
"ghost",
|
||||||
|
"game-dice",
|
||||||
|
"ewe-1",
|
||||||
|
"artist-palette",
|
||||||
|
"baby-bottle",
|
||||||
|
]
|
||||||
|
|
||||||
|
function getRandomAvatar() {
|
||||||
|
const name = names[Math.floor(Math.random() * names.length)]
|
||||||
|
return `streamline-emojis:${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatar = ref(getRandomAvatar())
|
||||||
|
|
||||||
const envVersion = computed(() => {
|
const envVersion = computed(() => {
|
||||||
if (import.meta.env.PUBLIC_ENV === "test") {
|
if (import.meta.env.PUBLIC_ENV === "test") {
|
||||||
return "测试版"
|
return "测试版"
|
||||||
@@ -57,7 +86,6 @@ const menus = computed<MenuOption[]>(() => [
|
|||||||
label: () => h(RouterLink, { to: "/learn/01" }, { default: () => "自学" }),
|
label: () => h(RouterLink, { to: "/learn/01" }, { default: () => "自学" }),
|
||||||
key: "learn",
|
key: "learn",
|
||||||
icon: renderIcon("streamline-emojis:snake"),
|
icon: renderIcon("streamline-emojis:snake"),
|
||||||
show: isDesktop.value,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: () => h(RouterLink, { to: "/" }, { default: () => "题库" }),
|
label: () => h(RouterLink, { to: "/" }, { default: () => "题库" }),
|
||||||
@@ -69,6 +97,7 @@ const menus = computed<MenuOption[]>(() => [
|
|||||||
h(RouterLink, { to: "/submission" }, { default: () => "提交" }),
|
h(RouterLink, { to: "/submission" }, { default: () => "提交" }),
|
||||||
key: "submission",
|
key: "submission",
|
||||||
icon: renderIcon("streamline-emojis:bouquet"),
|
icon: renderIcon("streamline-emojis:bouquet"),
|
||||||
|
show: userStore.showSubmissions,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: () => h(RouterLink, { to: "/contest" }, { default: () => "比赛" }),
|
label: () => h(RouterLink, { to: "/contest" }, { default: () => "比赛" }),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user