Compare commits

...

56 Commits

Author SHA1 Message Date
389393b70d add ws
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-07 17:04:35 +08:00
437da9d588 fmt 2025-10-07 17:03:59 +08:00
6f345611eb update 2025-10-07 03:05:08 +08:00
97baf85611 update 2025-10-07 01:32:58 +08:00
ed3cfaacd4 fix 2025-10-07 01:01:03 +08:00
66b346bbb2 fix 2025-10-07 00:58:48 +08:00
613ab6eae1 fix 2025-10-07 00:58:14 +08:00
0b70c478b2 update 2025-10-07 00:53:51 +08:00
d6820b70af update 2025-10-07 00:30:38 +08:00
c1d5119a0a 重构 AI 2025-10-07 00:26:22 +08:00
0c8e32aad4 update 2025-10-06 20:35:09 +08:00
0d19529632 update 2025-10-06 19:38:44 +08:00
96adf39cba 教程的标题 2025-10-06 19:13:01 +08:00
bc13976a78 fix editor 2025-10-06 11:56:25 +08:00
76d5e0a78f update 2025-10-06 00:23:12 +08:00
fccc3a361b fix 2025-10-06 00:06:25 +08:00
d2c031fbd9 ACM helper 2025-10-06 00:02:27 +08:00
06146d8895 update 2025-10-05 22:24:40 +08:00
d28c22ddcb dix 2025-10-05 21:56:38 +08:00
1bd25ba28f fix 2025-10-05 21:36:53 +08:00
12dd9e1cfe remove 2025-10-05 21:22:15 +08:00
73e1969d39 fix2 2025-10-05 21:20:45 +08:00
7529cb728d fix1 2025-10-05 21:12:58 +08:00
1ba042fae9 优化首屏 2025-10-05 21:07:29 +08:00
7e6d03ca1a 统一导入 2025-10-05 20:07:41 +08:00
2b93a9e849 update 2025-10-05 19:47:25 +08:00
f593b34ce7 update 2025-10-05 19:34:23 +08:00
38a37d395d fix 2025-10-05 17:43:12 +08:00
64ade2fe47 fix UI 2025-10-05 16:57:33 +08:00
68a14aad28 add sort 2025-10-05 16:44:01 +08:00
de21261b1b update 2025-10-05 16:30:18 +08:00
98785d6419 fix 2025-10-05 16:04:46 +08:00
be3b644531 fix 2025-10-05 15:59:07 +08:00
70d4629e27 fix 2025-10-05 10:53:18 +08:00
3be60ae50d change url 2025-10-05 10:12:30 +08:00
94d2066a99 fix 2025-10-05 10:04:36 +08:00
ece84e15c7 update 2025-10-05 10:03:41 +08:00
04c6c49eaa fix 2025-10-05 09:54:42 +08:00
719f315405 update 2025-10-05 01:43:37 +08:00
41d3246246 fix copy 2025-10-05 01:40:12 +08:00
aa8fcccf7d fix 2025-10-05 01:34:22 +08:00
7b139d404e 协同编辑代码 2025-10-05 01:18:00 +08:00
fce96f2087 update 2025-10-04 14:36:30 +08:00
dfa8d970f2 fix 2025-10-04 14:26:02 +08:00
c8751223db update 2025-10-04 13:57:53 +08:00
204bbf2d3c update 2025-10-04 13:34:31 +08:00
ef0b0c5528 fix chartjs register 2025-10-04 13:29:13 +08:00
a823cb7a13 UI 2025-10-04 12:10:55 +08:00
68512d6d54 add problem answers 2025-10-04 01:46:34 +08:00
7f1ecec290 add problem prompt 2025-10-04 00:45:01 +08:00
15d0e3cac6 fix UI 2025-10-03 23:41:35 +08:00
8ede959fc2 在题目详情页显示数据统计 2025-10-03 15:28:25 +08:00
e462bd0864 后台出题人 2025-10-03 14:30:08 +08:00
df72936d9f fix UI 2025-10-03 02:23:49 +08:00
20996a8171 fix UI 2025-10-03 02:19:15 +08:00
b0229cb264 出题人 2025-10-03 02:03:01 +08:00
120 changed files with 6620 additions and 2639 deletions

1
.env
View File

@@ -3,3 +3,4 @@ PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb
PUBLIC_OJ_URL=http://localhost:8000
PUBLIC_CODE_URL=http://localhost:3000
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc

View File

@@ -3,3 +3,4 @@ PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb
PUBLIC_OJ_URL=https://oj.xuyue.cc
PUBLIC_CODE_URL=https://code.xuyue.cc
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc

View File

@@ -4,3 +4,4 @@ PUBLIC_OJ_URL=http://10.13.114.114:81
PUBLIC_CODE_URL=http://10.13.114.114:82
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc

View File

@@ -4,3 +4,4 @@ PUBLIC_OJ_URL=http://10.13.114.114:81
PUBLIC_CODE_URL=http://10.13.114.114:82
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc

View 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普通用户APIoj/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. 管理员APIadmin/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/announcementOJ端增强竞赛体验
### 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` - 获取所有判题服务器列表及状态
**结论**
此接口是系统内部通信接口,前端完全不需要调用。类似的内部接口还可能存在于分布式系统的其他服务间通信中。

View File

@@ -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未来添加新的分页页面将变得非常简单只需要一行代码就能获得完整的分页功能。

View File

@@ -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. 支持浏览器前进后退按钮

View File

@@ -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
View 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
展示内容: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
价值: 全面评估进步情况

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}

View File

@@ -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;
}

View File

@@ -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 使用单独的代理配置
},
},
}

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { deleteAnnouncement } from "~/admin/api"
import { deleteAnnouncement } from "admin/api"
interface Props {
announcementID: number

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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,
})
}

View File

@@ -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"

View File

@@ -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"])

View File

@@ -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>

View File

@@ -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 {

View 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>

View File

@@ -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 }),
},
]

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { addProblemForContest } from "~/admin/api"
import { addProblemForContest } from "admin/api"
interface Props {
problemID: number

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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)

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { deleteTutorial } from "~/admin/api"
import { deleteTutorial } from "admin/api"
interface Props {
tutorialID: number

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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
View File

@@ -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 {

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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-60是周日
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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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,11 +257,8 @@ 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() {

View File

@@ -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: "",

View File

@@ -1,3 +1,3 @@
import { Problem } from "~/utils/types"
import { Problem } from "utils/types"
export const problem = ref<Problem | null>(null)

View 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
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ContestRank } from "~/utils/types"
import { ContestRank } from "utils/types"
interface Props {
rank: ContestRank

View File

@@ -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>

View File

@@ -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()

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 }>()

View File

@@ -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 } }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View 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>

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 }>()

View File

@@ -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"

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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>

View File

@@ -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()

View File

@@ -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[]>([])

View File

@@ -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()

View File

@@ -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",

View File

@@ -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)

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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