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_OJ_URL=http://localhost:8000
|
||||
PUBLIC_CODE_URL=http://localhost:3000
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=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_OJ_URL=https://oj.xuyue.cc
|
||||
PUBLIC_CODE_URL=https://code.xuyue.cc
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
||||
@@ -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_CODE_URL=http://10.13.114.114:82
|
||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_SIGNALING_URL=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_CODE_URL=http://10.13.114.114:82
|
||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_SIGNALING_URL=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>
|
||||
window.localStorage.setItem("maxkbMaskTip", true)
|
||||
</script>
|
||||
|
||||
<% if (process.env.PUBLIC_MAXKB_URL) { %>
|
||||
<script
|
||||
async
|
||||
defer
|
||||
src="<%= process.env.PUBLIC_MAXKB_URL %>"
|
||||
></script>
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
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-python": "^6.2.1",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"@vueuse/router": "^13.9.0",
|
||||
"@wangeditor-next/editor": "^5.6.46",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"axios": "^1.12.2",
|
||||
@@ -27,20 +28,22 @@
|
||||
"naive-ui": "^2.43.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^3.0.3",
|
||||
"query-string": "^9.3.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"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": {
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@rsbuild/core": "^1.5.13",
|
||||
"@rsbuild/plugin-vue": "^1.1.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/node": "^24.6.2",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "^20.2.0",
|
||||
"unplugin-vue-components": "^29.1.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
@font-face {
|
||||
font-family: "Monaco";
|
||||
src: url(/Monaco.ttf);
|
||||
}
|
||||
font-family: "Monaco";
|
||||
src: url(/Monaco.ttf);
|
||||
}
|
||||
|
||||
.md-editor-preview .md-editor-code .md-editor-code-head {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
.md-editor-preview h1 {
|
||||
font-size: 1.6rem !important;
|
||||
}
|
||||
|
||||
.md-editor-preview h2 {
|
||||
font-size: 1.4rem !important;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ export default defineConfig(({ envMode }) => {
|
||||
headers: { Referer: url },
|
||||
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 {
|
||||
plugins: [pluginVue()],
|
||||
tools: {
|
||||
@@ -72,14 +80,68 @@ export default defineConfig(({ envMode }) => {
|
||||
performance: {
|
||||
chunkSplit: {
|
||||
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: {
|
||||
alias: {
|
||||
"~": "./src",
|
||||
utils: "./src/utils",
|
||||
oj: "./src/oj",
|
||||
admin: "./src/admin",
|
||||
shared: "./src/shared",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@@ -87,6 +149,7 @@ export default defineConfig(({ envMode }) => {
|
||||
proxy: {
|
||||
"/api": proxyConfig,
|
||||
"/public": proxyConfig,
|
||||
"/ws": wsProxyConfig, // WebSocket 使用单独的代理配置
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
39
src/App.vue
39
src/App.vue
@@ -1,17 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import hljs from "highlight.js/lib/core"
|
||||
import c from "highlight.js/lib/languages/c"
|
||||
import cpp from "highlight.js/lib/languages/cpp"
|
||||
import python from "highlight.js/lib/languages/python"
|
||||
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||
import "normalize.css"
|
||||
import "./index.css"
|
||||
|
||||
hljs.registerLanguage("c", c)
|
||||
hljs.registerLanguage("python", python)
|
||||
hljs.registerLanguage("cpp", cpp)
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +40,7 @@ const isDark = useDark()
|
||||
:theme="isDark ? darkTheme : null"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:hljs="hljs"
|
||||
:hljs="hljsInstance"
|
||||
>
|
||||
<n-message-provider>
|
||||
<router-view></router-view>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteAnnouncement } from "~/admin/api"
|
||||
import { deleteAnnouncement } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
announcementID: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { AnnouncementEdit } from "~/utils/types"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { AnnouncementEdit } from "utils/types"
|
||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Announcement } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Announcement } from "utils/types"
|
||||
import { editAnnouncement, getAnnouncementList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Tutorial,
|
||||
User,
|
||||
WebsiteConfig,
|
||||
} from "~/utils/types"
|
||||
} from "utils/types"
|
||||
|
||||
export function getBaseInfo() {
|
||||
return http.get("admin/dashboard_info")
|
||||
@@ -25,8 +25,8 @@ export async function getProblemList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword: string,
|
||||
author?: string,
|
||||
contestID?: string,
|
||||
ruleType?: "ACM" | "OI",
|
||||
) {
|
||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||
const res = await http.get(endpoint, {
|
||||
@@ -35,8 +35,8 @@ export async function getProblemList(
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
rule_type: ruleType,
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -86,9 +86,10 @@ export function getUserList(
|
||||
limit = 10,
|
||||
type = "",
|
||||
keyword: string,
|
||||
orderBy = "",
|
||||
) {
|
||||
return http.get("admin/user", {
|
||||
params: { paging: true, offset, limit, keyword, type },
|
||||
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,3 +257,32 @@ export function deleteTutorial(id: number) {
|
||||
export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||
return http.put("admin/tutorial/visibility", { id, is_public })
|
||||
}
|
||||
|
||||
// 将竞赛题目转为公开题目
|
||||
export function makeProblemPublic(id: number, display_id: string) {
|
||||
return http.post("admin/contest_problem/make_public", {
|
||||
id,
|
||||
display_id,
|
||||
})
|
||||
}
|
||||
|
||||
// 比赛辅助检查
|
||||
export function getACMHelperList(contest_id: number) {
|
||||
return http.get("admin/contest/acm_helper", {
|
||||
params: { contest_id },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateACMHelperChecked(
|
||||
contest_id: number,
|
||||
rank_id: number,
|
||||
problem_id: string,
|
||||
checked: boolean,
|
||||
) {
|
||||
return http.put("admin/contest/acm_helper", {
|
||||
contest_id,
|
||||
rank_id,
|
||||
problem_id,
|
||||
checked,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Comment } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Comment } from "utils/types"
|
||||
import { getCommentList } from "../api"
|
||||
import CommentActions from "./components/CommentActions.vue"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteComment } from "~/admin/api"
|
||||
import { deleteComment } from "admin/api"
|
||||
|
||||
const props = defineProps<{ commentID: number }>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Contest } from "~/utils/types"
|
||||
import { Contest } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
contest: Contest
|
||||
@@ -20,12 +20,30 @@ function goEditProblems() {
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
function goACMHelper() {
|
||||
router.push({
|
||||
name: "admin contest helper",
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
const isACM = computed(() => props.contest.rule_type === "ACM")
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
||||
题目
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="isACM"
|
||||
size="small"
|
||||
type="warning"
|
||||
secondary
|
||||
@click="goACMHelper"
|
||||
>
|
||||
审核
|
||||
</n-button>
|
||||
<n-button size="small" type="info" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { formatISO } from "date-fns"
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { BlankContest } from "~/utils/types"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { BlankContest } from "utils/types"
|
||||
import { createContest, editContest, getContest } from "../api"
|
||||
|
||||
interface Props {
|
||||
|
||||
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">
|
||||
import { NSwitch, NTag } from "naive-ui"
|
||||
import ContestTitle from "~/shared/components/ContestTitle.vue"
|
||||
import ContestType from "~/shared/components/ContestType.vue"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "~/utils/constants"
|
||||
import { Contest } from "~/utils/types"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import ContestType from "shared/components/ContestType.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "utils/constants"
|
||||
import { Contest } from "utils/types"
|
||||
import { editContest, getContestList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
@@ -66,7 +66,7 @@ const columns: DataTableColumn<Contest>[] = [
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
width: 220,
|
||||
render: (row) => h(Actions, { contest: row }),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteContestProblem, deleteProblem } from "~/admin/api"
|
||||
import download from "~/utils/download"
|
||||
import {
|
||||
deleteContestProblem,
|
||||
deleteProblem,
|
||||
makeProblemPublic,
|
||||
} from "admin/api"
|
||||
import download from "utils/download"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
problemDisplayID: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
const emit = defineEmits(["updated"])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const isContestProblem = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
|
||||
const showMakePublicModal = ref(false)
|
||||
const newDisplayID = ref("")
|
||||
|
||||
async function handleDeleteProblem() {
|
||||
try {
|
||||
if (route.name === "admin contest problem list") {
|
||||
@@ -21,7 +32,7 @@ async function handleDeleteProblem() {
|
||||
await deleteProblem(props.problemID)
|
||||
}
|
||||
message.success("删除成功")
|
||||
emit("deleted")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Can't delete the problem as it has submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
@@ -53,6 +64,33 @@ function goCheck() {
|
||||
}
|
||||
window.open(data.href, "_blank")
|
||||
}
|
||||
|
||||
function openMakePublicModal() {
|
||||
newDisplayID.value = ""
|
||||
showMakePublicModal.value = true
|
||||
}
|
||||
|
||||
async function handleMakePublic() {
|
||||
if (!newDisplayID.value.trim()) {
|
||||
message.error("请输入新的题目编号")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
|
||||
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display ID") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (err.data === "Already be a public problem") {
|
||||
message.error("该题目已经是公开题目")
|
||||
} else {
|
||||
message.error("转换失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
@@ -62,6 +100,19 @@ function goCheck() {
|
||||
<n-button size="small" secondary type="info" @click="goCheck">
|
||||
查看
|
||||
</n-button>
|
||||
<n-tooltip v-if="isContestProblem">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
type="warning"
|
||||
@click="openMakePublicModal"
|
||||
>
|
||||
公开
|
||||
</n-button>
|
||||
</template>
|
||||
将此竞赛题目转为公开题目
|
||||
</n-tooltip>
|
||||
<n-popconfirm @positive-click="handleDeleteProblem">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="error">删除</n-button>
|
||||
@@ -75,4 +126,35 @@ function goCheck() {
|
||||
下载测试用例
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showMakePublicModal"
|
||||
preset="card"
|
||||
title="转为公开题目"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>
|
||||
将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。
|
||||
</p>
|
||||
<n-form>
|
||||
<n-form-item label="新的题目编号" required>
|
||||
<n-input
|
||||
v-model:value="newDisplayID"
|
||||
placeholder="例如: 1001"
|
||||
clearable
|
||||
@keyup.enter="handleMakePublic"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
|
||||
</n-alert>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showMakePublicModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleMakePublic">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { addProblemForContest } from "~/admin/api"
|
||||
import { addProblemForContest } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { getProblemList } from "~/admin/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { AdminProblemFiltered } from "~/utils/types"
|
||||
import { getProblemList } from "admin/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { AdminProblemFiltered } from "utils/types"
|
||||
import AddButton from "./AddButton.vue"
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { getProblemTagList } from "~/shared/api"
|
||||
import TextEditor from "~/shared/components/TextEditor.vue"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import {
|
||||
CODE_TEMPLATES,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
STORAGE_KEY,
|
||||
} from "~/utils/constants"
|
||||
import download from "~/utils/download"
|
||||
import { unique } from "~/utils/functions"
|
||||
import { BlankProblem, LANGUAGE, Tag } from "~/utils/types"
|
||||
} from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { unique } from "utils/functions"
|
||||
import { BlankProblem, LANGUAGE, Tag } from "utils/types"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "../api"
|
||||
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("~/shared/components/CodeEditor.vue"),
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
@@ -70,6 +70,8 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
||||
rule_type: "ACM",
|
||||
hint: "",
|
||||
source: "",
|
||||
prompt: "",
|
||||
answers: [],
|
||||
io_mode: {
|
||||
io_mode: "Standard IO",
|
||||
input: "input.txt",
|
||||
@@ -90,10 +92,11 @@ const tags = useLocalStorage<Tags>(STORAGE_KEY.ADMIN_PROBLEM_TAGS, {
|
||||
upload: [],
|
||||
})
|
||||
|
||||
// 这三个用的少,就不缓存本地了
|
||||
// 这几个用的少,就不缓存本地了
|
||||
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
||||
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
|
||||
const currentActiveTemplate = ref<LANGUAGE>("C")
|
||||
const currentActiveTemplate = ref<LANGUAGE>("Python3")
|
||||
const currentActiveAnswer = ref<LANGUAGE>("Python3")
|
||||
|
||||
// 给 TextEditor 用
|
||||
const [ready, toggleReady] = useToggle(false)
|
||||
@@ -148,6 +151,15 @@ async function getProblemDetail() {
|
||||
problem.value.rule_type = data.rule_type
|
||||
problem.value.hint = data.hint
|
||||
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
|
||||
if (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() {
|
||||
const notCompleted = detectProblemCompletion()
|
||||
if (notCompleted) return
|
||||
filterHint()
|
||||
getTemplate()
|
||||
filterAnswers()
|
||||
const api = {
|
||||
"admin problem create": createProblem,
|
||||
"admin problem edit": editProblem,
|
||||
@@ -372,6 +391,19 @@ watch(
|
||||
problem.value.tags = uniqueTags
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => problem.value.languages,
|
||||
(langs) => {
|
||||
const answers = langs.map((lang) => {
|
||||
const existing = problem.value.answers.find(
|
||||
(ans) => ans.language === lang,
|
||||
)
|
||||
return existing || { language: lang, code: "" }
|
||||
})
|
||||
problem.value.answers = answers
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -459,39 +491,72 @@ watch(
|
||||
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
||||
添加用例
|
||||
</n-button>
|
||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示" />
|
||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
|
||||
<n-form>
|
||||
<n-form-item label="题目的来源">
|
||||
<n-form-item label="题目的来源(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.source"
|
||||
placeholder="比如来自某道题的改编等,或者网上的资料"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form v-if="needTemplate">
|
||||
<n-form-item label="编写代码模板">
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="C"
|
||||
class="template box"
|
||||
v-model:value="currentActiveTemplate"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="(lang, index) in problem.languages"
|
||||
:key="index"
|
||||
:name="lang"
|
||||
>
|
||||
<CodeEditor
|
||||
v-model:value="template[lang]"
|
||||
:language="lang"
|
||||
:font-size="16"
|
||||
height="200px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-form-item label="本题的考察知识点(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.prompt"
|
||||
placeholder="这里的内容是方便喂给 AI 进行辅助分析的"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-grid :cols="2" x-gap="20">
|
||||
<n-gi>
|
||||
<n-form>
|
||||
<n-form-item label="本题参考答案(选填,用于 AI 分析,不会泄露)">
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="Python3"
|
||||
v-model:value="currentActiveAnswer"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="(answer, index) in problem.answers"
|
||||
:key="index"
|
||||
:name="answer.language"
|
||||
>
|
||||
<CodeEditor
|
||||
v-model:value="answer.code"
|
||||
:language="answer.language"
|
||||
:font-size="16"
|
||||
height="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
|
||||
class="box"
|
||||
v-if="problem.test_case_score.length"
|
||||
@@ -519,7 +584,7 @@ watch(
|
||||
</n-alert>
|
||||
<n-space style="margin-bottom: 100px" justify="space-between">
|
||||
<n-form inline label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="语言">
|
||||
<n-form-item label="编程语言">
|
||||
<n-checkbox-group v-model:value="problem.languages">
|
||||
<n-flex align="center">
|
||||
<n-checkbox
|
||||
@@ -554,7 +619,7 @@ watch(
|
||||
<template #trigger>
|
||||
<n-button text>温馨提醒</n-button>
|
||||
</template>
|
||||
【测试用例】最好要有10个,要考虑边界情况,不要跟【测试样例】一模一样
|
||||
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||
</n-tooltip>
|
||||
<div>
|
||||
<n-upload
|
||||
@@ -594,8 +659,4 @@ watch(
|
||||
.addSamples {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template {
|
||||
width: 60%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { AdminProblemFiltered } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { AdminProblemFiltered } from "utils/types"
|
||||
import { getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Modal from "./components/Modal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
|
||||
interface Props {
|
||||
contestID?: string
|
||||
@@ -34,11 +36,13 @@ const problems = ref<AdminProblemFiltered[]>([])
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
author: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: "",
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
@@ -55,6 +59,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
minWidth: 80,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
@@ -66,17 +71,16 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 260,
|
||||
width: 330,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemID: row.id,
|
||||
problemDisplayID: row._id,
|
||||
onDeleted: listProblems,
|
||||
onUpdated: listProblems,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async function listProblems() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
@@ -84,6 +88,7 @@ async function listProblems() {
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.author,
|
||||
props.contestID,
|
||||
)
|
||||
total.value = res.total
|
||||
@@ -115,17 +120,13 @@ async function selectProblems() {
|
||||
onMounted(listProblems)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
listProblems,
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watchDebounced(() => query.keyword, listProblems, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit],
|
||||
listProblems,
|
||||
)
|
||||
watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -151,8 +152,17 @@ watch(
|
||||
>
|
||||
从题库中选择
|
||||
</n-button>
|
||||
<n-flex align="center" v-if="!props.contestID">
|
||||
<span>出题人</span>
|
||||
<AuthorSelect v-model:value="query.author" all />
|
||||
</n-flex>
|
||||
<div>
|
||||
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" />
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Server } from "~/utils/types"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Server } from "utils/types"
|
||||
import {
|
||||
deleteJudgeServer,
|
||||
editWebsite,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from "naive-ui"
|
||||
import { getRank } from "oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { getACRate } from "~/utils/functions"
|
||||
import { Rank } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { getACRate } from "utils/functions"
|
||||
import { Rank } from "utils/types"
|
||||
import { getBaseInfo, randomUser10 } from "../api"
|
||||
|
||||
const userCount = ref(0)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteTutorial } from "~/admin/api"
|
||||
import { deleteTutorial } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
tutorialID: number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "~/shared/components/MarkdownEditor.vue"
|
||||
import { Tutorial } from "~/utils/types"
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||
import { Tutorial } from "utils/types"
|
||||
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Tutorial } from "~/utils/types"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Tutorial } from "utils/types"
|
||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { editUser } from "~/admin/api"
|
||||
import { User } from "~/utils/types"
|
||||
import { editUser } from "admin/api"
|
||||
import { User } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants"
|
||||
import { getUserRole } from "~/utils/functions"
|
||||
import { User } from "~/utils/types"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { getUserRole } from "utils/functions"
|
||||
import { User } from "utils/types"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
@@ -30,6 +31,6 @@ const isNotRegularUser = computed(
|
||||
: "仅自己"
|
||||
}}
|
||||
</n-tag>
|
||||
{{ props.user.username }}
|
||||
<TextCopy>{{ props.user.username }}</TextCopy>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { DataTableRowKey, SelectOption } from "naive-ui"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { User } from "~/utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { User } from "utils/types"
|
||||
import {
|
||||
deleteUsers,
|
||||
editUser,
|
||||
@@ -13,19 +13,23 @@ import {
|
||||
} from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Name from "./components/Name.vue"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
interface UserQuery {
|
||||
keyword: string
|
||||
type: string
|
||||
orderBy: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<UserQuery>({
|
||||
keyword: "",
|
||||
type: "",
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
type: useRouteQuery("type", "").value,
|
||||
orderBy: useRouteQuery("orderBy", "").value,
|
||||
})
|
||||
|
||||
const total = ref(0)
|
||||
@@ -37,6 +41,12 @@ const adminOptions = [
|
||||
{ label: "管理员", value: USER_TYPE.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 password = ref("")
|
||||
const userIDs = ref<DataTableRowKey[]>([])
|
||||
@@ -56,6 +66,7 @@ const columns: DataTableColumn<User>[] = [
|
||||
title: "密码",
|
||||
key: "raw_password",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.raw_password),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
@@ -72,12 +83,17 @@ const columns: DataTableColumn<User>[] = [
|
||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||
: "从未登录",
|
||||
},
|
||||
{ title: "真名", key: "real_name", width: 100 },
|
||||
{
|
||||
title: "真名",
|
||||
key: "real_name",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.real_name),
|
||||
},
|
||||
{ title: "邮箱", key: "email", width: 200 },
|
||||
{
|
||||
key: "actions",
|
||||
title: "选项",
|
||||
width: 260,
|
||||
width: 280,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
user: row,
|
||||
@@ -101,11 +117,16 @@ const problemPermissionOptions: SelectOption[] = [
|
||||
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
||||
]
|
||||
|
||||
|
||||
async function listUsers() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getUserList(offset, query.limit, query.type, query.keyword)
|
||||
const res = await getUserList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.type,
|
||||
query.keyword,
|
||||
query.orderBy,
|
||||
)
|
||||
total.value = res.data.total
|
||||
users.value = res.data.results
|
||||
}
|
||||
@@ -198,17 +219,10 @@ async function handleEditUser() {
|
||||
onMounted(listUsers)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
listUsers,
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit, query.type],
|
||||
listUsers,
|
||||
)
|
||||
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -231,6 +245,12 @@ watch(
|
||||
确定删除选中的用户吗?删除后无法恢复!
|
||||
</n-popconfirm>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="query.orderBy"
|
||||
:options="sortOptions"
|
||||
placeholder="排序方式"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.type"
|
||||
:options="adminOptions"
|
||||
@@ -238,7 +258,12 @@ watch(
|
||||
style="width: 120px"
|
||||
/>
|
||||
<div>
|
||||
<n-input style="width: 200px" v-model:value="query.keyword" />
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
v-model:value="query.keyword"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
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_JUDGE0_URL: string
|
||||
readonly PUBLIC_ICONIFY_URL: string
|
||||
readonly PUBLIC_SIGNALING_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
31
src/main.ts
31
src/main.ts
@@ -2,22 +2,6 @@ import { addAPIProvider } from "@iconify/vue"
|
||||
import { createPinia } from "pinia"
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
|
||||
import {
|
||||
ArcElement,
|
||||
BarElement,
|
||||
BarController,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Colors,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
LineElement,
|
||||
PointElement,
|
||||
} from "chart.js"
|
||||
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
|
||||
@@ -82,21 +66,6 @@ router.beforeEach(async (to, from, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
BarController,
|
||||
ArcElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
PointElement,
|
||||
Colors,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
)
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
|
||||
@@ -1,50 +1,74 @@
|
||||
<template>
|
||||
<n-grid :cols="isDesktop ? 5: 1" :x-gap="20">
|
||||
<n-gi :span="2">
|
||||
<n-flex vertical size="large">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-h3 style="margin: 0;">请选择时间范围,智能分析学习情况</n-h3>
|
||||
<n-select
|
||||
style="width: 140px"
|
||||
:options="options"
|
||||
v-model:value="aiStore.duration"
|
||||
/>
|
||||
<n-spin :show="aiStore.loading.fetching">
|
||||
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20" :y-gap="20">
|
||||
<n-gi :span="2">
|
||||
<n-flex vertical size="large">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
||||
<n-select
|
||||
style="width: 140px"
|
||||
:options="options"
|
||||
v-model:value="aiStore.duration"
|
||||
/>
|
||||
</n-flex>
|
||||
<Overview />
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20">
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<DifficultyGradeChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<TagsRadarChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<RankDistributionChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<TimeActivityHeatmap />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<SolvedTable />
|
||||
</n-flex>
|
||||
<Details :start="start" :end="end" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="3">
|
||||
<n-flex vertical size="large">
|
||||
<Heatmap />
|
||||
<WeeklyChart :end="end" />
|
||||
<AI v-if="aiStore.detailsData.solved.length" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-gi>
|
||||
<n-gi :span="3">
|
||||
<n-flex vertical size="large">
|
||||
<Heatmap />
|
||||
<ProgressChart />
|
||||
<EfficiencyChart />
|
||||
<DurationChart />
|
||||
<AI v-if="aiStore.detailsData.solved.length >= 10" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="5">
|
||||
<AI
|
||||
v-if="
|
||||
aiStore.detailsData.solved.length > 0 &&
|
||||
aiStore.detailsData.solved.length < 10
|
||||
"
|
||||
/>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import WeeklyChart from "./components/WeeklyChart.vue"
|
||||
import Details from "./components/Details.vue"
|
||||
import TagsRadarChart from "./components/TagsRadarChart.vue"
|
||||
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
|
||||
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
|
||||
import RankDistributionChart from "./components/RankDistributionChart.vue"
|
||||
import Overview from "./components/Overview.vue"
|
||||
import Heatmap from "./components/Heatmap.vue"
|
||||
import ProgressChart from "./components/ProgressChart.vue"
|
||||
import DurationChart from "./components/DurationChart.vue"
|
||||
import EfficiencyChart from "./components/EfficiencyChart.vue"
|
||||
import AI from "./components/AI.vue"
|
||||
import SolvedTable from "./components/SolvedTable.vue"
|
||||
import { useAIStore } from "../store/ai"
|
||||
import { DURATION_OPTIONS } from "utils/constants"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const start = ref("")
|
||||
const end = ref("")
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "一节课内", value: "hours:1" },
|
||||
{ label: "两节课内", value: "hours:2" },
|
||||
{ label: "一天内", value: "days:1" },
|
||||
{ label: "一周内", value: "weeks:1" },
|
||||
{ label: "一个月内", value: "months:1" },
|
||||
{ label: "两个月内", value: "months:2" },
|
||||
{ label: "半年内", value: "months:6" },
|
||||
{ label: "一年内", value: "years:1" },
|
||||
]
|
||||
const options = [...DURATION_OPTIONS]
|
||||
|
||||
const subOptions = computed<Duration>(() => {
|
||||
let dur = options.find((it) => it.value === aiStore.duration) ?? options[0]
|
||||
@@ -54,11 +78,25 @@ const subOptions = computed<Duration>(() => {
|
||||
return { [unit]: parseInt(n) } as Duration
|
||||
})
|
||||
|
||||
function updateRange() {
|
||||
const start = computed(() => {
|
||||
const current = new Date()
|
||||
end.value = formatISO(current)
|
||||
start.value = formatISO(sub(current, subOptions.value))
|
||||
}
|
||||
return formatISO(sub(current, subOptions.value))
|
||||
})
|
||||
|
||||
watch(() => aiStore.duration, updateRange, { immediate: true })
|
||||
const end = computed(() => {
|
||||
return formatISO(new Date())
|
||||
})
|
||||
|
||||
// 获取热力图数据(仅一次)
|
||||
onMounted(() => {
|
||||
aiStore.fetchHeatmapData()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.ai">
|
||||
<div class="container">
|
||||
<MdPreview :model-value="aiStore.mdContent" />
|
||||
</div>
|
||||
</n-spin>
|
||||
<n-card size="small">
|
||||
<template #header>
|
||||
<div class="cool-title">
|
||||
<span class="title-text">AI 帮你分析</span>
|
||||
</div>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.ai">
|
||||
<div class="container">
|
||||
<MdPreview :model-value="aiStore.mdContent" />
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "~/oj/store/ai"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
watch(
|
||||
() => [aiStore.loading.details, aiStore.loading.weekly],
|
||||
(newVal) => {
|
||||
if (newVal.every((val) => val === false)) {
|
||||
() => aiStore.loading.fetching,
|
||||
(isLoading) => {
|
||||
if (!isLoading) {
|
||||
aiStore.fetchAIAnalysis()
|
||||
}
|
||||
},
|
||||
@@ -22,7 +29,41 @@ watch(
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.cool-title {
|
||||
position: relative;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2, #f093fb);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.8px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: gradient-flow 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-flow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 200px;
|
||||
}
|
||||
:deep(.md-editor-preview h1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.details">
|
||||
<n-flex vertical size="large">
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
>
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>你一共解决 </span>
|
||||
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
|
||||
<span> 道题,</span>
|
||||
<span v-if="aiStore.detailsData.contest_count > 0">
|
||||
并且参加
|
||||
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
|
||||
次比赛,
|
||||
</span>
|
||||
<span>综合评价给到</span>
|
||||
<Grade :grade="aiStore.detailsData.grade" />
|
||||
<span>{{ greeting }}</span>
|
||||
</n-alert>
|
||||
<n-flex vertical size="large" v-else>
|
||||
<n-alert type="error" title="你还没有完成任何题目"></n-alert>
|
||||
<AI />
|
||||
</n-flex>
|
||||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="10" :y-gap="10">
|
||||
<n-gi>
|
||||
<TagsChart :tags="aiStore.detailsData.tags" />
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<DifficultyChart :difficulty="aiStore.detailsData.difficulty" />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-data-table
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
striped
|
||||
:data="aiStore.detailsData.solved"
|
||||
:columns="columns"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import Grade from "./Grade.vue"
|
||||
import TagsChart from "./TagsChart.vue"
|
||||
import DifficultyChart from "./DifficultyChart.vue"
|
||||
import TagTitle from "./TagTitle.vue"
|
||||
import AI from "./AI.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { SolvedProblem } from "~/utils/types"
|
||||
import { useAIStore } from "~/oj/store/ai"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
|
||||
const props = defineProps<{
|
||||
start: string
|
||||
end: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
title: "完成的题目",
|
||||
key: "problem.title",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.problem.contest_id) {
|
||||
router.push(
|
||||
"/contest/" +
|
||||
row.problem.contest_id +
|
||||
"/problem/" +
|
||||
row.problem.display_id,
|
||||
)
|
||||
} else {
|
||||
router.push("/problem/" + row.problem.display_id)
|
||||
}
|
||||
},
|
||||
},
|
||||
() => {
|
||||
if (row.problem.contest_id) {
|
||||
return h(TagTitle, { problem: row.problem })
|
||||
} else {
|
||||
return row.problem.display_id + " " + row.problem.title
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
|
||||
key: "rank",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (row) => row.rank + " / " + row.ac_count,
|
||||
},
|
||||
{
|
||||
title: "等级",
|
||||
key: "grade",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
]
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (aiStore.duration.includes("hours")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
|
||||
} else if (aiStore.duration.includes("days")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
|
||||
} else if (
|
||||
aiStore.duration.includes("weeks") ||
|
||||
aiStore.duration.includes("months")
|
||||
) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
|
||||
} else {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
|
||||
}
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
return {
|
||||
S: "要不试试高难度题目?",
|
||||
A: "你很棒,继续保持!",
|
||||
B: "请再接再厉!",
|
||||
C: "你还需要努力!",
|
||||
}[aiStore.detailsData.grade]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => aiStore.duration,
|
||||
() => {
|
||||
aiStore.fetchDetailsData(props.start, props.end)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.charming {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div class="chart" v-if="show">
|
||||
<Bar :data="data" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
|
||||
const props = defineProps<{
|
||||
difficulty: { [key: string]: number }
|
||||
}>()
|
||||
|
||||
const show = computed(() => {
|
||||
return Object.values(props.difficulty).reduce((a, b) => a + b, 0) > 0
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
return {
|
||||
labels: Object.keys(props.difficulty),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(props.difficulty),
|
||||
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
text: "题目的难度统计",
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
144
src/oj/ai/components/DifficultyGradeChart.vue
Normal file
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>
|
||||
<div class="chart">
|
||||
<n-h1 class="title">过去一年的提交次数热力图</n-h1>
|
||||
<n-heatmap
|
||||
:loading="!data.length"
|
||||
:color-theme="getRandomColorTheme()"
|
||||
size="large"
|
||||
:data="data"
|
||||
:tooltip="{ placement: 'top' }"
|
||||
>
|
||||
<template #tooltip="{ timestamp, value }">
|
||||
<div>{{ new Date(timestamp).toLocaleDateString() }}</div>
|
||||
<div>提交次数: {{ value }}</div>
|
||||
</template>
|
||||
</n-heatmap>
|
||||
</div>
|
||||
<n-card title="过去一年的提交热力图" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.heatmap">
|
||||
<div class="heatmap-container" ref="containerRef">
|
||||
<svg
|
||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
class="heatmap-svg"
|
||||
>
|
||||
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
|
||||
<text :x="label.x" :y="10" class="label" font-size="10">
|
||||
{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g v-for="(day, i) in WEEK_DAYS" :key="i">
|
||||
<text
|
||||
:x="0"
|
||||
:y="MONTH_HEIGHT + i * CELL_TOTAL + 8"
|
||||
class="label"
|
||||
font-size="9"
|
||||
>
|
||||
{{ day }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g :transform="`translate(${DAY_WIDTH}, ${MONTH_HEIGHT})`">
|
||||
<rect
|
||||
v-for="(cell, i) in cells"
|
||||
:key="i"
|
||||
:x="cell.x"
|
||||
:y="cell.y"
|
||||
:width="CELL_SIZE"
|
||||
:height="CELL_SIZE"
|
||||
:fill="cell.color"
|
||||
class="cell"
|
||||
rx="2"
|
||||
@mouseenter="(e) => showTooltip(e, cell)"
|
||||
@mouseleave="hideTooltip"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
|
||||
<div class="tooltip-date">{{ tooltip.date }}</div>
|
||||
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
|
||||
{{ tooltip.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type HeatmapData } from "naive-ui"
|
||||
import { getAIHeatmapData } from "~/oj/api"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const data = ref<HeatmapData>([])
|
||||
const aiStore = useAIStore()
|
||||
const containerRef = ref<HTMLElement>()
|
||||
|
||||
function getRandomColorTheme() {
|
||||
const themes = ["green", "blue", "orange", "purple", "red"] as const
|
||||
return themes[Math.floor(Math.random() * themes.length)]
|
||||
}
|
||||
const CELL_SIZE = 12
|
||||
const CELL_GAP = 3
|
||||
const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
||||
const DAY_WIDTH = 20
|
||||
const MONTH_HEIGHT = 20
|
||||
const RIGHT_PADDING = 5
|
||||
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getAIHeatmapData()
|
||||
data.value = res.data
|
||||
const getColor = (count: number) =>
|
||||
count === 0
|
||||
? COLORS[0]
|
||||
: count <= 2
|
||||
? COLORS[1]
|
||||
: count <= 4
|
||||
? COLORS[2]
|
||||
: count <= 7
|
||||
? COLORS[3]
|
||||
: COLORS[4]
|
||||
|
||||
const cells = computed(() =>
|
||||
aiStore.heatmapData.map((item, i) => ({
|
||||
date: new Date(item.timestamp),
|
||||
count: item.value,
|
||||
color: getColor(item.value),
|
||||
week: Math.floor(i / 7),
|
||||
day: i % 7,
|
||||
x: Math.floor(i / 7) * CELL_TOTAL,
|
||||
y: (i % 7) * CELL_TOTAL,
|
||||
})),
|
||||
)
|
||||
|
||||
const monthLabels = computed(() => {
|
||||
const labels: { text: string; x: number }[] = []
|
||||
let lastMonth = -1
|
||||
|
||||
cells.value.forEach((cell, i) => {
|
||||
const month = cell.date.getMonth()
|
||||
const isWeekStart = cell.date.getDay() === 0 || i === 0
|
||||
|
||||
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
|
||||
labels.push({
|
||||
text: `${month + 1}月`,
|
||||
x: DAY_WIDTH + cell.week * CELL_TOTAL,
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
})
|
||||
|
||||
return labels
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
margin: 0 auto;
|
||||
|
||||
const svgWidth = computed(
|
||||
() =>
|
||||
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
|
||||
)
|
||||
|
||||
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
|
||||
|
||||
interface Cell {
|
||||
date: Date
|
||||
count: number
|
||||
color: string
|
||||
week: number
|
||||
day: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
.title {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
|
||||
const tooltip = ref<{
|
||||
x: number
|
||||
y: number
|
||||
date: string
|
||||
text: string
|
||||
count: number
|
||||
} | null>(null)
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltip.value?.x}px`,
|
||||
top: `${tooltip.value?.y}px`,
|
||||
}))
|
||||
|
||||
const getTooltipText = (count: number) =>
|
||||
count === 0 ? "没有提交记录" : `提交了 ${count} 次`
|
||||
|
||||
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
const containerRect = containerRef.value?.getBoundingClientRect()
|
||||
|
||||
if (containerRect) {
|
||||
tooltip.value = {
|
||||
x: rect.left - containerRect.left + rect.width / 2,
|
||||
y: rect.top - containerRect.top - 10,
|
||||
date: parseTime(cell.date, "YYYY年M月D日"),
|
||||
text: getTooltipText(cell.count),
|
||||
count: cell.count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
tooltip.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-container {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.label {
|
||||
fill: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cell {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
stroke: rgba(0, 0, 0, 0.05);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
stroke: rgba(0, 0, 0, 0.3);
|
||||
stroke-width: 1.5;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-date {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tooltip-count {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tooltip-count.active {
|
||||
color: #7bc96f;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-100% - 5px));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
63
src/oj/ai/components/Overview.vue
Normal file
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>
|
||||
import { NTag } from "naive-ui"
|
||||
import { getAnnouncement, getAnnouncementList } from "~/oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import { Announcement } from "~/utils/types"
|
||||
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { Announcement } from "utils/types"
|
||||
import TitleWithTag from "./components/TitleWithTag.vue"
|
||||
|
||||
const total = ref(0)
|
||||
|
||||
@@ -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() {
|
||||
return http.get("pickone")
|
||||
}
|
||||
@@ -84,7 +92,7 @@ export function submitCode(data: SubmitCodePayload) {
|
||||
return http.post("submission", data)
|
||||
}
|
||||
|
||||
export function getSubmissions(params: SubmissionListPayload) {
|
||||
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
||||
return http.get(endpoint, { params })
|
||||
}
|
||||
@@ -249,13 +257,10 @@ export function getAIDetailData(start: string, end: string) {
|
||||
return http.get("ai/detail", { params: { start, end } })
|
||||
}
|
||||
|
||||
export function getAIWeeklyData(
|
||||
end: string,
|
||||
duration: string,
|
||||
) {
|
||||
return http.get("ai/weekly", { params: { end, duration } })
|
||||
export function getAIDurationData(end: string, duration: string) {
|
||||
return http.get("ai/duration", { params: { end, duration } })
|
||||
}
|
||||
|
||||
export function getAIHeatmapData() {
|
||||
return http.get("ai/heatmap")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { STORAGE_KEY } from "~/utils/constants"
|
||||
import storage from "~/utils/storage"
|
||||
import { Code } from "~/utils/types"
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
import { Code } from "utils/types"
|
||||
|
||||
export const code = reactive<Code>({
|
||||
value: "",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { Problem } from "~/utils/types"
|
||||
import { Problem } from "utils/types"
|
||||
|
||||
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">
|
||||
import { ContestRank } from "~/utils/types"
|
||||
import { ContestRank } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
rank: ContestRank
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { parseTime } from "utils/functions"
|
||||
import ContestType from "~/shared/components/ContestType.vue"
|
||||
import ContestType from "shared/components/ContestType.vue"
|
||||
|
||||
const contestStore = useContestStore()
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { ContestStatus } from "~/utils/constants"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
import { useContestStore } from "../store/contest"
|
||||
import ContestInfo from "./components/ContestInfo.vue"
|
||||
import ContestMenu from "./components/ContestMenu.vue"
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { NTag } from "naive-ui"
|
||||
import { getContestList } from "oj/api"
|
||||
import { duration, parseTime } from "utils/functions"
|
||||
import { Contest } from "utils/types"
|
||||
import ContestTitle from "~/shared/components/ContestTitle.vue"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { toggleLogin } from "~/shared/composables/modal"
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { CONTEST_STATUS, ContestType } from "~/utils/constants"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { toggleLogin } from "shared/composables/modal"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { CONTEST_STATUS, ContestType } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -22,9 +23,9 @@ interface ContestQuery {
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ContestQuery>({
|
||||
keyword: "",
|
||||
status: "",
|
||||
tag: "",
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
status: useRouteQuery("status", "").value,
|
||||
tag: useRouteQuery("tag", "").value,
|
||||
})
|
||||
|
||||
const data = ref<Contest[]>([])
|
||||
@@ -95,7 +96,6 @@ async function listContests() {
|
||||
total.value = res.data.total
|
||||
}
|
||||
|
||||
|
||||
function search(value: string) {
|
||||
query.keyword = value
|
||||
}
|
||||
@@ -107,17 +107,13 @@ function clear() {
|
||||
onMounted(listContests)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
listContests,
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watchDebounced(() => query.keyword, listContests, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit, query.status, query.tag],
|
||||
listContests,
|
||||
)
|
||||
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
|
||||
|
||||
function rowProps(row: Contest) {
|
||||
return {
|
||||
@@ -138,26 +134,30 @@ function rowProps(row: Contest) {
|
||||
<n-form :show-feedback="false" label-placement="left" inline>
|
||||
<n-form-item label="比赛状态">
|
||||
<n-select
|
||||
class="select"
|
||||
style="width: 120px"
|
||||
:options="options"
|
||||
v-model:value="query.status"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="标签">
|
||||
<n-select class="select" :options="tags" v-model:value="query.tag" />
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
:options="tags"
|
||||
v-model:value="query.tag"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form :show-feedback="false" label-placement="left" inline>
|
||||
<n-form-item>
|
||||
<n-input
|
||||
class="input"
|
||||
style="width: 180px"
|
||||
clearable
|
||||
v-model:value="query.keyword"
|
||||
placeholder="比赛标题"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-flex>
|
||||
<n-flex :wrap="false">
|
||||
<n-button @click="search(query.keyword)">搜索</n-button>
|
||||
<n-button @click="clear" quaternary>重置</n-button>
|
||||
</n-flex>
|
||||
@@ -177,13 +177,3 @@ function rowProps(row: Contest) {
|
||||
:total="total"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ProblemFiltered } from "utils/types"
|
||||
import ProblemStatus from "~/oj/problem/components/ProblemStatus.vue"
|
||||
import { useContestStore } from "~/oj/store/contest"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
|
||||
const props = defineProps<{ contestID: string }>()
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Icon } from "@iconify/vue"
|
||||
import { NButton, useThemeVars } from "naive-ui"
|
||||
import { getContestProblems, getContestRank } from "oj/api"
|
||||
import { secondsToDuration } from "utils/functions"
|
||||
import { useContestStore } from "~/oj/store/contest"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { ContestStatus } from "~/utils/constants"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import { ContestRank, ProblemFiltered } from "~/utils/types"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { ContestRank, ProblemFiltered } from "utils/types"
|
||||
import AcAndSubmission from "../components/AcAndSubmission.vue"
|
||||
|
||||
interface Props {
|
||||
@@ -167,11 +167,15 @@ async function addColumns() {
|
||||
const status = row.submission_info[problem.id]
|
||||
if (status.is_first_ac) {
|
||||
backgroundColor = theme.value.primaryColor
|
||||
color = theme.value.baseColor
|
||||
} else if (status.is_ac) {
|
||||
backgroundColor = "#dff0d8"
|
||||
const success = theme.value.successColor
|
||||
backgroundColor = success + "50"
|
||||
color = theme.value.textColorBase
|
||||
} else {
|
||||
backgroundColor = theme.value.warningColor
|
||||
color = theme.value.errorColor
|
||||
const error = theme.value.errorColor
|
||||
backgroundColor = error + "50"
|
||||
color = theme.value.textColorBase
|
||||
}
|
||||
}
|
||||
return { style: { backgroundColor, color } }
|
||||
|
||||
@@ -1,78 +1,114 @@
|
||||
<template>
|
||||
<n-grid :cols="2" :x-gap="24" v-if="!!tutorial.id">
|
||||
<n-gi :span="1">
|
||||
<n-flex vertical>
|
||||
<n-flex align="center">
|
||||
<n-button text :disabled="step == 1" @click="prev">
|
||||
<Icon :width="30" icon="pepicons-pencil:arrow-left"></Icon>
|
||||
</n-button>
|
||||
<n-dropdown size="large" :options="menu" trigger="click">
|
||||
<n-button tertiary style="flex: 1" size="large">
|
||||
<n-flex align="center">
|
||||
<span style="font-weight: bold">
|
||||
{{ title }}
|
||||
</span>
|
||||
<Icon :width="24" icon="proicons:chevron-down"></Icon>
|
||||
</n-flex>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button text :disabled="step == titles.length" @click="next">
|
||||
<Icon :width="30" icon="pepicons-pencil:arrow-right"></Icon>
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex vertical size="large">
|
||||
<div>
|
||||
<!-- 桌面端布局 -->
|
||||
<n-grid :cols="5" :x-gap="16" v-if="tutorial.id && isDesktop">
|
||||
<n-gi :span="1">
|
||||
<n-card title="教程目录" :bordered="false" size="small">
|
||||
<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-text>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="tutorial.code ? 2 : 4">
|
||||
<n-card
|
||||
:title="`第 ${step} 课:${titles[step - 1]?.title}`"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
:model-value="tutorial.content"
|
||||
/>
|
||||
<n-flex justify="space-between">
|
||||
<div style="flex: 1">
|
||||
<n-button
|
||||
block
|
||||
style="height: 40px"
|
||||
v-if="step !== 1"
|
||||
@click="prev"
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="2" v-if="tutorial.code">
|
||||
<n-card title="示例代码" :bordered="false" size="small">
|
||||
<CodeEditor language="Python3" v-model="tutorial.code" />
|
||||
</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"
|
||||
>
|
||||
上一课时
|
||||
</n-button>
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<n-button
|
||||
block
|
||||
style="height: 40px"
|
||||
v-if="step !== titles.length"
|
||||
@click="next"
|
||||
>
|
||||
下一课时
|
||||
</n-button>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
{{ index + 1 }}. {{ item.title }}
|
||||
</n-text>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="content" :tab="`第 ${step} 课`">
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
:model-value="tutorial.content"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<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-gi>
|
||||
<n-gi :span="1">
|
||||
<n-flex vertical>
|
||||
<CodeEditor
|
||||
v-show="!!tutorial.code"
|
||||
language="Python3"
|
||||
v-model="tutorial.code"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { Tutorial } from "~/utils/types"
|
||||
import { Tutorial } from "utils/types"
|
||||
import { getTutorial, getTutorials } from "../api"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("~/shared/components/CodeEditor.vue"),
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
|
||||
const isDark = useDark()
|
||||
@@ -94,23 +130,28 @@ const tutorial = ref<Partial<Tutorial>>({
|
||||
})
|
||||
|
||||
const titles = ref<{ id: number; title: string }[]>([])
|
||||
const title = computed(
|
||||
() => `第 ${step.value} 课:${titles.value[step.value - 1].title}`,
|
||||
)
|
||||
const activeTab = ref("content")
|
||||
|
||||
const menu = computed<DropdownOption[]>(() => {
|
||||
return titles.value.map((item, index) => {
|
||||
const id = (index + 1).toString().padStart(2, "0")
|
||||
const prefix = `第 ${index + 1} 课:`
|
||||
return {
|
||||
key: id,
|
||||
label: prefix + item.title,
|
||||
props: {
|
||||
onClick: () => router.push(`/learn/${id}`),
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
const isFirstLesson = computed(() => step.value === 1)
|
||||
const isLastLesson = computed(() => step.value === titles.value.length)
|
||||
|
||||
function goToLesson(lessonNumber: number) {
|
||||
activeTab.value = "content"
|
||||
const dest = lessonNumber.toString().padStart(2, "0")
|
||||
router.push("/learn/" + dest)
|
||||
}
|
||||
|
||||
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() {
|
||||
const res1 = await getTutorials()
|
||||
@@ -121,18 +162,6 @@ async function init() {
|
||||
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(
|
||||
() => route.params.step,
|
||||
async () => {
|
||||
@@ -142,9 +171,3 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
</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 { problem } from "oj/composables/problem"
|
||||
import { SOURCES } from "utils/constants"
|
||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import storage from "~/utils/storage"
|
||||
import CodeEditor from "shared/components/CodeEditor.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 contestID = !!route.params.contestID ? route.params.contestID : null
|
||||
|
||||
const contestID = route.params.contestID || null
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`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(() =>
|
||||
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)
|
||||
}
|
||||
|
||||
function changeLanguage(v: string) {
|
||||
if (
|
||||
storage.get(storageKey.value) &&
|
||||
storageKey.value.split("_").pop() === v
|
||||
) {
|
||||
code.value = storage.get(storageKey.value)
|
||||
} else {
|
||||
code.value =
|
||||
problem.value!.template[code.language] || SOURCES[code.language]
|
||||
}
|
||||
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]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -50,11 +46,9 @@ function changeLanguage(v: string) {
|
||||
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
||||
<CodeEditor
|
||||
v-model:value="code.value"
|
||||
@update:model-value="changeCode"
|
||||
:language="code.language"
|
||||
:height="editorHeight"
|
||||
@update:model-value="changeCode"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -2,11 +2,14 @@
|
||||
import { code, input, output } from "oj/composables/code"
|
||||
import { problem } from "oj/composables/problem"
|
||||
import { SOURCES } from "utils/constants"
|
||||
import CodeEditor from "~/shared/components/CodeEditor.vue"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import storage from "~/utils/storage"
|
||||
import Form from "./Form.vue"
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import storage from "utils/storage"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
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 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) {
|
||||
storage.set(storageKey.value, v)
|
||||
}
|
||||
@@ -43,23 +42,62 @@ function changeLanguage(v: string) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<Form
|
||||
:storage-key="storageKey"
|
||||
@change-language="changeLanguage"
|
||||
with-test
|
||||
/>
|
||||
<n-flex vertical style="height: calc(100vh - 92px)">
|
||||
<n-split direction="horizontal" :min="1 / 3" :max="4 / 5">
|
||||
<template #1>
|
||||
<CodeEditor
|
||||
v-model:value="code.value"
|
||||
@update:model-value="changeCode"
|
||||
:language="code.language"
|
||||
:height="editorHeight"
|
||||
/>
|
||||
<n-flex vertical>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="code.language"
|
||||
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 #2>
|
||||
<n-split
|
||||
@@ -1,146 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import copyText from "copy-text-to-clipboard"
|
||||
import { code, input, output } from "oj/composables/code"
|
||||
import { copyToClipboard } from "utils/functions"
|
||||
import { code } from "oj/composables/code"
|
||||
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 { isDesktop, isMobile } from "~/shared/composables/breakpoints"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { createTestSubmission } from "~/utils/judge"
|
||||
import storage from "~/utils/storage"
|
||||
import { LANGUAGE } from "~/utils/types"
|
||||
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import storage from "utils/storage"
|
||||
import { LANGUAGE } from "utils/types"
|
||||
import Submit from "./Submit.vue"
|
||||
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||
import IconButton from "shared/components/IconButton.vue"
|
||||
|
||||
interface Props {
|
||||
storageKey: string
|
||||
withTest?: boolean
|
||||
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
|
||||
}
|
||||
|
||||
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 route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const emit = defineEmits(["changeLanguage"])
|
||||
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
||||
const statisticPanel = ref(false)
|
||||
|
||||
function copy() {
|
||||
copyText(code.value)
|
||||
message.success("代码复制成功")
|
||||
// 计算属性
|
||||
const isContestMode = computed(() => route.name === "contest problem")
|
||||
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]
|
||||
storage.remove(props.storageKey)
|
||||
message.success("代码重置成功")
|
||||
}
|
||||
|
||||
function goSubmissions() {
|
||||
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) {
|
||||
const changeLanguage = (v: LANGUAGE) => {
|
||||
storage.set(STORAGE_KEY.LANGUAGE, v)
|
||||
emit("changeLanguage", v)
|
||||
}
|
||||
|
||||
function gotoTestCat() {
|
||||
const url = import.meta.env.PUBLIC_CODE_URL
|
||||
window.open(url, "_blank")
|
||||
const goTestCat = () => {
|
||||
window.open(import.meta.env.PUBLIC_CODE_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>
|
||||
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
class="language"
|
||||
v-model:value="code.language"
|
||||
style="width: 120px"
|
||||
:size="buttonSize"
|
||||
:options="languageOptions"
|
||||
@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>
|
||||
.language {
|
||||
width: 120px;
|
||||
}
|
||||
</style>
|
||||
<Submit />
|
||||
|
||||
<n-button
|
||||
v-if="!userStore.isSuperAdmin && userStore.showSubmissions"
|
||||
: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 { problem } from "oj/composables/problem"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { createComment, getComment, getCommentStatistics } from "~/oj/api"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { createComment, getComment, getCommentStatistics } from "oj/api"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
interface Props {
|
||||
showStatistics?: boolean
|
||||
|
||||
@@ -5,7 +5,7 @@ import { code } from "oj/composables/code"
|
||||
import { problem } from "oj/composables/problem"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
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] & {
|
||||
id: number
|
||||
@@ -17,6 +17,18 @@ type Sample = Problem["samples"][number] & {
|
||||
const theme = useThemeVars()
|
||||
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[]>(
|
||||
problem.value!.samples.map((sample, index) => ({
|
||||
...sample,
|
||||
@@ -89,13 +101,24 @@ function type(status: ProblemStatus) {
|
||||
|
||||
<template>
|
||||
<div v-if="problem" class="problemContent">
|
||||
<!-- 已通过 -->
|
||||
<n-alert
|
||||
class="success"
|
||||
class="status-alert"
|
||||
v-if="problem.my_status === 0"
|
||||
type="success"
|
||||
title="🎉 本 题 已 经 被 你 解 决 啦"
|
||||
/>
|
||||
>
|
||||
</n-alert>
|
||||
|
||||
<!-- 尝试过但未通过 -->
|
||||
<n-alert
|
||||
class="status-alert"
|
||||
v-else-if="hasTriedButNotPassed"
|
||||
type="warning"
|
||||
title="💪 你已经尝试过这道题,但还没有通过"
|
||||
>
|
||||
不要放弃!仔细检查代码逻辑,或者寻求 AI 的帮助获取灵感。
|
||||
</n-alert>
|
||||
<n-flex align="center">
|
||||
<n-tag>{{ problem._id }}</n-tag>
|
||||
<h2 class="problemTitle">{{ problem.title }}</h2>
|
||||
@@ -181,116 +204,138 @@ function type(status: ProblemStatus) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.problemContent .problemTitle {
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
|
||||
.problemContent .title {
|
||||
.title {
|
||||
font-size: 20px;
|
||||
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;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.problemContent .testcase {
|
||||
font-size: 14px;
|
||||
white-space: pre;
|
||||
font-family: "Monaco";
|
||||
}
|
||||
|
||||
.problemContent .success {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.problemContent .content > p {
|
||||
/* 针对 v-html 渲染内容的深度样式 */
|
||||
.content :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.problemContent .content > blockquote {
|
||||
border-left: 3px solid #bbbec4;
|
||||
.content :deep(blockquote) {
|
||||
border-left: 3px solid var(--blockquote-border);
|
||||
padding-left: 10px;
|
||||
margin: 10px 0;
|
||||
color: #7b7b7b;
|
||||
color: var(--blockquote-color);
|
||||
}
|
||||
|
||||
.problemContent .content > pre {
|
||||
.content :deep(pre) {
|
||||
width: 100%;
|
||||
background-color: rgb(250, 250, 252);
|
||||
border: 1px solid rgb(239, 239, 245);
|
||||
background-color: var(--bg-code-light);
|
||||
border: 1px solid var(--border-color-light);
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color var(--transition),
|
||||
border-color var(--transition);
|
||||
}
|
||||
|
||||
.problemContent .content > pre > code {
|
||||
font-family: "Monaco";
|
||||
.content :deep(pre code) {
|
||||
font-family: "Monaco", monospace;
|
||||
}
|
||||
|
||||
.dark .problemContent .content > pre {
|
||||
background-color: rgb(24, 24, 28);
|
||||
border-color: rgba(255, 255, 255, 0.09);
|
||||
.dark .content :deep(pre) {
|
||||
background-color: var(--bg-code-dark);
|
||||
border-color: var(--border-color-dark);
|
||||
}
|
||||
|
||||
.problemContent .content > table {
|
||||
.content :deep(table) {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
border: 1px solid rgba(239, 239, 245, 1);
|
||||
border: 1px solid var(--border-color-light);
|
||||
transition:
|
||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color var(--transition),
|
||||
border-color var(--transition);
|
||||
}
|
||||
|
||||
.problemContent .content > table th {
|
||||
background-color: rgba(250, 250, 252, 1);
|
||||
.content :deep(table th) {
|
||||
background-color: var(--bg-table-light);
|
||||
padding: 8px;
|
||||
transition:
|
||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color var(--transition),
|
||||
border-color var(--transition);
|
||||
}
|
||||
|
||||
.dark .problemContent .content > table th {
|
||||
background-color: rgba(38, 38, 42, 1);
|
||||
.dark .content :deep(table th) {
|
||||
background-color: var(--bg-table-dark);
|
||||
}
|
||||
|
||||
.problemContent .content > table td {
|
||||
.content :deep(table td) {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.problemContent .content > table td,
|
||||
.problemContent .content > table th {
|
||||
border-right: 1px solid rgba(239, 239, 245, 1);
|
||||
border-bottom: 1px solid rgba(239, 239, 245, 1);
|
||||
.content :deep(table td),
|
||||
.content :deep(table th) {
|
||||
border-right: 1px solid var(--border-color-light);
|
||||
border-bottom: 1px solid var(--border-color-light);
|
||||
}
|
||||
|
||||
.problemContent .content > table th:last-child,
|
||||
.problemContent .content > table td:last-child {
|
||||
border-right: 0px solid rgba(239, 239, 245, 1);
|
||||
.content :deep(table th:last-child),
|
||||
.content :deep(table td:last-child) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.problemContent .content > table tr:last-child td {
|
||||
border-bottom: 0px solid rgba(239, 239, 245, 1);
|
||||
.content :deep(table tr:last-child td) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.problemContent .content > p > code {
|
||||
.content :deep(p code) {
|
||||
font-size: 90%;
|
||||
padding: 2px 5px;
|
||||
margin: 0px 4px;
|
||||
margin: 0 4px;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
line-height: 1.5;
|
||||
font-family: "Monaco", monospace;
|
||||
}
|
||||
|
||||
.problemContent .content img {
|
||||
max-width: 100% !important;
|
||||
height: 100% !important;
|
||||
.content :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.problemContent .content a {
|
||||
color: #18a058;
|
||||
.content :deep(a) {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.content :deep(a:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</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 { getACRateNumber, getTagColor, parseTime } from "utils/functions"
|
||||
import { Pie } from "vue-chartjs"
|
||||
import { getProblemBeatRate } from "~/oj/api"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import {
|
||||
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")
|
||||
|
||||
@@ -55,7 +66,7 @@ const numbers = computed(() => {
|
||||
{
|
||||
icon: "streamline-emojis:sparkles",
|
||||
title: parseFloat(beatRate.value),
|
||||
content: "你击败的用户",
|
||||
content: "击败用户",
|
||||
int: false,
|
||||
suffix: "%",
|
||||
},
|
||||
@@ -109,7 +120,7 @@ onMounted(getBeatRate)
|
||||
<n-gi v-for="item in numbers" :key="item.content">
|
||||
<n-card hoverable>
|
||||
<n-flex align="center">
|
||||
<Icon :icon="item.icon" width="40" />
|
||||
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
|
||||
<div>
|
||||
<n-h2 class="number">
|
||||
<n-number-animation
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import { getSubmissions, getRankOfProblem } from "~/oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
||||
import { useConfigStore } from "~/shared/store/config"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { LANGUAGE_SHOW_VALUE } from "~/utils/constants"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import { Submission } from "~/utils/types"
|
||||
import { getSubmissions, getRankOfProblem } from "oj/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { Submission } from "utils/types"
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -75,16 +73,9 @@ const query = reactive({
|
||||
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(() => {
|
||||
if (!userStore.isAuthed) return "请先登录"
|
||||
else if (!configStore.config.submission_list_show_all)
|
||||
return "提交列表已被管理员关闭"
|
||||
else if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
|
||||
else return ""
|
||||
})
|
||||
|
||||
@@ -102,7 +93,6 @@ async function listSubmissions() {
|
||||
}
|
||||
|
||||
async function getRankOfThisProblem() {
|
||||
|
||||
loading.value = true
|
||||
const res = await getRankOfProblem(<string>route.params.problemID ?? "")
|
||||
loading.value = false
|
||||
@@ -122,19 +112,25 @@ onMounted(() => {
|
||||
watch(query, listSubmissions)
|
||||
</script>
|
||||
<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="class_name">
|
||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
<span>
|
||||
本道题你在班上排名第 <b>{{ rank }}</b
|
||||
>,你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
|
||||
</div>
|
||||
</span>
|
||||
<n-button
|
||||
v-if="showList"
|
||||
secondary
|
||||
v-if="userStore.showSubmissions"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
@@ -153,16 +149,20 @@ watch(query, listSubmissions)
|
||||
</n-flex>
|
||||
</template>
|
||||
</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>
|
||||
<n-flex>
|
||||
<div>
|
||||
本道题你还没有解决,
|
||||
<div v-if="class_name">你们班</div>
|
||||
共有 <b>{{ class_ac_count }}</b> 人答案正确
|
||||
</div>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你还没有解决,你们班共有
|
||||
<b>{{ class_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
v-if="showList"
|
||||
v-if="userStore.showSubmissions"
|
||||
secondary
|
||||
@click="
|
||||
router.push({
|
||||
@@ -187,14 +187,13 @@ watch(query, listSubmissions)
|
||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
<span>
|
||||
本道题你在全服排名第 <b>{{ rank }}</b
|
||||
>,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||
</div>
|
||||
<div></div>
|
||||
</span>
|
||||
<n-button
|
||||
secondary
|
||||
v-if="showList"
|
||||
v-if="userStore.showSubmissions"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
@@ -212,15 +211,19 @@ watch(query, listSubmissions)
|
||||
</n-flex>
|
||||
</template>
|
||||
</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>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
本道题你还没有解决,全服共有
|
||||
<b>{{ all_ac_count }}</b> 人答案正确
|
||||
</div>
|
||||
<span>
|
||||
本道题你还没有解决,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
v-if="showList"
|
||||
v-if="userStore.showSubmissions"
|
||||
secondary
|
||||
@click="
|
||||
router.push({
|
||||
@@ -242,8 +245,13 @@ watch(query, listSubmissions)
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="showList">
|
||||
<n-data-table striped :columns="columns" :data="submissions" />
|
||||
<template v-if="userStore.showSubmissions && userStore.isAuthed">
|
||||
<n-data-table
|
||||
v-if="submissions.length > 0"
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="submissions"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
|
||||
@@ -7,9 +7,13 @@ import { problem } from "oj/composables/problem"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions"
|
||||
import { Submission, SubmitCodePayload } from "utils/types"
|
||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
import {
|
||||
useSubmissionWebSocket,
|
||||
type SubmissionUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const ProblemComment = defineAsyncComponent(
|
||||
() => import("./ProblemComment.vue"),
|
||||
@@ -39,7 +43,7 @@ const { start: showCommentPanel } = useTimeoutFn(
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
const { start: fetchSubmission } = useTimeoutFn(
|
||||
const { start: fetchSubmission, stop: stopFetchSubmission } = useTimeoutFn(
|
||||
async () => {
|
||||
const res = await getSubmission(submissionId.value)
|
||||
submission.value = res.data
|
||||
@@ -57,45 +61,76 @@ const { start: fetchSubmission } = useTimeoutFn(
|
||||
{ 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(
|
||||
() =>
|
||||
!!(
|
||||
submission.value && submission.value.result === SubmissionStatus.judging
|
||||
),
|
||||
() => submission.value?.result === SubmissionStatus.judging,
|
||||
)
|
||||
|
||||
const pending = computed(
|
||||
() =>
|
||||
!!(
|
||||
submission.value && submission.value.result === SubmissionStatus.pending
|
||||
),
|
||||
() => submission.value?.result === SubmissionStatus.pending,
|
||||
)
|
||||
|
||||
const submitting = computed(
|
||||
() =>
|
||||
!!(
|
||||
submission.value &&
|
||||
submission.value.result === SubmissionStatus.submitting
|
||||
),
|
||||
() => submission.value?.result === SubmissionStatus.submitting,
|
||||
)
|
||||
|
||||
const submitDisabled = computed(() => {
|
||||
if (!userStore.isAuthed) {
|
||||
return true
|
||||
}
|
||||
if (code.value.trim() === "") {
|
||||
return true
|
||||
}
|
||||
if (judging.value || pending.value || submitting.value) {
|
||||
return true
|
||||
}
|
||||
if (submitted.value) {
|
||||
return true
|
||||
}
|
||||
if (isPending.value) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return (
|
||||
!userStore.isAuthed ||
|
||||
code.value.trim() === "" ||
|
||||
judging.value ||
|
||||
pending.value ||
|
||||
submitting.value ||
|
||||
submitted.value ||
|
||||
isPending.value
|
||||
)
|
||||
})
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
@@ -115,43 +150,39 @@ const submitLabel = computed(() => {
|
||||
})
|
||||
|
||||
const msg = computed(() => {
|
||||
if (!submission.value) return ""
|
||||
|
||||
let msg = ""
|
||||
const result = submission.value && submission.value.result
|
||||
const result = submission.value.result
|
||||
|
||||
if (
|
||||
result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error
|
||||
) {
|
||||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||||
}
|
||||
if (
|
||||
submission.value &&
|
||||
submission.value.statistic_info &&
|
||||
submission.value.statistic_info.err_info
|
||||
) {
|
||||
|
||||
if (submission.value.statistic_info?.err_info) {
|
||||
msg += submission.value.statistic_info.err_info
|
||||
}
|
||||
|
||||
return msg
|
||||
})
|
||||
|
||||
const infoTable = computed(() => {
|
||||
if (!submission.value?.info?.data?.length) return []
|
||||
|
||||
const result = submission.value.result
|
||||
if (
|
||||
submission.value &&
|
||||
submission.value.result !== SubmissionStatus.accepted &&
|
||||
submission.value.result !== SubmissionStatus.compile_error &&
|
||||
submission.value.result !== SubmissionStatus.runtime_error &&
|
||||
submission.value.info &&
|
||||
submission.value.info.data &&
|
||||
submission.value.info.data.length
|
||||
result === SubmissionStatus.accepted ||
|
||||
result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error
|
||||
) {
|
||||
const data = submission.value.info.data
|
||||
if (data.some((item) => item.result === 0)) {
|
||||
return submission.value.info.data
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = submission.value.info.data
|
||||
return data.some((item) => item.result === 0) ? data : []
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
||||
@@ -175,9 +206,8 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
||||
]
|
||||
|
||||
async function submit() {
|
||||
if (!userStore.isAuthed) {
|
||||
return
|
||||
}
|
||||
if (!userStore.isAuthed) return
|
||||
|
||||
const data: SubmitCodePayload = {
|
||||
problem_id: problem.value!.id,
|
||||
language: code.language,
|
||||
@@ -186,35 +216,73 @@ async function submit() {
|
||||
if (contestID) {
|
||||
data.contest_id = parseInt(contestID)
|
||||
}
|
||||
|
||||
submission.value = { result: 9 } as Submission
|
||||
const res = await submitCode(data)
|
||||
submissionId.value = res.data.submission_id
|
||||
// 防止重复提交
|
||||
console.log(`[Submit] 代码已提交: ID=${submissionId.value}`)
|
||||
submitPending()
|
||||
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(
|
||||
() => submission?.value?.result,
|
||||
() => submission.value?.result,
|
||||
(result) => {
|
||||
if (result === SubmissionStatus.accepted) {
|
||||
// 刷新题目状态
|
||||
problem.value!.my_status = 0
|
||||
// 放烟花
|
||||
confetti({
|
||||
particleCount: 300,
|
||||
startVelocity: 30,
|
||||
gravity: 0.5,
|
||||
spread: 350,
|
||||
origin: { x: 0.5, y: 0.4 },
|
||||
})
|
||||
// 题目在第一次完成之后,弹出点评框
|
||||
if (!contestID) {
|
||||
showCommentPanel()
|
||||
}
|
||||
}
|
||||
if (result !== SubmissionStatus.accepted) return
|
||||
|
||||
// 刷新题目状态
|
||||
problem.value!.my_status = 0
|
||||
// 放烟花
|
||||
confetti({
|
||||
particleCount: 300,
|
||||
startVelocity: 30,
|
||||
gravity: 0.5,
|
||||
spread: 350,
|
||||
origin: { x: 0.5, y: 0.4 },
|
||||
})
|
||||
// 题目在第一次完成之后,弹出点评框
|
||||
if (!contestID) showCommentPanel()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { getProblem } from "oj/api"
|
||||
import { ScreenMode } from "utils/constants"
|
||||
import { isDesktop, isMobile } from "~/shared/composables/breakpoints"
|
||||
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||
import {
|
||||
bothAndProblem,
|
||||
resetScreenMode,
|
||||
screenMode,
|
||||
} from "~/shared/composables/switchScreen"
|
||||
} from "shared/composables/switchScreen"
|
||||
import { problem } from "../composables/problem"
|
||||
|
||||
const Editor = defineAsyncComponent(() => import("./components/Editor.vue"))
|
||||
const EditorWithTest = defineAsyncComponent(
|
||||
() => import("./components/EditorWithTest.vue"),
|
||||
const ProblemEditor = defineAsyncComponent(
|
||||
() => import("./components/ProblemEditor.vue"),
|
||||
)
|
||||
const ContestEditor = defineAsyncComponent(
|
||||
() => import("./components/ContestEditor.vue"),
|
||||
)
|
||||
const EditorForTest = defineAsyncComponent(
|
||||
() => import("./components/EditorForTest.vue"),
|
||||
)
|
||||
const ProblemContent = defineAsyncComponent(
|
||||
() => import("./components/ProblemContent.vue"),
|
||||
@@ -54,6 +59,8 @@ const tabOptions = computed(() => {
|
||||
|
||||
const currentTab = ref("content")
|
||||
|
||||
const shouldUseProblemEditor = computed(() => route.name === "problem")
|
||||
|
||||
watch(
|
||||
[() => route.query.tab, () => tabOptions.value],
|
||||
([rawTab]) => {
|
||||
@@ -125,7 +132,8 @@ watch(isMobile, (value) => {
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="editor" tab="编辑">
|
||||
<Editor />
|
||||
<ProblemEditor v-if="shouldUseProblemEditor" />
|
||||
<ContestEditor v-else />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="统计">
|
||||
<ProblemInfo />
|
||||
@@ -139,10 +147,11 @@ watch(isMobile, (value) => {
|
||||
</n-tabs>
|
||||
</n-gi>
|
||||
<n-gi v-if="isDesktop && screenMode === ScreenMode.both">
|
||||
<Editor />
|
||||
<ProblemEditor v-if="shouldUseProblemEditor" />
|
||||
<ContestEditor v-else />
|
||||
</n-gi>
|
||||
<n-gi v-if="isDesktop && screenMode === ScreenMode.code">
|
||||
<EditorWithTest />
|
||||
<EditorForTest />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-empty v-else :description="errMsg"></n-empty>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
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 { ProblemFiltered } from "utils/types"
|
||||
import { getProblemTagList } from "~/shared/api"
|
||||
import Hitokoto from "~/shared/components/Hitokoto.vue"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import Hitokoto from "shared/components/Hitokoto.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import ProblemStatus from "./components/ProblemStatus.vue"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
|
||||
interface Tag {
|
||||
id: number
|
||||
@@ -23,6 +25,7 @@ interface ProblemQuery {
|
||||
keyword: string
|
||||
difficulty: string
|
||||
tag: string
|
||||
author: string
|
||||
}
|
||||
|
||||
const difficultyOptions = [
|
||||
@@ -42,9 +45,10 @@ const [showTag, toggleShowTag] = useToggle(isDesktop.value)
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: "",
|
||||
difficulty: "",
|
||||
tag: "",
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
difficulty: useRouteQuery("difficulty", "").value,
|
||||
tag: useRouteQuery("tag", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
})
|
||||
|
||||
async function listProblems() {
|
||||
@@ -54,6 +58,7 @@ async function listProblems() {
|
||||
keyword: query.keyword,
|
||||
tag: query.tag,
|
||||
difficulty: query.difficulty,
|
||||
author: query.author,
|
||||
})
|
||||
total.value = res.total
|
||||
problems.value = res.results
|
||||
@@ -67,11 +72,6 @@ async function listTags() {
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
function search(value: string) {
|
||||
query.keyword = value
|
||||
}
|
||||
|
||||
function chooseTag(tag: Tag) {
|
||||
query.tag = tag.checked ? "" : tag.name
|
||||
tags.value = tags.value.map((t) => {
|
||||
@@ -84,26 +84,21 @@ function chooseTag(tag: Tag) {
|
||||
})
|
||||
}
|
||||
|
||||
function clear() {
|
||||
clearQuery()
|
||||
async function getRandom() {
|
||||
const res = await getRandomProblemID()
|
||||
router.push("/problem/" + res.data)
|
||||
}
|
||||
|
||||
// async function getRandom() {
|
||||
// const res = await getRandomProblemID()
|
||||
// router.push("/problem/" + res.data)
|
||||
// }
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
listProblems,
|
||||
{ debounce: 500, maxWait: 1000 }
|
||||
)
|
||||
watchDebounced(() => query.keyword, listProblems, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.tag, query.difficulty, query.limit, query.page],
|
||||
listProblems
|
||||
() => [query.tag, query.difficulty, query.limit, query.page, query.author],
|
||||
listProblems,
|
||||
)
|
||||
|
||||
// 监听标签变化,更新标签选中状态
|
||||
@@ -117,14 +112,13 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 监听用户认证状态变化,只在认证完成且已登录时刷新问题列表
|
||||
watch(
|
||||
() => userStore.isFinished && userStore.isAuthed,
|
||||
(isAuthenticatedAndFinished) => {
|
||||
if (isAuthenticatedAndFinished) {
|
||||
listProblems()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -202,45 +196,49 @@ function rowProps(row: ProblemFiltered) {
|
||||
<template>
|
||||
<n-flex vertical size="large">
|
||||
<n-flex justify="space-between">
|
||||
<n-flex>
|
||||
<div>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item label="题目难度">
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
v-model:value="query.difficulty"
|
||||
:options="difficultyOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-input
|
||||
clearable
|
||||
style="width: 200px"
|
||||
v-model:value="query.keyword"
|
||||
placeholder="题号或者标题"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<div>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item>
|
||||
<n-flex align="center">
|
||||
<n-button @click="search(query.keyword)">搜索</n-button>
|
||||
<n-button @click="clear" quaternary>重置</n-button>
|
||||
<!-- <n-button @click="getRandom" quaternary>试试手气</n-button> -->
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<n-button @click="toggleShowTag()" quaternary icon-placement="right">
|
||||
<template #icon>
|
||||
<Icon v-if="showTag" icon="ph:caret-down"></Icon>
|
||||
<Icon v-else icon="ph:caret-up"></Icon>
|
||||
</template>
|
||||
标签
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-space>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item label="难度">
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
v-model:value="query.difficulty"
|
||||
:options="difficultyOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="出题者">
|
||||
<AuthorSelect v-model:value="query.author" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item>
|
||||
<n-input
|
||||
clearable
|
||||
style="width: 200px"
|
||||
v-model:value="query.keyword"
|
||||
placeholder="编号或者标题"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button @click="clearQuery" quaternary>重置</n-button>
|
||||
</n-form-item>
|
||||
<!-- <n-form-item>
|
||||
<n-button @click="getRandom" quaternary>随机</n-button>
|
||||
</n-form-item> -->
|
||||
<n-form-item>
|
||||
<n-button
|
||||
@click="toggleShowTag()"
|
||||
quaternary
|
||||
icon-placement="right"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon v-if="showTag" icon="ph:caret-down"></Icon>
|
||||
<Icon v-else icon="ph:caret-up"></Icon>
|
||||
</template>
|
||||
标签
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-space>
|
||||
<Hitokoto v-if="isDesktop" />
|
||||
</n-flex>
|
||||
<n-collapse-transition :show="showTag">
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
import { ChartType } from "~/utils/constants"
|
||||
import { Rank } from "~/utils/types"
|
||||
import {
|
||||
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 }>()
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { NButton } from "naive-ui"
|
||||
import { getActivityRank, getRank } from "oj/api"
|
||||
import { getACRate } from "utils/functions"
|
||||
import { Rank } from "utils/types"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { ChartType } from "~/utils/constants"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { ChartType } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import Chart from "./components/Chart.vue"
|
||||
import Index from "./components/Index.vue"
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DetailsData, WeeklyData } from "~/utils/types"
|
||||
import { consumeJSONEventStream } from "~/utils/stream"
|
||||
import { getAIDetailData, getAIWeeklyData } from "../api"
|
||||
import { getCSRFToken } from "~/utils/functions"
|
||||
import { DetailsData, DurationData } from "utils/types"
|
||||
import { consumeJSONEventStream } from "utils/stream"
|
||||
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api"
|
||||
import { getCSRFToken } from "utils/functions"
|
||||
|
||||
export const useAIStore = defineStore("ai", () => {
|
||||
const duration = ref("months:6")
|
||||
const weeklyData = ref<WeeklyData[]>([])
|
||||
const durationData = ref<DurationData[]>([])
|
||||
const detailsData = reactive<DetailsData>({
|
||||
start: "",
|
||||
end: "",
|
||||
@@ -16,20 +16,17 @@ export const useAIStore = defineStore("ai", () => {
|
||||
contest_count: 0,
|
||||
solved: [],
|
||||
})
|
||||
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||
|
||||
const loading = reactive({
|
||||
details: false,
|
||||
weekly: false,
|
||||
fetching: false, // 合并 details 和 duration 的 loading
|
||||
ai: false,
|
||||
heatmap: false,
|
||||
})
|
||||
|
||||
const mdContent = ref("")
|
||||
|
||||
async function fetchDetailsData(
|
||||
start: string,
|
||||
end: string,
|
||||
) {
|
||||
loading.details = true
|
||||
async function fetchDetailsData(start: string, end: string) {
|
||||
const res = await getAIDetailData(start, end)
|
||||
detailsData.start = res.data.start
|
||||
detailsData.end = res.data.end
|
||||
@@ -39,17 +36,34 @@ export const useAIStore = defineStore("ai", () => {
|
||||
detailsData.tags = res.data.tags
|
||||
detailsData.difficulty = res.data.difficulty
|
||||
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,
|
||||
duration: string,
|
||||
) {
|
||||
loading.weekly = true
|
||||
const res = await getAIWeeklyData(end, duration)
|
||||
weeklyData.value = res.data
|
||||
loading.weekly = false
|
||||
loading.fetching = true
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchDetailsData(start, end),
|
||||
fetchDurationData(end, duration),
|
||||
])
|
||||
} finally {
|
||||
loading.fetching = false
|
||||
}
|
||||
}
|
||||
|
||||
let aiController: AbortController | null = null
|
||||
@@ -78,7 +92,7 @@ export const useAIStore = defineStore("ai", () => {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
details: detailsData,
|
||||
weekly: weeklyData.value,
|
||||
duration: durationData.value,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -132,11 +146,12 @@ export const useAIStore = defineStore("ai", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
fetchWeeklyData,
|
||||
fetchDetailsData,
|
||||
fetchAnalysisData,
|
||||
fetchHeatmapData,
|
||||
fetchAIAnalysis,
|
||||
weeklyData,
|
||||
durationData,
|
||||
detailsData,
|
||||
heatmapData,
|
||||
duration,
|
||||
loading,
|
||||
mdContent,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { formatISO, getTime, parseISO } from "date-fns"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { ContestStatus, ContestType } from "~/utils/constants"
|
||||
import { duration } from "~/utils/functions"
|
||||
import { Contest, Problem } from "~/utils/types"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { ContestStatus, ContestType } from "utils/constants"
|
||||
import { duration } from "utils/functions"
|
||||
import { Contest, Problem } from "utils/types"
|
||||
import {
|
||||
checkContestPassword,
|
||||
getContest,
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
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>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-button text type="info" @click="$emit('click')"><slot></slot></n-button>
|
||||
<n-button text @click="$emit('search')">
|
||||
<template #icon>
|
||||
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="$emit('search')">
|
||||
<template #icon>
|
||||
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</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>
|
||||
</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')">
|
||||
{{ props.submission.id.slice(0, 12) }}
|
||||
</n-button>
|
||||
<n-button text @click="goto">
|
||||
<template #icon>
|
||||
<Icon icon="streamline-emojis:backhand-index-pointing-right-1"></Icon>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="goto">
|
||||
<template #icon>
|
||||
<Icon icon="catppuccin:folder-debug"></Icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
</n-button>
|
||||
查看测试详情
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
<span v-else>
|
||||
{{ props.submission.id.slice(0, 12) }}
|
||||
@@ -16,7 +21,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { SubmissionListItem } from "~/utils/types"
|
||||
import { SubmissionListItem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
submission: SubmissionListItem
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import qs from "query-string"
|
||||
import { getSubmission } from "oj/api"
|
||||
import {
|
||||
JUDGE_STATUS,
|
||||
@@ -11,10 +10,11 @@ import {
|
||||
submissionMemoryFormat,
|
||||
submissionTimeFormat,
|
||||
utoa,
|
||||
copyToClipboard,
|
||||
} from "utils/functions"
|
||||
import { Submission } from "utils/types"
|
||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
||||
import { isDesktop, isMobile } from "~/shared/composables/breakpoints"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||
|
||||
const props = defineProps<{
|
||||
submissionID: string
|
||||
@@ -24,6 +24,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const submission = ref<Submission>()
|
||||
|
||||
@@ -61,23 +62,38 @@ function copyToCat() {
|
||||
input: "",
|
||||
}
|
||||
const base64 = utoa(JSON.stringify(data))
|
||||
const url = qs.stringifyUrl({
|
||||
url: import.meta.env.PUBLIC_CODE_URL,
|
||||
query: {
|
||||
share: base64,
|
||||
},
|
||||
})
|
||||
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
function copyToProblem() {
|
||||
router.push({
|
||||
name: "problem",
|
||||
params: {
|
||||
contestID: submission.value!.contest,
|
||||
problemID: props.problemID,
|
||||
},
|
||||
})
|
||||
async function copyToProblem() {
|
||||
const success = await copyToClipboard(submission.value!.code)
|
||||
if (success) {
|
||||
message.success("代码复制成功")
|
||||
} else {
|
||||
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)
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NH2, NText } from "naive-ui"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { adminRejudge, getSubmissions, getTodaySubmissionCount } from "oj/api"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { LANGUAGE, SubmissionListItem } from "utils/types"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { usePagination } from "~/shared/composables/pagination"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { LANGUAGE_SHOW_VALUE } from "~/utils/constants"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
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 SubmissionDetail from "./detail.vue"
|
||||
import IconButton from "shared/components/IconButton.vue"
|
||||
|
||||
interface SubmissionQuery {
|
||||
username: string
|
||||
result: string
|
||||
myself: boolean
|
||||
myself: "0" | "1"
|
||||
problem: string
|
||||
language: LANGUAGE | ""
|
||||
}
|
||||
@@ -34,11 +36,11 @@ const todayCount = ref(0)
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<SubmissionQuery>({
|
||||
username: "",
|
||||
result: "",
|
||||
myself: false,
|
||||
problem: "",
|
||||
language: "",
|
||||
username: useRouteQuery("username", "").value,
|
||||
result: useRouteQuery("result", "").value,
|
||||
myself: useRouteQuery("myself", "0").value,
|
||||
problem: useRouteQuery("problem", "").value,
|
||||
language: useRouteQuery("language", "").value,
|
||||
})
|
||||
const submissionID = ref("")
|
||||
const problemDisplayID = ref("")
|
||||
@@ -65,7 +67,6 @@ async function listSubmissions() {
|
||||
try {
|
||||
const res = await getSubmissions({
|
||||
...query,
|
||||
myself: query.myself ? "1" : "0",
|
||||
offset,
|
||||
problem_id: query.problem,
|
||||
contest_id: <string>route.params.contestID ?? "",
|
||||
@@ -92,7 +93,6 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
function search(username: string, problem: string) {
|
||||
query.username = username
|
||||
query.problem = problem
|
||||
@@ -129,11 +129,10 @@ function showCodePanel(id: string, problem: string) {
|
||||
}
|
||||
|
||||
// 监听用户名和题号变化(防抖)
|
||||
watchDebounced(
|
||||
() => [query.username, query.problem],
|
||||
listSubmissions,
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watchDebounced(() => [query.username, query.problem], listSubmissions, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
@@ -161,7 +160,7 @@ const columns = computed(() => {
|
||||
{
|
||||
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
||||
key: "id",
|
||||
minWidth: 160,
|
||||
minWidth: 180,
|
||||
render: (row) =>
|
||||
h(SubmissionLink, {
|
||||
submission: row,
|
||||
@@ -171,7 +170,7 @@ const columns = computed(() => {
|
||||
{
|
||||
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
|
||||
key: "status",
|
||||
width: 160,
|
||||
width: 140,
|
||||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||||
},
|
||||
{
|
||||
@@ -182,6 +181,7 @@ const columns = computed(() => {
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "题目",
|
||||
onClick: () => problemClicked(row),
|
||||
onSearch: () => (query.problem = row.problem),
|
||||
},
|
||||
@@ -203,13 +203,16 @@ const columns = computed(() => {
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
key: "username",
|
||||
minWidth: 160,
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "用户",
|
||||
username: row.username,
|
||||
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
||||
onSearch: () => (query.username = row.username),
|
||||
onFilterClass: (classname: string) => (query.username = classname),
|
||||
},
|
||||
() => row.username,
|
||||
),
|
||||
@@ -239,14 +242,21 @@ const columns = computed(() => {
|
||||
<n-flex vertical size="large">
|
||||
<n-space>
|
||||
<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
|
||||
class="select"
|
||||
v-model:value="query.result"
|
||||
:options="resultOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="编程语言">
|
||||
<n-form-item label="语言" v-if="route.name !== 'contest submissions'">
|
||||
<n-select
|
||||
class="select"
|
||||
v-model:value="query.language"
|
||||
@@ -271,25 +281,40 @@ const columns = computed(() => {
|
||||
placeholder="题号"
|
||||
/>
|
||||
</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 :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-button @click="search(query.username, query.problem)">
|
||||
搜索
|
||||
</n-button>
|
||||
</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-button @click="clear" quaternary>重置</n-button>
|
||||
</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">
|
||||
<n-gradient-text>今日提交数:{{ todayCount }}</n-gradient-text>
|
||||
</component>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { NH2, NH3 } from "naive-ui"
|
||||
import { getProfile } from "~/shared/api"
|
||||
import { isDesktop } from "~/shared/composables/breakpoints"
|
||||
import { durationToDays, parseTime } from "~/utils/functions"
|
||||
import { Profile } from "~/utils/types"
|
||||
import { getProfile } from "shared/api"
|
||||
import { isDesktop } from "shared/composables/breakpoints"
|
||||
import { durationToDays, parseTime } from "utils/functions"
|
||||
import { Profile } from "utils/types"
|
||||
import { getMetrics } from "../api"
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { getMessageList } from "oj/api"
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
|
||||
import Copy from "~/shared/components/Copy.vue"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { Message } from "~/utils/types"
|
||||
import Copy from "shared/components/Copy.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { Message } from "utils/types"
|
||||
|
||||
const router = useRouter()
|
||||
const messages = ref<Message[]>([])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { updateProfile, uploadAvatar } from "oj/api"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { RouteRecordRaw } from "vue-router"
|
||||
|
||||
export const ojs: RouteRecordRaw = {
|
||||
path: "/",
|
||||
component: () => import("~/shared/layout/default.vue"),
|
||||
component: () => import("shared/layout/default.vue"),
|
||||
children: [
|
||||
{ path: "", component: () => import("oj/problem/list.vue") },
|
||||
{
|
||||
@@ -101,12 +101,12 @@ export const ojs: RouteRecordRaw = {
|
||||
|
||||
export const admins: RouteRecordRaw = {
|
||||
path: "/admin",
|
||||
component: () => import("~/shared/layout/admin.vue"),
|
||||
component: () => import("shared/layout/admin.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "admin home",
|
||||
component: () => import("~/admin/setting/home.vue"),
|
||||
component: () => import("admin/setting/home.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
@@ -124,7 +124,7 @@ export const admins: RouteRecordRaw = {
|
||||
{
|
||||
path: "user/generate",
|
||||
name: "admin user generate",
|
||||
component: () => import("~/admin/user/generate.vue"),
|
||||
component: () => import("admin/user/generate.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
// admin和super_admin都可以访问的路由 (需要题目权限)
|
||||
@@ -187,6 +187,13 @@ export const admins: RouteRecordRaw = {
|
||||
props: 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可以访问的路由
|
||||
{
|
||||
path: "announcement/list",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 }) {
|
||||
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 { EditorView } from "@codemirror/view"
|
||||
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 { 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 {
|
||||
language?: LANGUAGE
|
||||
fontSize?: number
|
||||
@@ -28,32 +17,45 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
language: "C",
|
||||
language: "Python3",
|
||||
fontSize: 20,
|
||||
height: "100%",
|
||||
readonly: false,
|
||||
placeholder: "",
|
||||
})
|
||||
|
||||
const { readonly, placeholder, height, fontSize } = toRefs(props)
|
||||
const code = defineModel<string>("value")
|
||||
|
||||
const isDark = useDark()
|
||||
|
||||
const lang = computed(() => {
|
||||
if (props.language === "Python3" || props.language === "Python2") {
|
||||
return python()
|
||||
}
|
||||
return cpp()
|
||||
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",
|
||||
},
|
||||
})
|
||||
|
||||
const lang = computed((): Extension => {
|
||||
return ["Python2", "Python3"].includes(props.language) ? python() : cpp()
|
||||
})
|
||||
|
||||
const extensions = computed(() => [
|
||||
styleTheme,
|
||||
lang.value,
|
||||
isDark.value ? oneDark : smoothy,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Codemirror
|
||||
v-model="code"
|
||||
indentWithTab
|
||||
:extensions="[styleTheme, lang, isDark ? oneDark : smoothy]"
|
||||
:disabled="props.readonly"
|
||||
:tabSize="4"
|
||||
:placeholder="props.placeholder"
|
||||
:style="{ height: props.height, fontSize: props.fontSize + 'px' }"
|
||||
:extensions="extensions"
|
||||
:disabled="readonly"
|
||||
:tab-size="4"
|
||||
:placeholder="placeholder"
|
||||
:style="{ height, fontSize: `${fontSize}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ContestType } from "~/utils/constants"
|
||||
import { Contest } from "~/utils/types"
|
||||
import { ContestType } from "utils/constants"
|
||||
import { Contest } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
contest: Contest
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import copy from "copy-text-to-clipboard"
|
||||
import { copyToClipboard } from "utils/functions"
|
||||
|
||||
defineProps<{ value: string }>()
|
||||
const [copied, toggle] = useToggle()
|
||||
@@ -9,10 +9,12 @@ const { start } = useTimeoutFn(() => toggle(false), 1000, { immediate: false })
|
||||
const COPY = h(Icon, { icon: "twemoji:clipboard" })
|
||||
const OK = h(Icon, { icon: "twemoji:check-mark-button" })
|
||||
|
||||
function handleClick(value: string) {
|
||||
copy(value)
|
||||
toggle(true)
|
||||
start()
|
||||
async function handleClick(value: string) {
|
||||
const success = await copyToClipboard(value)
|
||||
if (success) {
|
||||
toggle(true)
|
||||
start()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { RouterLink } from "vue-router"
|
||||
import { isDesktop, isMobile } from "~/shared/composables/breakpoints"
|
||||
import { toggleLogin, toggleSignup } from "~/shared/composables/modal"
|
||||
import { screenMode, switchScreenMode } from "~/shared/composables/switchScreen"
|
||||
import { avatar } from "~/utils/constants"
|
||||
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
||||
import { toggleLogin, toggleSignup } from "shared/composables/modal"
|
||||
import { screenMode, switchScreenMode } from "shared/composables/switchScreen"
|
||||
import { logout } from "../api"
|
||||
import { useConfigStore } from "../store/config"
|
||||
import { useUserStore } from "../store/user"
|
||||
@@ -16,6 +15,36 @@ const configStore = useConfigStore()
|
||||
const route = useRoute()
|
||||
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(() => {
|
||||
if (import.meta.env.PUBLIC_ENV === "test") {
|
||||
return "测试版"
|
||||
@@ -57,7 +86,6 @@ const menus = computed<MenuOption[]>(() => [
|
||||
label: () => h(RouterLink, { to: "/learn/01" }, { default: () => "自学" }),
|
||||
key: "learn",
|
||||
icon: renderIcon("streamline-emojis:snake"),
|
||||
show: isDesktop.value,
|
||||
},
|
||||
{
|
||||
label: () => h(RouterLink, { to: "/" }, { default: () => "题库" }),
|
||||
@@ -69,6 +97,7 @@ const menus = computed<MenuOption[]>(() => [
|
||||
h(RouterLink, { to: "/submission" }, { default: () => "提交" }),
|
||||
key: "submission",
|
||||
icon: renderIcon("streamline-emojis:bouquet"),
|
||||
show: userStore.showSubmissions,
|
||||
},
|
||||
{
|
||||
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