Compare commits

...

99 Commits

Author SHA1 Message Date
71a903e28b update
Some checks are pending
Deploy / deploy (push) Waiting to run
2025-12-23 21:40:30 +08:00
2a14fe138f update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-12-23 21:24:11 +08:00
5fcbe4703b add filter
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-12-23 20:57:13 +08:00
0e9b289f7c 删除不需要的代码
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-12-22 19:38:42 +08:00
f80b82e09d update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-12-22 19:31:55 +08:00
ef38fa5d97 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-12-22 19:23:59 +08:00
9888a8124b update incomplete problem
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-12-22 19:11:34 +08:00
88b3a041c4 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-12-20 22:04:01 +08:00
1cb96a1073 add flowchart dead data info
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-12-13 17:48:31 +08:00
f0f9c74973 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-11-30 19:02:51 +08:00
eb0122ffc2 filter by class
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-11-30 18:41:58 +08:00
91e2f8bf05 fix problemset stats
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-11-24 20:40:03 +08:00
082d7843df fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-11-02 21:15:59 +08:00
c77851aa97 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-11-02 20:35:19 +08:00
d7ef6abedf fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-27 19:20:31 +08:00
bd414cfc3f fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-27 18:33:55 +08:00
9ff9c7ae54 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-27 17:38:19 +08:00
fe2411083f update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-27 17:33:11 +08:00
d88da0739c update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-27 16:23:55 +08:00
37925c1be6 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-27 13:40:26 +08:00
b87cd85889 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-24 14:50:20 +08:00
d384d15aa6 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-24 11:19:46 +08:00
d766433068 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-24 11:04:01 +08:00
df24bf7f54 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-23 23:20:00 +08:00
791828b9e1 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-23 19:54:04 +08:00
67dce91dbe update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-23 16:52:34 +08:00
0a31cc3d2f update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-23 16:12:42 +08:00
42ce9ac63b update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-23 12:42:32 +08:00
9e7fbd8ff2 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-23 10:54:55 +08:00
ee59039968 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-23 09:36:22 +08:00
9f07fcb0a0 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-23 00:54:10 +08:00
9789b86920 add admin
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-22 20:29:17 +08:00
6bc2140052 add problemset
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-22 18:47:41 +08:00
0bf265b5d9 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-22 17:42:38 +08:00
3770627df8 add contest chart
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-22 15:05:15 +08:00
a2094dd659 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-22 13:37:48 +08:00
ca2ae54af5 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-20 20:16:54 +08:00
63e58593d7 add flowchart
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-20 20:05:16 +08:00
d32af7e6e7 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-17 20:48:02 +08:00
5127ded7d5 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-15 19:46:10 +08:00
07c67d2694 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-15 19:43:32 +08:00
43e0376fba add sort
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 21:30:39 +08:00
d1acd24cd0 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 21:11:12 +08:00
c6fefba081 fix UI
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 10:12:26 +08:00
41eabef923 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 09:25:44 +08:00
943724d613 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 08:55:14 +08:00
c503745832 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 01:22:42 +08:00
0bca7eccec update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 01:21:15 +08:00
9ecabf9eb9 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 01:17:55 +08:00
7c21ce0e2e update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-14 00:24:12 +08:00
b991688edd fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 20:55:17 +08:00
ab4e12e0e5 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 20:50:30 +08:00
04d77a3e9d fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 20:23:05 +08:00
88b7224b49 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 20:13:52 +08:00
34413704c3 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 15:43:36 +08:00
6f1720acd5 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 15:18:14 +08:00
854b1f0769 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 14:58:21 +08:00
eda470cb78 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 12:51:23 +08:00
41819b6d9b 提交流程图
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 12:31:12 +08:00
2c31acaba7 流程图的功能
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-13 01:38:54 +08:00
2aec0abca2 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 18:28:33 +08:00
696f5ae2c9 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 17:14:23 +08:00
87d2b828a1 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 15:57:59 +08:00
4335ffb1e1 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 15:18:47 +08:00
f06b7313b0 显示 log
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 14:48:08 +08:00
1103789cd5 mermaid全量导入
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 14:43:12 +08:00
4a67717f42 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 14:34:25 +08:00
ead086c5ac remove useless
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 14:29:25 +08:00
234f149ee5 还是去掉吧
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 14:18:46 +08:00
0651d7f9fb 开启 cache
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 14:15:24 +08:00
6c23634bb7 删掉 cache
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 14:12:55 +08:00
a08f4a1935 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 14:09:15 +08:00
6ee8fe02ba test
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 13:46:52 +08:00
70306a8ef2 revert
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 13:28:27 +08:00
d3694a8d28 显示流程图
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-12 12:59:38 +08:00
d7316d4681 删除 spj
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-11 23:35:41 +08:00
33aca892f4 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-11 18:34:32 +08:00
df4bab8bd1 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-11 14:37:51 +08:00
cdaf85b89e update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-11 14:36:13 +08:00
5cec55acd6 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-11 14:32:42 +08:00
76f8a03177 设置的实时变动
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-11 14:07:16 +08:00
b65bb3d53c fix test env
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-11 13:37:23 +08:00
0725e222dc 设置的实时
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-11 13:35:21 +08:00
f45d07dbe7 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 22:31:00 +08:00
bf745be372 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 22:28:15 +08:00
010beaaafb update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 21:53:39 +08:00
232e2d75e1 show grade info
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 21:49:35 +08:00
ffcb436a82 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 18:52:19 +08:00
93d4837c6d env
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 18:35:49 +08:00
9e820f57f4 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 01:03:03 +08:00
021e53ee59 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 00:58:45 +08:00
b14316b919 batch update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-08 00:46:49 +08:00
b8c622dde1 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-07 22:10:39 +08:00
6151f002bd update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-07 21:57:54 +08:00
1e0becb1f8 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-07 21:38:51 +08:00
657047793d fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-07 21:35:07 +08:00
87e9c4120e fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-07 21:32:42 +08:00
fa5cd00ab4 fix
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-07 21:31:41 +08:00
7938e02421 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2025-10-07 21:26:54 +08:00
138 changed files with 11367 additions and 1737 deletions

3
.env
View File

@@ -3,4 +3,5 @@ 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
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
PUBLIC_WS_URL=ws://localhost:8001/ws

View File

@@ -3,4 +3,5 @@ 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
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
PUBLIC_WS_URL=wss://oj.xuyue.cc/ws

View File

@@ -4,4 +4,5 @@ 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
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
PUBLIC_WS_URL=ws://10.13.114.114:81/ws

View File

@@ -1,7 +1,8 @@
PUBLIC_ENV=test
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=dd37457027c40b39
PUBLIC_OJ_URL=http://10.13.114.114:81
PUBLIC_OJ_URL=http://10.13.114.114:93
PUBLIC_CODE_URL=http://10.13.114.114:82
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
PUBLIC_WS_URL=ws://10.13.114.114:93/ws

127
README.md
View File

@@ -1 +1,126 @@
OJ next
# OJ Next
基于 Vue 3 + TypeScript 的在线判题系统前端项目。
## 项目特性
- 🎯 **现代化技术栈** - Vue 3 + TypeScript + Vite
- 📊 **数据可视化** - 丰富的图表和统计功能
- 🎨 **流程图编辑器** - 内置 FlowchartEditor 组件
- 📱 **响应式设计** - 支持多端适配
-**高性能** - 优化的组件和状态管理
## 核心组件
### FlowchartEditor 流程图编辑器
一个功能完整的流程图编辑器组件,支持:
- 🎯 **拖拽创建节点** - 从工具栏拖拽节点到画布
- 🔗 **节点连接** - 支持节点间的连线操作
- ✏️ **节点编辑** - 双击节点进行文本编辑
- 📋 **节点操作** - 复制、删除、批量操作
- ↩️ **撤销重做** - 完整的历史记录管理
- ⌨️ **键盘快捷键** - Ctrl+Z/Ctrl+Y 撤销重做Delete 删除
- 💾 **自动保存** - 本地缓存,防止数据丢失
- 🎨 **多种节点类型** - 开始、输入、处理、判断、循环、输出、结束
- 🖱️ **交互优化** - 流畅的拖拽体验和视觉反馈
**核心特性**
- 基于 Vue Flow 构建,性能优异
- 模块化设计,易于扩展
- 响应式布局,支持多端适配
- 完整的 TypeScript 支持
详细文档:[FlowchartEditor 文档](./docs/FlowchartEditor.md)
## 技术栈
- **Vue 3** - 渐进式 JavaScript 框架
- **TypeScript** - JavaScript 的超集
- **Rsbuild** - 基于 Rspack 的构建工具
- **Vue Flow** - 流程图组件库
- **Composition API** - Vue 3 组合式 API
- **Naive UI** - Vue 3 组件库
- **Chart.js** - 图表可视化库
- **CodeMirror** - 代码编辑器
- **Yjs** - 实时协作框架
## 项目结构
```
src/
├── shared/ # 共享组件和工具
│ ├── components/
│ │ └── FlowchartEditor/ # 流程图编辑器
│ │ ├── index.vue # 主组件
│ │ ├── CustomNode.vue # 自定义节点
│ │ ├── Toolbar.vue # 工具栏
│ │ ├── NodeHandles.vue # 节点操作
│ │ ├── NodeActions.vue # 节点动作
│ │ ├── useCache.ts # 缓存管理
│ │ ├── useDnD.ts # 拖拽处理
│ │ ├── useFlowOperations.ts # 流程操作
│ │ ├── useHistory.ts # 历史记录
│ │ └── useNodeStyles.ts # 节点样式
│ ├── composables/ # 组合式函数
│ ├── layout/ # 布局组件
│ ├── store/ # 状态管理
│ └── themes/ # 主题配置
├── oj/ # 主要业务组件
│ ├── ai/ # AI分析模块
│ ├── problem/ # 题目相关
│ ├── contest/ # 竞赛相关
│ ├── submission/ # 提交相关
│ └── store/ # 业务状态管理
├── admin/ # 管理后台组件
└── utils/ # 工具函数
```
## 开发指南
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm start
# 或
npm run start
```
### 构建生产版本
```bash
npm run build
```
### 构建测试环境版本
```bash
npm run build:test
```
### 构建预发布版本
```bash
npm run build:staging
```
## 文档
- [FlowchartEditor 流程图编辑器](./docs/FlowchartEditor.md)
- [图表组件说明](./docs/图表.md)
- [API 接口对比分析](./docs/API接口对比分析.md)
## 贡献
欢迎提交 Issue 和 Pull Request
## 许可证
MIT License

View File

@@ -2,7 +2,23 @@
## 更新日志
### 最近更新2025-10
### 最近更新2025-01-15
-**FlowchartEditor 流程图编辑器**:新增完整的流程图编辑功能
- 基于 Vue Flow 构建的流程图编辑器组件
- 支持7种节点类型开始、输入、处理、判断、循环、输出、结束
- 完整的拖拽创建、节点连接、编辑功能
- 撤销重做、自动保存、键盘快捷键支持
- 模块化设计包含9个独立的功能模块
- 🎨 **前端组件优化**:完善了组件文档和项目结构说明
- 更新了 README.md 中的技术栈和项目结构
- 创建了详细的 FlowchartEditor 使用文档
- 优化了开发指南和构建流程说明
- 🔧 **构建工具升级**:从 Vite 迁移到 Rsbuild
- 使用 Rsbuild 作为新的构建工具
- 支持多环境构建test、staging、production
- 优化了构建性能和开发体验
### 历史更新2025-10
-**AI分析功能增强**完善了AI智能分析模块的文档说明
- 详细说明了4个AI相关接口的功能和参数
- 新增等级系统说明S/A/B/C包含特殊规则
@@ -204,7 +220,6 @@
- `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格式题目
@@ -278,7 +293,6 @@
- **版本信息**: versions显示系统版本
- **FPS导入**: import_fps特定格式题目导入
- **文件上传**: upload_file目前只有图片上传
- **Special Judge编译**: compile_spj高级题目功能
### 4. 可选功能
- **生成用户**: generate_user批量生成测试用户
@@ -436,3 +450,99 @@ const hasTriedButNotPassed = computed(() => {
**结论**
此接口是系统内部通信接口,前端完全不需要调用。类似的内部接口还可能存在于分布式系统的其他服务间通信中。
---
## 六、新增功能模块
### FlowchartEditor 流程图编辑器 ✨
**功能概述**: 基于 Vue Flow 构建的完整流程图编辑器,支持拖拽创建、节点连接、编辑等核心功能。
**技术实现**:
- **核心库**: Vue Flow (@vue-flow/core)
- **组件架构**: 模块化设计9个独立功能模块
- **状态管理**: 基于 Vue 3 Composition API
- **数据持久化**: localStorage 自动缓存
- **交互体验**: 拖拽、键盘快捷键、撤销重做
**组件结构**:
```
FlowchartEditor/
├── index.vue # 主组件 - 整合所有功能
├── CustomNode.vue # 自定义节点 - 7种节点类型
├── Toolbar.vue # 工具栏 - 节点创建和操作
├── NodeHandles.vue # 节点操作手柄 - 连接点管理
├── NodeActions.vue # 节点动作 - 删除、编辑按钮
├── useCache.ts # 缓存管理 - 自动保存/恢复
├── useDnD.ts # 拖拽处理 - 节点创建逻辑
├── useFlowOperations.ts # 流程操作 - 增删改查
├── useHistory.ts # 历史记录 - 撤销重做
└── useNodeStyles.ts # 节点样式 - 类型配置
```
**节点类型支持**:
- **开始节点** (start) - 流程开始,绿色主题
- **输入节点** (input) - 数据输入,蓝色主题
- **处理节点** (default) - 数据处理,紫色主题
- **判断节点** (decision) - 条件判断,橙色主题
- **循环节点** (loop) - 循环处理,青色主题
- **输出节点** (output) - 数据输出,粉色主题
- **结束节点** (end) - 流程结束,红色主题
**核心功能**:
1. **拖拽创建**: 从工具栏拖拽节点到画布
2. **节点连接**: 支持节点间的连线操作
3. **节点编辑**: 双击节点进行文本编辑
4. **撤销重做**: 完整的历史记录管理
5. **自动保存**: 本地缓存,防止数据丢失
6. **键盘快捷键**: Ctrl+Z/Ctrl+Y 撤销重做Delete 删除
7. **批量操作**: 支持多选和批量删除
**数据格式**:
```typescript
// 节点数据
interface Node {
id: string
type: string
position: { x: number, y: number }
data: {
customLabel: string
[key: string]: any
}
}
// 边数据
interface Edge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
type?: string
}
```
**使用场景**:
- 算法流程图绘制
- 业务流程设计
- 教学演示工具
- 问题分析图表
**性能优化**:
- 基于 Vue Flow 的高性能渲染
- 模块化设计,按需加载
- 本地缓存减少重复计算
- 响应式状态管理
**扩展性**:
- 支持自定义节点类型
- 可扩展工具栏功能
- 支持主题定制
- 支持数据导入导出
**文档支持**:
- 详细的使用文档: `docs/FlowchartEditor.md`
- 完整的 API 说明
- 最佳实践指南
- 故障排除手册

311
docs/FlowchartEditor.md Normal file
View File

@@ -0,0 +1,311 @@
# FlowchartEditor 流程图编辑器
一个基于 Vue 3 + Vue Flow 构建的功能完整的流程图编辑器组件。
## 功能特性
### 🎯 核心功能
- **拖拽创建节点** - 从工具栏拖拽节点到画布
- **节点连接** - 支持节点间的连线操作
- **节点编辑** - 双击节点进行文本编辑
- **撤销重做** - 完整的历史记录管理
- **自动保存** - 本地缓存,防止数据丢失
- **键盘快捷键** - 支持常用快捷键操作
### 🎨 节点类型
- **开始节点** (start) - 流程开始
- **输入节点** (input) - 数据输入
- **处理节点** (default) - 数据处理
- **判断节点** (decision) - 条件判断
- **循环节点** (loop) - 循环处理
- **输出节点** (output) - 数据输出
- **结束节点** (end) - 流程结束
### ⌨️ 快捷键
- `Ctrl+Z` / `Cmd+Z` - 撤销
- `Ctrl+Y` / `Cmd+Shift+Z` - 重做
- `Delete` / `Backspace` - 删除选中节点
## 组件结构
```
FlowchartEditor/
├── index.vue # 主组件
├── CustomNode.vue # 自定义节点组件
├── Toolbar.vue # 工具栏组件
├── NodeHandles.vue # 节点操作手柄
├── NodeActions.vue # 节点动作按钮
├── useCache.ts # 缓存管理
├── useDnD.ts # 拖拽处理
├── useFlowOperations.ts # 流程操作
├── useHistory.ts # 历史记录
└── useNodeStyles.ts # 节点样式
```
## 使用方法
### 基本用法
```vue
<template>
<FlowchartEditor ref="flowchartRef" />
</template>
<script setup>
import { ref } from 'vue'
import FlowchartEditor from '@/shared/components/FlowchartEditor/index.vue'
const flowchartRef = ref()
// 获取流程图数据
const getFlowchartData = () => {
return flowchartRef.value?.getFlowchartData()
}
</script>
```
### 获取流程图数据
```javascript
// 获取当前流程图数据
const data = flowchartRef.value.getFlowchartData()
console.log(data.nodes) // 节点数组
console.log(data.edges) // 边数组
```
## 组件详解
### 主组件 (index.vue)
主组件负责整合所有功能模块,提供完整的流程图编辑体验。
**主要功能**
- 整合 Vue Flow 核心功能
- 管理节点和边的状态
- 处理用户交互事件
- 提供数据暴露接口
**暴露的方法**
- `getFlowchartData()` - 获取当前流程图数据
### 自定义节点 (CustomNode.vue)
基于 Vue Flow 的自定义节点组件,支持多种节点类型。
**节点类型配置**
- 每种节点类型都有独特的样式和图标
- 支持自定义标签文本
- 提供删除和编辑功能
### 工具栏 (Toolbar.vue)
提供节点创建和操作的工具集合。
**功能**
- 节点类型选择
- 撤销/重做操作
- 清空画布
- 保存状态显示
### 缓存管理 (useCache.ts)
提供本地存储功能,防止数据丢失。
**功能**
- 自动保存到 localStorage
- 页面刷新后恢复数据
- 保存状态提示
- 清空缓存功能
### 拖拽处理 (useDnD.ts)
处理节点拖拽创建的逻辑。
**功能**
- 拖拽开始处理
- 拖拽悬停效果
- 拖拽放置处理
- 节点位置计算
### 流程操作 (useFlowOperations.ts)
处理流程图的增删改操作。
**功能**
- 节点连接处理
- 边删除处理
- 节点删除处理
- 节点更新处理
- 清空画布
### 历史记录 (useHistory.ts)
提供撤销重做功能。
**功能**
- 状态快照保存
- 撤销操作
- 重做操作
- 历史记录管理
### 节点样式 (useNodeStyles.ts)
定义各种节点类型的样式配置。
**功能**
- 节点类型配置
- 样式定义
- 图标配置
- 颜色主题
## 样式定制
### 节点样式
可以通过修改 `useNodeStyles.ts` 来自定义节点样式:
```typescript
export function getNodeTypeConfig(type: string) {
const configs = {
start: {
label: '开始',
icon: 'play-circle',
color: '#10b981',
// 自定义样式
},
// 其他节点类型...
}
return configs[type] || configs.default
}
```
### 边样式
边的样式在主组件中定义:
```vue
:default-edge-options="{
type: 'step',
style: {
stroke: '#6366f1',
strokeWidth: 2.5,
cursor: 'pointer',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))'
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#6366f1',
width: 16,
height: 16,
},
}"
```
## 数据格式
### 节点数据格式
```typescript
interface Node {
id: string
type: string
position: { x: number, y: number }
data: {
customLabel: string
[key: string]: any
}
}
```
### 边数据格式
```typescript
interface Edge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
type?: string
}
```
## 最佳实践
### 1. 性能优化
- 大量节点时考虑虚拟化
- 合理使用缓存机制
- 避免频繁的状态更新
### 2. 用户体验
- 提供清晰的操作反馈
- 支持键盘快捷键
- 保持操作的直观性
### 3. 数据管理
- 定期保存到服务器
- 提供数据导入导出功能
- 支持版本控制机制
## 扩展开发
### 添加新节点类型
1.`useNodeStyles.ts` 中添加新类型配置
2.`Toolbar.vue` 中添加新节点按钮
3.`CustomNode.vue` 中添加新节点渲染逻辑
### 添加新功能
1. 创建新的 composable 函数
2. 在主组件中集成新功能
3. 更新工具栏和用户界面
## 故障排除
### 常见问题
1. **节点无法拖拽**
- 检查拖拽事件处理
- 确认节点类型配置正确
2. **撤销重做不工作**
- 检查历史记录状态
- 确认状态保存时机
3. **数据丢失**
- 检查缓存配置
- 确认 localStorage 可用
### 调试技巧
1. 使用 Vue DevTools 查看组件状态
2. 检查浏览器控制台错误
3. 验证数据格式正确性
## 更新日志
### v1.0.0 (2024-01-15)
- ✨ 初始版本发布
- 🎯 基础流程图编辑功能
- 🔧 拖拽创建节点
- 🔗 节点连接功能
- ↩️ 撤销重做支持
- 💾 本地缓存功能
### v1.1.0 (2024-01-20)
- 🎨 新增多种节点类型
- ⌨️ 键盘快捷键支持
- 🖱️ 优化拖拽体验
- 📱 响应式布局改进
### v1.2.0 (2024-01-25)
- 🔧 模块化重构
- 📦 组合式函数优化
- 🎯 性能提升
- 🐛 修复已知问题
## 许可证
MIT License

View File

@@ -1,41 +1,216 @@
📊 可以添加的图表类型
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
展示内容: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
价值: 全面评估进步情况
# 图表组件说明
基于 Chart.js 和 Vue-ChartJS 构建的数据可视化组件库,为 OJ Next 项目提供丰富的图表展示功能。
## 技术栈
- **Chart.js** - 强大的图表库,支持多种图表类型
- **Vue-ChartJS** - Chart.js 的 Vue 3 封装
- **Vue 3 Composition API** - 现代化的组件开发方式
- **TypeScript** - 完整的类型支持
## 现有图表组件
### 1. DurationChart 混合图表
**文件位置**: `src/oj/ai/components/DurationChart.vue`
**功能描述**: 展示用户学习进度的时间趋势,结合柱状图和折线图。
**数据来源**: `durationData` API 接口
- 每周/每月的综合情况
- 题目数、提交数、等级变化
**图表类型**:
- 柱状图:完成题目数
- 折线图:提交次数、等级变化
**使用场景**: AI分析页面的主要图表
### 2. Heatmap 热力图
**文件位置**: `src/oj/ai/components/Heatmap.vue`
**功能描述**: 展示用户的提交活跃度分布。
**数据来源**: `heatmapData` API 接口
- 按日期统计提交数
- 可视化用户活跃度
**图表类型**: 热力图矩阵
**使用场景**: 展示学习习惯和时间规律
### 3. Details 详细数据
**文件位置**: `src/oj/ai/components/Details.vue`
**功能描述**: 展示用户详细的学习数据。
**数据来源**: `detailsData` API 接口
- 用户等级、已解决题目列表
- 标签统计、难度统计
- 参赛次数等
**展示方式**: 数据表格和统计卡片
## 可扩展的图表类型
### 1. 提交效率趋势图 (折线图)
**数据来源**: `durationData` 中的 `submission_count / problem_count`
**展示内容**: 每个时间段的提交效率(提交次数/完成题目数)
**价值**: 反映刷题质量的提升值越接近1说明一次AC率越高
### 2. 排名分布图 (直方图/箱线图)
**数据来源**: `solved` 数组中每道题的 `rank``ac_count`
**展示内容**: 用户解题排名的分布情况前10%、10-30%、30-50%等区间的题目数量)
**价值**: 了解解题速度和竞争力
### 3. 等级分布饼图/环形图
**数据来源**: `solved` 数组中每道题的 `grade`
**展示内容**: S/A/B/C 各等级题目的数量和占比
**价值**: 直观看出题目质量分布
### 4. 标签雷达图
**数据来源**: `tags` 对象
**展示内容**: 多维度展示各类标签的掌握程度(可以归一化处理)
**价值**: 可视化知识点覆盖面
### 5. 时间活跃度分析 (热力矩阵)
**数据来源**: `solved` 数组中的 `ac_time`
**展示内容**: 按星期几和时间段统计做题分布工作日vs周末早中晚时段
**价值**: 了解学习习惯和时间规律
### 6. 难度-等级关联散点图
**数据来源**: `solved` 数组中的难度信息和 `grade`
**展示内容**: X轴为难度Y轴为等级每个点代表一道题
**价值**: 分析在不同难度下的表现
### 7. 做题加速度图
**数据来源**: `durationData`
**展示内容**: 每个时间段完成题目数的变化率
**价值**: 看出学习动力的变化趋势
### 8. 竞赛题目占比
**数据来源**: `solved` 数组中的 `contest_id``contest_count`
**展示内容**: 竞赛题 vs 常规题的数量对比
**价值**: 了解竞赛参与情况
### 9. 连续做题天数统计
**数据来源**: `heatmapData`
**展示内容**: 最长连续做题天数、当前连续天数等
**价值**: 激励持续学习
### 10. 月度对比雷达图
**数据来源**: `durationData`
**展示内容**: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
**价值**: 全面评估进步情况
## 组件开发指南
### 创建新图表组件
```vue
<template>
<div class="chart-container">
<Line
:data="chartData"
:options="chartOptions"
/>
</div>
</template>
<script setup lang="ts">
import { Line } from 'vue-chartjs'
import { computed } from 'vue'
// 定义 props
const props = defineProps<{
data: any[]
}>()
// 计算图表数据
const chartData = computed(() => ({
labels: props.data.map(item => item.label),
datasets: [{
label: '数据',
data: props.data.map(item => item.value),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
}))
// 图表配置
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true
}
}
}
</script>
```
### 图表样式定制
```typescript
// 主题配置
const theme = {
colors: {
primary: '#6366f1',
secondary: '#8b5cf6',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444'
},
gradients: {
primary: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
success: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)'
}
}
```
## 性能优化
### 1. 数据懒加载
- 按需加载图表数据
- 使用虚拟滚动处理大量数据
### 2. 图表缓存
- 缓存计算结果
- 避免重复渲染
### 3. 响应式设计
- 自适应容器大小
- 移动端优化
## 最佳实践
### 1. 数据预处理
- 在组件外部处理数据
- 使用 computed 属性缓存计算结果
### 2. 错误处理
- 添加数据验证
- 提供降级方案
### 3. 用户体验
- 添加加载状态
- 提供交互反馈
## 更新日志
### v1.0.0 (2024-01-15)
- ✨ 初始版本发布
- 📊 基础图表组件
- 🎨 主题样式支持
- 📱 响应式设计
### v1.1.0 (2024-01-20)
- 🔧 性能优化
- 📈 新增混合图表
- 🎯 交互体验改进
## 许可证
MIT License

2528
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,39 +12,47 @@
"dependencies": {
"@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",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.1",
"@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.0",
"@vue-flow/node-toolbar": "^1.1.1",
"@vueuse/core": "^14.1.0",
"@vueuse/router": "^14.1.0",
"@wangeditor-next/editor": "^5.6.49",
"@wangeditor-next/editor-for-vue": "^5.1.14",
"axios": "^1.12.2",
"canvas-confetti": "^1.9.3",
"chart.js": "^4.5.0",
"axios": "^1.13.2",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.1.0",
"fflate": "^0.8.2",
"highlight.js": "^11.11.1",
"md-editor-v3": "^6.0.1",
"naive-ui": "^2.43.1",
"md-editor-v3": "^6.2.1",
"mermaid": "^11.12.2",
"naive-ui": "^2.43.2",
"nanoid": "^5.1.6",
"normalize.css": "^8.0.1",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.2",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-chartjs": "^5.3.3",
"vue-codemirror": "^6.1.1",
"vue-router": "^4.5.1",
"vue-router": "^4.6.4",
"y-codemirror.next": "^0.3.5",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.27"
"yjs": "^13.6.28"
},
"devDependencies": {
"@iconify/vue": "^5.0.0",
"@rsbuild/core": "^1.5.13",
"@rsbuild/plugin-vue": "^1.1.2",
"@rsbuild/core": "^1.6.15",
"@rsbuild/plugin-vue": "^1.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.6.2",
"prettier": "^3.6.2",
"@types/node": "^25.0.3",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0"
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0"
}
}

View File

@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28.45" height="32" viewBox="0 0 256 288">
<path fill="#649AD2"
d="M255.987 84.59c-.002-4.837-1.037-9.112-3.13-12.781c-2.054-3.608-5.133-6.632-9.261-9.023c-34.08-19.651-68.195-39.242-102.264-58.913c-9.185-5.303-18.09-5.11-27.208.27c-13.565 8-81.48 46.91-101.719 58.632C4.071 67.6.015 74.984.013 84.58C0 124.101.013 163.62 0 203.141c0 4.73.993 8.923 2.993 12.537c2.056 3.717 5.177 6.824 9.401 9.269c20.24 11.722 88.164 50.63 101.726 58.631c9.121 5.382 18.027 5.575 27.215.27c34.07-19.672 68.186-39.262 102.272-58.913c4.224-2.444 7.345-5.553 9.401-9.267c1.997-3.614 2.992-7.806 2.992-12.539c0 0 0-79.018-.013-118.539" />
<path fill="#004482"
d="m128.392 143.476l-125.4 72.202c2.057 3.717 5.178 6.824 9.402 9.269c20.24 11.722 88.164 50.63 101.726 58.631c9.121 5.382 18.027 5.575 27.215.27c34.07-19.672 68.186-39.262 102.272-58.913c4.224-2.444 7.345-5.553 9.401-9.267l-124.616-72.192" />
<path fill="#1A4674"
d="M91.25 164.863c7.297 12.738 21.014 21.33 36.75 21.33c15.833 0 29.628-8.7 36.888-21.576l-36.496-21.141l-37.142 21.387" />
<path fill="#01589C"
d="M255.987 84.59c-.002-4.837-1.037-9.112-3.13-12.781l-124.465 71.667l124.616 72.192c1.997-3.614 2.99-7.806 2.992-12.539c0 0 0-79.018-.013-118.539" />
<path fill="#FFF"
d="M249.135 148.636h-9.738v9.74h-9.74v-9.74h-9.737V138.9h9.737v-9.738h9.74v9.738h9.738v9.737ZM128 58.847c31.135 0 58.358 16.74 73.17 41.709l.444.759l-37.001 21.307c-7.333-12.609-20.978-21.094-36.613-21.094c-23.38 0-42.333 18.953-42.333 42.332a42.13 42.13 0 0 0 5.583 21.003c7.297 12.738 21.014 21.33 36.75 21.33c15.659 0 29.325-8.51 36.647-21.153l.241-.423l36.947 21.406c-14.65 25.597-42.228 42.851-73.835 42.851c-31.549 0-59.084-17.185-73.754-42.707c-7.162-12.459-11.26-26.904-11.26-42.307c0-46.95 38.061-85.013 85.014-85.013Zm75.865 70.314v9.738h9.737v9.737h-9.737v9.74h-9.738v-9.74h-9.738V138.9h9.738v-9.738h9.738Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28.45" height="32" viewBox="0 0 256 288">
<path fill="#A9B9CB"
d="M255.987 85.672c-.002-4.843-1.037-9.122-3.129-12.794c-2.055-3.612-5.134-6.638-9.262-9.032c-34.081-19.67-68.195-39.28-102.264-58.97c-9.185-5.307-18.091-5.114-27.208.27c-13.565 8.008-81.481 46.956-101.719 58.689C4.071 68.665.015 76.056.013 85.663C0 125.221.013 164.777 0 204.336c.002 4.736.993 8.932 2.993 12.55c2.056 3.72 5.177 6.83 9.401 9.278c20.239 11.733 88.164 50.678 101.726 58.688c9.121 5.387 18.027 5.579 27.215.27c34.07-19.691 68.186-39.3 102.272-58.97c4.224-2.447 7.345-5.559 9.401-9.276c1.997-3.618 2.99-7.814 2.992-12.551c0 0 0-79.094-.013-118.653" />
<path fill="#7F8B99"
d="M141.101 5.134c-9.17-5.294-18.061-5.101-27.163.269C100.395 13.39 32.59 52.237 12.385 63.94C4.064 68.757.015 76.129.013 85.711C0 125.166.013 164.62 0 204.076c.002 4.724.991 8.909 2.988 12.517c2.053 3.711 5.169 6.813 9.386 9.254a9008.51 9008.51 0 0 0 20.159 11.62L219.625 50.375c-26.178-15.074-52.363-30.136-78.524-45.241" />
<path fill="#FFF"
d="m154.456 126.968l39.839.281c0-16.599-16.802-57.249-64.973-57.249c-30.691 0-71.951 19.512-71.951 75.61c0 56.097 40.447 74.39 71.951 74.39c51.017 0 63.21-35.302 63.21-55.252l-38.007-2.173s1.017 23.075-25.406 23.075c-24.39 0-28.46-29.878-28.46-40.04c0-15.447 5.493-40.244 28.46-40.244c22.968 0 25.337 21.602 25.337 21.602" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="85.34" height="32" viewBox="0 0 512 192">
<path fill="#00ACD7"
d="m292.533 13.295l1.124.75c13.212 8.725 22.685 20.691 28.917 35.15c1.496 2.243.499 3.49-2.493 4.237l-5.063 1.296c-11.447 2.949-20.53 5.429-31.827 8.378l-6.443 1.678c-2.32.574-2.96.333-5.428-2.477l-.348-.399c-3.519-3.988-6.155-6.652-10.817-9.03l-.899-.443c-15.705-7.727-30.911-5.484-45.12 3.74c-16.952 10.968-25.677 27.172-25.428 47.364c.25 19.942 13.96 36.395 33.654 39.137c16.951 2.244 31.16-3.739 42.378-16.452c2.244-2.743 4.238-5.734 6.73-9.224h-48.11c-5.235 0-6.481-3.24-4.736-7.478l.864-2.035c3.204-7.454 8.173-18.168 11.4-24.294l.704-1.319c.862-1.494 2.612-3.513 5.977-3.513h80.224c3.603-11.415 9.449-22.201 17.246-32.407c18.198-23.931 40.135-36.396 69.8-41.63c25.427-4.488 49.359-1.995 71.046 12.713c19.694 13.461 31.909 31.66 35.15 55.59c4.237 33.654-5.485 61.075-28.668 84.508c-16.453 16.702-36.645 27.172-59.829 31.908c-6.73 1.247-13.461 1.496-19.942 2.244c-22.685-.499-43.376-6.98-60.826-21.937c-12.273-10.61-20.727-23.648-24.928-38.828a104.937 104.937 0 0 1-10.47 16.89c-17.949 23.683-41.381 38.39-71.046 42.38c-24.43 3.24-47.115-1.497-67.058-16.454c-18.447-13.96-28.917-32.407-31.66-55.34c-3.24-27.173 4.737-51.603 21.19-73.041c17.7-23.184 41.132-37.891 69.8-43.126c22.999-4.16 45.037-1.595 64.936 11.464ZM411.12 49.017l-.798.178c-23.183 5.235-38.14 19.942-43.624 43.375c-4.488 19.444 4.985 39.138 22.934 47.115c13.71 5.983 27.421 5.235 40.633-1.496c19.694-10.22 30.413-26.175 31.66-47.613c-.25-3.24-.25-5.734-.749-8.227c-4.436-24.401-26.664-38.324-50.056-33.332ZM116.416 94.564c.997 0 1.496.748 1.496 1.745l-.499 5.983c0 .997-.997 1.745-1.745 1.745l-54.344-.249c-.997 0-1.246-.748-.748-1.496l3.49-6.232c.499-.748 1.496-1.496 2.493-1.496h49.857ZM121.9 71.63c.997 0 1.496.748 1.247 1.496l-1.995 5.983c-.249.997-1.246 1.495-2.243 1.495l-117.912.25c-.997 0-1.246-.499-.748-1.247l5.235-6.73c.499-.748 1.745-1.247 2.742-1.247H121.9Zm12.963-22.934c.997 0 1.246.748.748 1.496l-4.238 6.481c-.499.748-1.745 1.496-2.493 1.496l-90.24-.25c-.998 0-1.247-.498-.749-1.246l5.235-6.73c.499-.748 1.745-1.247 2.742-1.247h88.995Z" />
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="23.68" height="32" viewBox="0 0 256 346">
<path fill="#5382A1"
d="M82.554 267.473s-13.198 7.675 9.393 10.272c27.369 3.122 41.356 2.675 71.517-3.034c0 0 7.93 4.972 19.003 9.279c-67.611 28.977-153.019-1.679-99.913-16.517m-8.262-37.814s-14.803 10.958 7.805 13.296c29.236 3.016 52.324 3.263 92.276-4.43c0 0 5.526 5.602 14.215 8.666c-81.747 23.904-172.798 1.885-114.296-17.532" />
<path fill="#E76F00"
d="M143.942 165.515c16.66 19.18-4.377 36.44-4.377 36.44s42.301-21.837 22.874-49.183c-18.144-25.5-32.059-38.172 43.268-81.858c0 0-118.238 29.53-61.765 94.6" />
<path fill="#5382A1"
d="M233.364 295.442s9.767 8.047-10.757 14.273c-39.026 11.823-162.432 15.393-196.714.471c-12.323-5.36 10.787-12.8 18.056-14.362c7.581-1.644 11.914-1.337 11.914-1.337c-13.705-9.655-88.583 18.957-38.034 27.15c137.853 22.356 251.292-10.066 215.535-26.195M88.9 190.48s-62.771 14.91-22.228 20.323c17.118 2.292 51.243 1.774 83.03-.89c25.978-2.19 52.063-6.85 52.063-6.85s-9.16 3.923-15.787 8.448c-63.744 16.765-186.886 8.966-151.435-8.183c29.981-14.492 54.358-12.848 54.358-12.848m112.605 62.942c64.8-33.672 34.839-66.03 13.927-61.67c-5.126 1.066-7.411 1.99-7.411 1.99s1.903-2.98 5.537-4.27c41.37-14.545 73.187 42.897-13.355 65.647c0 .001 1.003-.895 1.302-1.697" />
<path fill="#E76F00"
d="M162.439.371s35.887 35.9-34.037 91.101c-56.071 44.282-12.786 69.53-.023 98.377c-32.73-29.53-56.75-55.526-40.635-79.72C111.395 74.612 176.918 57.393 162.439.37" />
<path fill="#5382A1"
d="M95.268 344.665c62.199 3.982 157.712-2.209 159.974-31.64c0 0-4.348 11.158-51.404 20.018c-53.088 9.99-118.564 8.824-157.399 2.421c.001 0 7.95 6.58 48.83 9.201" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256">
<path fill="#F7DF1E" d="M0 0h256v256H0V0Z" />
<path
d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574" />
</svg>

Before

Width:  |  Height:  |  Size: 868 B

View File

@@ -1,16 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32.13" height="32" viewBox="0 0 256 255">
<defs>
<linearGradient id="svgIDa" x1="12.959%" x2="79.639%" y1="12.039%" y2="78.201%">
<stop offset="0%" stop-color="#387EB8" />
<stop offset="100%" stop-color="#366994" />
</linearGradient>
<linearGradient id="svgIDb" x1="19.128%" x2="90.742%" y1="20.579%" y2="88.429%">
<stop offset="0%" stop-color="#FFE052" />
<stop offset="100%" stop-color="#FFC331" />
</linearGradient>
</defs>
<path fill="url(#svgIDa)"
d="M126.916.072c-64.832 0-60.784 28.115-60.784 28.115l.072 29.128h61.868v8.745H41.631S.145 61.355.145 126.77c0 65.417 36.21 63.097 36.21 63.097h21.61v-30.356s-1.165-36.21 35.632-36.21h61.362s34.475.557 34.475-33.319V33.97S194.67.072 126.916.072ZM92.802 19.66a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.13Z" />
<path fill="url(#svgIDb)"
d="M128.757 254.126c64.832 0 60.784-28.115 60.784-28.115l-.072-29.127H127.6v-8.745h86.441s41.486 4.705 41.486-60.712c0-65.416-36.21-63.096-36.21-63.096h-21.61v30.355s1.165 36.21-35.632 36.21h-61.362s-34.475-.557-34.475 33.32v56.013s-5.235 33.897 62.518 33.897Zm34.114-19.586a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.131a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,16 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32.13" height="32" viewBox="0 0 256 255">
<defs>
<linearGradient id="svgIDa" x1="12.959%" x2="79.639%" y1="12.039%" y2="78.201%">
<stop offset="0%" stop-color="#387EB8" />
<stop offset="100%" stop-color="#366994" />
</linearGradient>
<linearGradient id="svgIDb" x1="19.128%" x2="90.742%" y1="20.579%" y2="88.429%">
<stop offset="0%" stop-color="#FFE052" />
<stop offset="100%" stop-color="#FFC331" />
</linearGradient>
</defs>
<path fill="url(#svgIDa)"
d="M126.916.072c-64.832 0-60.784 28.115-60.784 28.115l.072 29.128h61.868v8.745H41.631S.145 61.355.145 126.77c0 65.417 36.21 63.097 36.21 63.097h21.61v-30.356s-1.165-36.21 35.632-36.21h61.362s34.475.557 34.475-33.319V33.97S194.67.072 126.916.072ZM92.802 19.66a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.13Z" />
<path fill="url(#svgIDb)"
d="M128.757 254.126c64.832 0 60.784-28.115 60.784-28.115l-.072-29.127H127.6v-8.745h86.441s41.486 4.705 41.486-60.712c0-65.416-36.21-63.096-36.21-63.096h-21.61v30.355s1.165 36.21-35.632 36.21h-61.362s-34.475-.557-34.475 33.32v56.013s-5.235 33.897 62.518 33.897Zm34.114-19.586a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.131a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13Z" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/badge-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/badge-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/badge-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/badge-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
public/badge-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/badge-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

329
public/flowchart-data.html Normal file
View File

@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python流程图作业 - 学情分析看板</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2.1.0/dist/echarts-wordcloud.min.js"></script>
<style>
:root {
--bg-color: #f0f2f5;
--card-bg: #ffffff;
--primary: #1890ff;
--text-main: #333;
--text-secondary: #666;
}
body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
background-color: var(--bg-color);
margin: 0;
padding: 20px;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 { color: var(--text-main); margin: 0; }
.header p { color: var(--text-secondary); margin-top: 5px; }
/* 顶部概览卡片 */
.overview-container {
display: flex;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
flex: 1;
transition: transform 0.3s;
}
.card:hover { transform: translateY(-5px); }
.stat-title { font-size: 14px; color: var(--text-secondary); }
.stat-value { font-size: 28px; font-weight: bold; color: var(--text-main); margin-top: 10px; }
.stat-sub { font-size: 12px; color: #52c41a; margin-top: 5px; }
/* 图表布局 */
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.chart-box {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
height: 400px;
}
.chart-title {
font-size: 16px;
font-weight: bold;
border-left: 4px solid var(--primary);
padding-left: 10px;
margin-bottom: 15px;
}
/* 学生列表 */
.student-list {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
th { background-color: #fafafa; color: var(--text-secondary); }
.tag {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.tag-S { background: #fff7e6; color: #fa8c16; }
.tag-A { background: #e6f7ff; color: #1890ff; }
.tag-B { background: #f6ffed; color: #52c41a; }
.tag-C { background: #fff1f0; color: #f5222d; }
</style>
</head>
<body>
<div class="overview-container">
<div class="card">
<div class="stat-title">班级平均分</div>
<div class="stat-value" id="avgScore">0</div>
<div class="stat-sub">↑ 比上周For循环 +2.5分</div>
</div>
<div class="card">
<div class="stat-title">S+A 级别(卓越+优秀)人数</div>
<div class="stat-value" id="countA">0</div>
<div class="stat-sub">占比 35.7%</div>
</div>
<div class="card">
<div class="stat-title">未掌握核心难点</div>
<div class="stat-value" style="color: #f5222d; font-size: 24px;">循环条件</div>
<div class="stat-sub">需重点讲解 a<100 边界</div>
</div>
</div>
<div class="charts-grid">
<div class="chart-box">
<div class="chart-title">作业评级分布</div>
<div id="pieChart" style="width: 100%; height: 340px;"></div>
</div>
<div class="chart-box">
<div class="chart-title">薄弱知识点词云 (AI分析)</div>
<div id="wordCloud" style="width: 100%; height: 340px;"></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-box" style="grid-column: span 2;">
<div class="chart-title">全班分数段统计</div>
<div id="barChart" style="width: 100%; height: 340px;"></div>
</div>
</div>
<script>
// --- 1. 模拟数据生成 (40位同学) ---
const totalStudents = 40;
const students = [];
const familyNames = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨";
// 精确分配等级人数S 10% (4人), A 15% (6人), B 50% (20人), C 15% (6人), D 10% (4人)
const gradeDistribution = {
S: 4, // 10% = 4人
A: 6, // 15% = 6人
B: 20, // 50% = 20人
C: 6, // 15% = 6人
D: 4 // 10% = 4人
};
let gradeCounts = { S: 0, A: 0, B: 0, C: 0, D: 0 };
let totalScore = 0;
// 创建等级数组,确保精确分配
let levelQueue = [];
for (let level in gradeDistribution) {
for (let i = 0; i < gradeDistribution[level]; i++) {
levelQueue.push(level);
}
}
// 打乱顺序
for (let i = levelQueue.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[levelQueue[i], levelQueue[j]] = [levelQueue[j], levelQueue[i]];
}
for (let i = 1; i <= totalStudents; i++) {
let score;
let level = levelQueue[i - 1];
// 根据等级生成对应分数范围
if (level === 'S') {
// S级95-100
score = Math.floor(Math.random() * (100 - 95 + 1) + 95);
} else if (level === 'A') {
// A级85-94
score = Math.floor(Math.random() * (95 - 85) + 85);
} else if (level === 'B') {
// B级70-84
score = Math.floor(Math.random() * (85 - 70) + 70);
} else if (level === 'C') {
// C级60-69
score = Math.floor(Math.random() * (70 - 60) + 60);
} else {
// D级50-59
score = Math.floor(Math.random() * (60 - 50) + 50);
}
gradeCounts[level]++;
totalScore += score;
let comment = "";
if (level === 'S') comment = "完美!逻辑清晰,变量初始化正确,闭环完美,代码规范。";
else if (level === 'A') comment = "逻辑清晰,变量初始化正确,闭环完美。";
else if (level === 'B') comment = "整体逻辑正确,但部分连线方向有误。";
else if (level === 'C') comment = "循环条件判断错误,导致死循环或无法进入。";
else comment = "基础概念理解不足,需要重新学习。";
students.push({
id: 2025000 + i,
name: familyNames[i % familyNames.length] + "同学",
score: score,
level: level,
comment: comment
});
}
// --- 2. 填充顶部数据 ---
document.getElementById('avgScore').innerText = (totalScore / totalStudents).toFixed(1);
const excellentCount = gradeCounts.S + gradeCounts.A; // S级和A级合计
document.getElementById('countA').innerText = excellentCount;
// 更新占比显示
const excellentPercent = ((excellentCount / totalStudents) * 100).toFixed(1);
const countACard = document.getElementById('countA').parentElement;
countACard.querySelector('.stat-sub').innerText = `占比 ${excellentPercent}%`;
// --- 3. 初始化图表 ---
// A. 饼图 - 等级分布
const pieChart = echarts.init(document.getElementById('pieChart'));
pieChart.setOption({
tooltip: { trigger: 'item' },
legend: { top: '5%', left: 'center' },
color: ['#fa8c16', '#1890ff', '#52c41a', '#faad14', '#f5222d'],
series: [{
name: '评级占比',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
label: { show: false, position: 'center' },
emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } },
data: [
{ value: gradeCounts.S, name: 'S级 (卓越)' },
{ value: gradeCounts.A, name: 'A级 (优秀)' },
{ value: gradeCounts.B, name: 'B级 (良好)' },
{ value: gradeCounts.C, name: 'C级 (待改进)' }
]
}]
});
// B. 词云图 - 知识点掌握情况
// 这里重点突出“循环条件”
const wordChart = echarts.init(document.getElementById('wordCloud'));
wordChart.setOption({
tooltip: {},
series: [{
type: 'wordCloud',
gridSize: 2,
sizeRange: [12, 60], // 字体大小范围
rotationRange: [-45, 45],
shape: 'circle',
width: '100%',
height: '100%',
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold',
color: function () {
return 'rgb(' + [
Math.round(Math.random() * 160),
Math.round(Math.random() * 160),
Math.round(Math.random() * 160)
].join(',') + ')';
}
},
data: [
{ name: '循环条件', value: 150, textStyle: { color: 'red' } }, // 核心痛点
{ name: '变量初始化', value: 80 },
{ name: 'i=i+1', value: 70 },
{ name: 'a<100', value: 65 },
{ name: '死循环', value: 60 },
{ name: '连线方向', value: 50 },
{ name: '退出逻辑', value: 45 },
{ name: 'While语法', value: 40 },
{ name: 'Print缩进', value: 35 },
{ name: 'Yes/No分支', value: 30 },
{ name: '流程结束符', value: 25 },
{ name: '变量定义', value: 20 }
]
}]
});
// C. 柱状图 - 分数段
const barChart = echarts.init(document.getElementById('barChart'));
// 简单的分段统计
let ranges = { '90-100': 0, '80-89': 0, '70-79': 0, '60-69': 0, '<60': 0 };
students.forEach(s => {
if (s.score >= 90) ranges['90-100']++;
else if (s.score >= 80) ranges['80-89']++;
else if (s.score >= 70) ranges['70-79']++;
else if (s.score >= 60) ranges['60-69']++;
else ranges['<60']++;
});
barChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: Object.keys(ranges) },
yAxis: { type: 'value' },
series: [{
name: '人数',
type: 'bar',
barWidth: '50%',
data: Object.values(ranges),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
])
}
}]
});
// 窗口缩放适配
window.addEventListener('resize', function() {
pieChart.resize();
wordChart.resize();
barChart.resize();
});
</script>
</body>
</html>

View File

@@ -10,19 +10,15 @@ export default defineConfig(({ envMode }) => {
mode: envMode,
})
const url = rawPublicVars["PUBLIC_OJ_URL"]
const proxyConfig = {
target: url,
headers: { Referer: url },
target: rawPublicVars["PUBLIC_OJ_URL"],
changeOrigin: true,
}
// WebSocket 代理配置(开发环境 Daphne 在 8001 端口)
const wsProxyConfig = {
target: url.replace(':8000', ':8001').replace('http:', 'ws:'), // http://localhost:8001 → ws://localhost:8001
ws: true, // 启用 WebSocket 代理
target: rawPublicVars["PUBLIC_WS_URL"],
ws: true,
changeOrigin: true,
logLevel: 'debug' as const, // 显示代理日志
}
return {
plugins: [pluginVue()],
@@ -80,61 +76,7 @@ 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: {
@@ -149,7 +91,7 @@ export default defineConfig(({ envMode }) => {
proxy: {
"/api": proxyConfig,
"/public": proxyConfig,
"/ws": wsProxyConfig, // WebSocket 使用单独的代理配置
"/ws": wsProxyConfig,
},
},
}

View File

@@ -2,8 +2,24 @@
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
import "normalize.css"
import "./index.css"
import { useConfigStore } from "shared/store/config"
import { useConfigUpdate } from "shared/composables/configUpdate"
import { useMaxKB } from "shared/composables/maxkb"
import { useUserStore } from "shared/store/user"
const isDark = useDark()
const configStore = useConfigStore()
const userStore = useUserStore()
// 初始化配置和实时更新
onMounted(() => {
configStore.getConfig()
userStore.getMyProfile()
})
// 使用配置更新和 MaxKB 功能
useConfigUpdate()
useMaxKB()
// 延迟加载 highlight.js避免阻塞首屏
const hljsInstance = ref<any>(null)

View File

@@ -134,7 +134,6 @@ export async function uploadImage(file: File): Promise<string> {
export function uploadTestcases(file: File) {
const form = new window.FormData()
form.append("file", file)
form.append("spj", "false")
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
headers: { "content-type": "multipart/form-data" },
})
@@ -286,3 +285,148 @@ export function updateACMHelperChecked(
checked,
})
}
// 题单管理 API
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return http.get("admin/problemset", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
})
}
export function getProblemSetDetail(id: number) {
return http.get(`admin/problemset/${id}`)
}
export function createProblemSet(data: {
title: string
description: string
difficulty: string
status: string
}) {
return http.post("admin/problemset", data)
}
export function editProblemSet(data: {
id: number
title?: string
description?: string
difficulty?: string
status?: string
visible?: boolean
}) {
return http.put("admin/problemset", data)
}
export function deleteProblemSet(id: number) {
return http.delete("admin/problemset", { params: { id } })
}
export function toggleProblemSetVisible(id: number) {
return http.put("admin/problemset/visible", { id })
}
export function updateProblemSetStatus(id: number, status: string) {
return http.put("admin/problemset/status", { id, status })
}
// 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/problems`)
}
export function addProblemToSet(
problemSetId: number,
data: {
problem_id: string
order?: number
is_required?: boolean
score?: number
hint?: string
},
) {
return http.post(`admin/problemset/${problemSetId}/problems`, data)
}
export function editProblemInSet(
problemSetId: number,
problemSetProblemId: number,
data: {
order?: number
is_required?: boolean
score?: number
hint?: string
},
) {
return http.put(
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
data,
)
}
export function removeProblemFromSet(
problemSetId: number,
problemSetProblemId: number,
) {
return http.delete(
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
)
}
// 题单奖章管理 API
export function getProblemSetBadges(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/badges`)
}
export function createProblemSetBadge(
problemSetId: number,
data: {
name: string
description: string
icon: string
condition_type: string
condition_value: number
level?: number
},
) {
return http.post(`admin/problemset/${problemSetId}/badges`, data)
}
export function editProblemSetBadge(
problemSetId: number,
badgeId: number,
data: {
name?: string
description?: string
icon?: string
condition_type?: string
condition_value?: number
level?: number
},
) {
return http.put(`admin/problemset/${problemSetId}/badges/${badgeId}`, data)
}
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
return http.delete(`admin/problemset/${problemSetId}/badges/${badgeId}`)
}
// 题单进度管理 API
export function getProblemSetProgress(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/progress`)
}
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`)
}

View File

@@ -4,7 +4,7 @@ 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"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
contestID: string
@@ -28,6 +28,8 @@ interface HelperItem {
const props = defineProps<Props>()
const message = useMessage()
const { isDesktop } = useBreakpoints()
const submissions = ref<HelperItem[]>([])
const contestStartTime = ref<Date | null>(null)
const query = reactive({

View File

@@ -42,13 +42,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
async function getList() {
const offset = (query.page - 1) * query.limit
const res = await getProblemList(
offset,
query.limit,
query.keyword,
"",
"ACM",
)
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
total.value = res.total
problems.value = res.results
}

View File

@@ -8,7 +8,7 @@ import {
} from "utils/constants"
import download from "utils/download"
import { unique } from "utils/functions"
import { BlankProblem, LANGUAGE, Tag } from "utils/types"
import { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import {
createContestProblem,
createProblem,
@@ -22,6 +22,10 @@ const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"),
)
const MermaidEditor = defineAsyncComponent(
() => import("shared/components/MermaidEditor.vue"),
)
interface Props {
problemID?: string
contestID?: string
@@ -50,33 +54,35 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
output_description: "",
time_limit: 1000,
memory_limit: 64,
difficulty: "Low",
difficulty: "Low" as "Low" | "Mid" | "High",
visible: false,
share_submission: false,
tags: [],
languages: ["C", "Python3"],
template: {},
languages: ["Python3", "C"] as LANGUAGE[],
template: {} as { [key in LANGUAGE]?: string },
samples: [
{ input: "", output: "" },
{ input: "", output: "" },
{ input: "", output: "" },
],
spj: false,
spj_language: "",
spj_code: "",
spj_compile_ok: false,
test_case_id: "",
test_case_score: [],
test_case_score: [] as Testcase[],
rule_type: "ACM",
hint: "",
source: "",
prompt: "",
answers: [],
answers: [] as { language: LANGUAGE; code: string }[],
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
output: "output.txt",
},
contest_id: "",
allow_flowchart: false,
mermaid_code: "",
flowchart_data: {},
flowchart_hint: "",
show_flowchart: false,
})
// 从服务器来的tag列表
@@ -101,6 +107,9 @@ const currentActiveAnswer = ref<LANGUAGE>("Python3")
// 给 TextEditor 用
const [ready, toggleReady] = useToggle(false)
// Mermaid 渲染状态
const mermaidRenderSuccess = ref(false)
const difficultyOptions: SelectOption[] = [
{ label: "简单", value: "Low" },
{ label: "中等", value: "Mid" },
@@ -142,16 +151,18 @@ async function getProblemDetail() {
problem.value.template = data.template
problem.value.samples = data.samples
problem.value.samples = data.samples
problem.value.spj = data.spj
problem.value.spj_language = data.spj_language
problem.value.spj_code = data.spj_code
problem.value.spj_compile_ok = data.spj_compile_ok
problem.value.test_case_id = data.test_case_id
problem.value.test_case_score = data.test_case_score
problem.value.rule_type = data.rule_type
problem.value.hint = data.hint
problem.value.source = data.source
problem.value.prompt = data.prompt
// 流程图相关字段
problem.value.allow_flowchart = data.allow_flowchart
problem.value.show_flowchart = data.show_flowchart
problem.value.mermaid_code = data.mermaid_code ?? ""
problem.value.flowchart_hint = data.flowchart_hint ?? ""
problem.value.flowchart_data = data.flowchart_data
if (data.answers && data.answers.length) {
problem.value.answers = data.answers
} else {
@@ -225,9 +236,6 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
const testcases = res.data.info
for (let file of testcases) {
file.score = (100 / testcases.length).toFixed(0)
if (!file.output_name && problem.value.spj) {
file.output_name = "-"
}
}
problem.value.test_case_score = testcases
problem.value.test_case_id = res.data.id
@@ -240,18 +248,23 @@ function downloadTestcases() {
download("test_case?problem_id=" + problem.value.id)
}
// Mermaid 渲染事件处理
function onMermaidRenderSuccess() {
mermaidRenderSuccess.value = true
}
// 题目是否有漏写的
function detectProblemCompletion() {
let flag = false
async function validateProblem() {
let hasErrors = false
// 标题
if (!problem.value._id || !problem.value.title) {
message.error("编号或标题没有填写")
flag = true
hasErrors = true
}
// 标签
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) {
message.error("标签没有填写")
flag = true
hasErrors = true
}
// 题目
else if (
@@ -260,12 +273,12 @@ function detectProblemCompletion() {
!problem.value.output_description
) {
message.error("题目或输入或输出没有填写")
flag = true
hasErrors = true
}
// 样例
else if (problem.value.samples.length == 0) {
message.error("样例没有填写")
flag = true
hasErrors = true
}
// 样例是空的
else if (
@@ -274,21 +287,34 @@ function detectProblemCompletion() {
)
) {
message.error("空样例没有删干净")
flag = true
hasErrors = true
}
// 测试用例
else if (problem.value.test_case_score.length === 0) {
message.error("测试用例没有上传")
flag = true
hasErrors = true
} else if (problem.value.languages.length === 0) {
message.error("编程语言没有选择")
flag = true
hasErrors = true
}
// 流程图验证
else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
if (
!problem.value.mermaid_code ||
problem.value.mermaid_code.trim() === ""
) {
message.error("启用了流程图功能,但流程图代码为空")
hasErrors = true
} else if (!mermaidRenderSuccess.value) {
message.error("Mermaid 代码尚未成功渲染,请检查代码语法")
hasErrors = true
}
}
// 通过了
else {
flag = false
hasErrors = false
}
return flag
return hasErrors
}
function getTemplate() {
@@ -319,8 +345,8 @@ function filterAnswers() {
}
async function submit() {
const notCompleted = detectProblemCompletion()
if (notCompleted) return
const hasValidationErrors = await validateProblem()
if (hasValidationErrors) return
filterHint()
getTemplate()
filterAnswers()
@@ -499,14 +525,50 @@ watch(
placeholder="比如来自某道题的改编等,或者网上的资料"
/>
</n-form-item>
<n-form-item label="本题的考察知识点(选填)">
<n-form-item label="本题的考察知识点(选填,用于 AI 分析">
<n-input
v-model:value="problem.prompt"
placeholder="这里的内容是方便喂给 AI 进行辅助分析的"
placeholder="比如考察选择、循环、算法等知识点"
/>
</n-form-item>
</n-form>
<n-divider />
<h2 class="title">代码区域</h2>
<n-form inline label-placement="left">
<n-form-item label="编程语言">
<n-checkbox-group v-model:value="problem.languages">
<n-flex align="center">
<n-checkbox
v-for="(language, index) in languageOptions"
:key="index"
:value="language.value"
:label="language.label"
/>
</n-flex>
</n-checkbox-group>
</n-form-item>
<n-form-item>
<n-checkbox
v-model:checked="needTemplate"
label="预制代码(显示在编辑器中,帮助快速上手)"
/>
</n-form-item>
<n-form-item>
<n-button
v-if="needTemplate"
size="small"
tertiary
type="warning"
@click="resetTemplate(currentActiveTemplate)"
>
重置 {{ LANGUAGE_SHOW_VALUE[currentActiveTemplate] }} 的预制代码
</n-button>
</n-form-item>
</n-form>
<n-grid :cols="2" x-gap="20">
<n-gi>
<n-form>
@@ -525,7 +587,7 @@ watch(
v-model:value="answer.code"
:language="answer.language"
:font-size="16"
height="200px"
height="300px"
/>
</n-tab-pane>
</n-tabs>
@@ -534,7 +596,7 @@ watch(
</n-gi>
<n-gi>
<n-form v-if="needTemplate">
<n-form-item label="编写代码模板">
<n-form-item label="编写预制代码">
<n-tabs
type="segment"
default-value="Python3"
@@ -549,7 +611,7 @@ watch(
v-model:value="template[lang]"
:language="lang"
:font-size="16"
height="200px"
height="300px"
/>
</n-tab-pane>
</n-tabs>
@@ -557,6 +619,36 @@ watch(
</n-form>
</n-gi>
</n-grid>
<n-divider />
<h2 class="title">流程图区域</h2>
<!-- 流程图相关设置 -->
<n-form inline label-placement="left">
<n-form-item label="允许提交流程图">
<n-switch v-model:value="problem.allow_flowchart" />
</n-form-item>
<n-form-item label="显示标准流程图">
<n-switch v-model:value="problem.show_flowchart" />
</n-form-item>
</n-form>
<n-form>
<n-form-item label="流程图">
<MermaidEditor
v-model="problem.mermaid_code"
@render-success="onMermaidRenderSuccess"
/>
</n-form-item>
<n-form-item label="流程图提示信息(选填)">
<n-input
v-model:value="problem.flowchart_hint"
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
/>
</n-form-item>
</n-form>
<n-divider />
<n-alert
class="box"
v-if="problem.test_case_score.length"
@@ -582,57 +674,24 @@ watch(
</n-flex>
</template>
</n-alert>
<n-space style="margin-bottom: 100px" justify="space-between">
<n-form inline label-placement="left" :show-feedback="false">
<n-form-item label="编程语言">
<n-checkbox-group v-model:value="problem.languages">
<n-flex align="center">
<n-checkbox
v-for="(language, index) in languageOptions"
:key="index"
:value="language.value"
:label="language.label"
/>
</n-flex>
</n-checkbox-group>
</n-form-item>
<n-form-item>
<n-checkbox
v-model:checked="needTemplate"
label="代码模板(一般用不到)"
/>
</n-form-item>
<n-form-item>
<n-button
v-if="needTemplate"
size="small"
tertiary
type="warning"
@click="resetTemplate(currentActiveTemplate)"
>
重置 {{ LANGUAGE_SHOW_VALUE[currentActiveTemplate] }} 代码模板
</n-button>
</n-form-item>
</n-form>
<n-flex align="center">
<n-tooltip placement="left">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">上传测试用例</n-button>
</n-upload>
</div>
<n-button type="primary" @click="submit">提交</n-button>
</n-flex>
</n-space>
<n-flex style="margin-bottom: 120px" align="center" justify="end">
<n-tooltip placement="left">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">上传测试用例</n-button>
</n-upload>
</div>
<n-button type="primary" @click="submit">提交</n-button>
</n-flex>
</template>
<style scoped>

View File

@@ -150,7 +150,7 @@ watch(() => [query.page, query.limit, query.author], listProblems)
type="primary"
@click="selectProblems"
>
从题中选择
从题中选择
</n-button>
<n-flex align="center" v-if="!props.contestID">
<span>出题人</span>

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
interface Props {
problemSetId: number
}
const props = defineProps<Props>()
const emit = defineEmits(["updated"])
const router = useRouter()
const message = useMessage()
const showStatusModal = ref(false)
const newStatus = ref<"active" | "archived" | "draft">("active")
const statusOptions = [
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
async function handleDeleteProblemSet() {
try {
await deleteProblemSet(props.problemSetId)
message.success("删除成功")
emit("updated")
} catch (err: any) {
message.error("删除失败:" + (err.data || "未知错误"))
}
}
function goEdit() {
router.push({
name: "admin problemset edit",
params: { problemSetId: props.problemSetId },
})
}
function goDetail() {
router.push({
name: "admin problemset detail",
params: { problemSetId: props.problemSetId },
})
}
function openStatusModal() {
showStatusModal.value = true
}
async function handleUpdateStatus() {
try {
await updateProblemSetStatus(props.problemSetId, newStatus.value)
message.success("状态更新成功")
showStatusModal.value = false
emit("updated")
} catch (err: any) {
message.error("状态更新失败:" + (err.data || "未知错误"))
}
}
</script>
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary type="info" @click="goDetail">
详情
</n-button>
<n-button size="small" secondary type="warning" @click="openStatusModal">
状态
</n-button>
<n-popconfirm @positive-click="handleDeleteProblemSet">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这个题单吗删除后题单将不可见
</n-popconfirm>
</n-flex>
<n-modal
v-model:show="showStatusModal"
preset="card"
title="更新题单状态"
style="width: 400px"
>
<n-space vertical>
<n-form>
<n-form-item label="状态" required>
<n-select
v-model:value="newStatus"
:options="statusOptions"
placeholder="选择状态"
/>
</n-form-item>
</n-form>
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="showStatusModal = false">取消</n-button>
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
name: string
description: string
icon: string
condition_type: "all_problems" | "problem_count" | "score"
condition_value?: number
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const newBadgeName = ref("")
const newBadgeDescription = ref("")
const newBadgeIcon = ref("")
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
"all_problems",
)
const newBadgeConditionValue = ref(1)
const BADGE_LEN = 6
const badgeIconOptions = []
for (let i = 1; i <= BADGE_LEN; i++) {
badgeIconOptions.push({
label: `奖章${i}`,
value: `/badge-${i}.png`,
icon: `/badge-${i}.png`,
})
}
const conditionTypeOptions = [
{ label: "完成所有题目", value: "all_problems" },
{ label: "完成指定数量题目", value: "problem_count" },
{ label: "达到指定分数", value: "score" },
]
function handleConfirm() {
const data: any = {
name: newBadgeName.value,
description: newBadgeDescription.value,
icon: newBadgeIcon.value,
condition_type: newBadgeConditionType.value,
}
// 只有非"完成所有题目"时才添加条件值
if (newBadgeConditionType.value !== "all_problems") {
data.condition_value = newBadgeConditionValue.value
}
emit("confirm", data)
}
function handleCancel() {
emit("update:show", false)
}
// 重置表单
watch(
() => props.show,
(newVal) => {
if (newVal) {
newBadgeName.value = ""
newBadgeDescription.value = ""
newBadgeIcon.value = ""
newBadgeConditionType.value = "all_problems"
newBadgeConditionValue.value = 1
}
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="添加奖章"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item label="奖章名称" required>
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="newBadgeDescription"
type="textarea"
placeholder="奖章描述"
required
/>
</n-form-item>
<n-form-item label="图标" required>
<n-flex align="center" gap="small">
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="newBadgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
newBadgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
newBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
:src="option.icon"
width="50"
height="50"
object-fit="cover"
preview-disabled
style="border-radius: 2px"
/>
</div>
</n-flex>
</n-form-item>
<n-flex align="center">
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="newBadgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="newBadgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="newBadgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
</n-flex>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
problem_id: string
order: number
is_required: boolean
score: number
hint: string
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const newProblemId = ref("")
const newProblemOrder = ref(0)
const newProblemRequired = ref(true)
const newProblemScore = ref(0)
const newProblemHint = ref("")
function handleConfirm() {
emit("confirm", {
problem_id: newProblemId.value,
order: newProblemOrder.value,
is_required: newProblemRequired.value,
score: newProblemScore.value,
hint: newProblemHint.value,
})
}
function handleCancel() {
emit("update:show", false)
}
// 重置表单
watch(
() => props.show,
(newVal) => {
if (newVal) {
newProblemId.value = ""
newProblemOrder.value = 0
newProblemRequired.value = true
newProblemScore.value = 0
newProblemHint.value = ""
}
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="添加题目"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item label="题目ID" required>
<n-input
v-model:value="newProblemId"
placeholder="请输入题目的显示ID1001"
/>
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="newProblemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="newProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number
v-model:value="newProblemScore"
placeholder="题目分数"
/>
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="newProblemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { h } from "vue"
import { ProblemSetBadge } from "utils/types"
import { NButton, NImage } from "naive-ui"
interface Props {
badges: ProblemSetBadge[]
}
interface Emits {
(e: "add-badge"): void
(e: "edit-badge", badge: ProblemSetBadge): void
(e: "delete-badge", badgeId: number): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>奖章列表</h3>
<n-button type="primary" @click="$emit('add-badge')"> 添加奖章 </n-button>
</n-flex>
<n-data-table
:columns="[
{
title: '图标',
key: 'icon',
render: (row) =>
h(NImage, {
src: row.icon,
width: 40,
height: 40,
objectFit: 'cover',
previewDisabled: true,
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
}),
},
{ title: '名称', key: 'name' },
{
title: '条件类型',
key: 'condition_type',
render: (row) => {
const typeMap: Record<string, string> = {
all_problems: '完成所有题目',
problem_count: '完成指定数量题目',
score: '达到指定分数',
}
return typeMap[row.condition_type] || row.condition_type
},
},
{
title: '条件值',
key: 'condition_value',
render: (row) => {
return row.condition_type === 'all_problems'
? '-'
: row.condition_value
},
},
{ title: '描述', key: 'description' },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { style: 'display: flex; gap: 8px;' }, [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => $emit('edit-badge', row),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => $emit('delete-badge', row.id),
},
{ default: () => '删除' },
),
]),
},
]"
:data="badges"
/>
</div>
</template>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import { ProblemSetBadge } from "utils/types"
interface Props {
show: boolean
badge: ProblemSetBadge | null
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
name: string
description: string
icon: string
condition_type: "all_problems" | "problem_count" | "score"
condition_value?: number
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editBadgeName = ref("")
const editBadgeDescription = ref("")
const editBadgeIcon = ref("")
const editBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
"all_problems",
)
const editBadgeConditionValue = ref(1)
// 预设奖章图标选项
const BADGE_LEN = 6
const badgeIconOptions = []
for (let i = 1; i <= BADGE_LEN; i++) {
badgeIconOptions.push({
label: `奖章${i}`,
value: `/badge-${i}.png`,
icon: `/badge-${i}.png`,
})
}
const conditionTypeOptions = [
{ label: "完成所有题目", value: "all_problems" },
{ label: "完成指定数量题目", value: "problem_count" },
{ label: "达到指定分数", value: "score" },
]
function handleConfirm() {
const data: any = {
name: editBadgeName.value,
description: editBadgeDescription.value,
icon: editBadgeIcon.value,
condition_type: editBadgeConditionType.value,
}
// 只有非"完成所有题目"时才添加条件值
if (editBadgeConditionType.value !== "all_problems") {
data.condition_value = editBadgeConditionValue.value
}
emit("confirm", data)
}
function handleCancel() {
emit("update:show", false)
}
// 当奖章数据变化时,更新表单数据
watch(
() => props.badge,
(newBadge) => {
if (newBadge) {
editBadgeName.value = newBadge.name
editBadgeDescription.value = newBadge.description
editBadgeIcon.value = newBadge.icon
editBadgeConditionType.value = newBadge.condition_type
editBadgeConditionValue.value = newBadge.condition_value
}
},
{ immediate: true },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="编辑奖章"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="badge">
<n-form-item label="奖章名称" required>
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="editBadgeDescription"
type="textarea"
placeholder="奖章描述"
required
/>
</n-form-item>
<n-form-item label="图标" required>
<n-flex align="center" gap="small">
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="editBadgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
editBadgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
:src="option.icon"
width="50"
height="50"
object-fit="cover"
style="border-radius: 2px"
preview-disabled
/>
</div>
</n-flex>
</n-form-item>
<n-flex align="center">
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="editBadgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="editBadgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="editBadgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
</n-flex>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ProblemSetProblem } from "utils/types"
interface Props {
show: boolean
problem: ProblemSetProblem | null
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
order: number
is_required: boolean
score: number
hint: string
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editProblemOrder = ref(0)
const editProblemRequired = ref(true)
const editProblemScore = ref(0)
const editProblemHint = ref("")
function handleConfirm() {
emit("confirm", {
order: editProblemOrder.value,
is_required: editProblemRequired.value,
score: editProblemScore.value,
hint: editProblemHint.value || "",
})
}
function handleCancel() {
emit("update:show", false)
}
// 当问题数据变化时,更新表单数据
watch(
() => props.problem,
(newProblem) => {
if (newProblem) {
editProblemOrder.value = newProblem.order
editProblemRequired.value = newProblem.is_required
editProblemScore.value = newProblem.score
editProblemHint.value = newProblem.hint || ""
}
},
{ immediate: true },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="编辑题目"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="problem">
<n-form-item label="题目标题">
<n-input :value="problem.problem.title" disabled />
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="editProblemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="editProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number
v-model:value="editProblemScore"
placeholder="题目分数"
/>
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="editProblemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import { ProblemSetProblem } from "utils/types"
interface Props {
problems: ProblemSetProblem[]
}
interface Emits {
(e: "add-problem"): void
(e: "edit-problem", problem: ProblemSetProblem): void
(e: "remove-problem", problemSetProblemId: number): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>题目列表</h3>
<n-button type="primary" @click="$emit('add-problem')">
添加题目
</n-button>
</n-flex>
<n-data-table
:columns="[
{ title: '题目ID', key: 'problem._id', width: 80 },
{ title: '题目标题', key: 'problem.title', minWidth: 200 },
{ title: '顺序', key: 'order', width: 80 },
{
title: '必做',
key: 'is_required',
width: 80,
render: (row) => (row.is_required ? '是' : '否'),
},
{ title: '分数', key: 'score', width: 80 },
{ title: '提示', key: 'hint', minWidth: 200 },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { style: 'display: flex; gap: 8px;' }, [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => $emit('edit-problem', row),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => $emit('remove-problem', row.id),
},
{ default: () => '移除' },
),
]),
},
]"
:data="problems"
/>
</div>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { parseTime } from "utils/functions"
import { ProblemSet } from "utils/types"
interface Props {
problemSet: ProblemSet
}
defineProps<Props>()
</script>
<template>
<n-card title="题单信息" style="margin-bottom: 16px">
<n-descriptions :column="4" bordered>
<n-descriptions-item label="描述">
{{ problemSet.description }}
</n-descriptions-item>
<n-descriptions-item label="创建者">
{{ problemSet.created_by.username }}
</n-descriptions-item>
<n-descriptions-item label="难度">
<n-tag
:type="
problemSet.difficulty === 'Easy'
? 'success'
: problemSet.difficulty === 'Medium'
? 'warning'
: 'error'
"
>
{{
problemSet.difficulty === "Easy"
? "简单"
: problemSet.difficulty === "Medium"
? "中等"
: "困难"
}}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag
:type="
problemSet.status === 'active'
? 'success'
: problemSet.status === 'archived'
? 'default'
: 'info'
"
>
{{
problemSet.status === "active"
? "活跃"
: problemSet.status === "archived"
? "已归档"
: "草稿"
}}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="可见">
{{ problemSet.visible ? "是" : "否" }}
</n-descriptions-item>
<n-descriptions-item label="题目数量">
{{ problemSet.problems_count }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss") }}
</n-descriptions-item>
</n-descriptions>
</n-card>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import { parseTime } from "utils/functions"
import { ProblemSetProgress } from "utils/types"
interface Props {
progress: ProblemSetProgress[]
}
interface Emits {
(e: "remove-user", userId: number): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
// 定义表格列
const progressColumns = [
{ title: "用户", key: "user.username", width: 120 },
{
title: "加入时间",
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) =>
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
},
{ title: "已完成", key: "completed_problems_count", width: 100 },
{ title: "总题目", key: "total_problems_count", width: 100 },
{
title: "进度",
key: "progress_percentage",
width: 100,
render: (row: ProblemSetProgress) =>
`${row.progress_percentage.toFixed(0)}%`,
},
{
title: "是否完成",
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => (row.is_completed ? "是" : "否"),
},
{
title: "操作",
key: "actions",
width: 120,
render: (row: ProblemSetProgress) =>
h(
NButton,
{
size: "small",
type: "error",
secondary: true,
onClick: () => emit("remove-user", row.user.id),
},
{ default: () => "移除" },
),
},
]
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>用户进度</h3>
</n-flex>
<n-data-table :columns="progressColumns" :data="progress" />
</div>
</template>

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
import {
ProblemSet,
ProblemSetProblem,
ProblemSetBadge,
ProblemSetProgress,
} from "utils/types"
import {
getProblemSetDetail,
getProblemSetProblems,
getProblemSetBadges,
getProblemSetProgress,
addProblemToSet,
editProblemInSet,
removeProblemFromSet,
createProblemSetBadge,
editProblemSetBadge,
deleteProblemSetBadge,
removeUserFromProblemSet,
} from "../api"
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
import ProblemManagement from "./components/ProblemManagement.vue"
import BadgeManagement from "./components/BadgeManagement.vue"
import ProgressManagement from "./components/ProgressManagement.vue"
import AddProblemModal from "./components/AddProblemModal.vue"
import EditProblemModal from "./components/EditProblemModal.vue"
import AddBadgeModal from "./components/AddBadgeModal.vue"
import EditBadgeModal from "./components/EditBadgeModal.vue"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const problemSet = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([])
const badges = ref<ProblemSetBadge[]>([])
const progress = ref<ProblemSetProgress[]>([])
// 模态框状态
const showAddProblemModal = ref(false)
const showEditProblemModal = ref(false)
const showAddBadgeModal = ref(false)
const showEditBadgeModal = ref(false)
// 编辑数据
const editingProblem = ref<ProblemSetProblem | null>(null)
const editingBadge = ref<ProblemSetBadge | null>(null)
async function loadProblemSetDetail() {
try {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
}
}
async function loadProblems() {
try {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res.data
} catch (err: any) {
message.error("加载题目列表失败:" + (err.data || "未知错误"))
}
}
async function loadBadges() {
try {
const res = await getProblemSetBadges(problemSetId.value)
badges.value = res.data
} catch (err: any) {
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
}
}
async function loadProgress() {
try {
const res = await getProblemSetProgress(problemSetId.value)
progress.value = res.data
} catch (err: any) {
message.error("加载进度列表失败:" + (err.data || "未知错误"))
}
}
async function handleAddProblem(data: any) {
try {
await addProblemToSet(problemSetId.value, data)
message.success("题目添加成功")
showAddProblemModal.value = false
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("添加题目失败:" + (err.data || "未知错误"))
}
}
async function handleRemoveProblem(problemSetProblemId: number) {
try {
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
message.success("题目移除成功")
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("移除题目失败:" + (err.data || "未知错误"))
}
}
async function handleEditProblem(data: any) {
if (!editingProblem.value) return
try {
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
message.success("题目编辑成功")
showEditProblemModal.value = false
loadProblems()
} catch (err: any) {
message.error("编辑题目失败:" + (err.data || "未知错误"))
}
}
async function handleAddBadge(data: any) {
try {
await createProblemSetBadge(problemSetId.value, data)
message.success("奖章创建成功")
showAddBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("创建奖章失败:" + (err.data || "未知错误"))
}
}
async function handleDeleteBadge(badgeId: number) {
try {
await deleteProblemSetBadge(problemSetId.value, badgeId)
message.success("奖章删除成功")
loadBadges()
} catch (err: any) {
message.error("删除奖章失败:" + (err.data || "未知错误"))
}
}
async function handleEditBadge(data: any) {
if (!editingBadge.value) return
try {
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
message.success("奖章编辑成功")
showEditBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("编辑奖章失败:" + (err.data || "未知错误"))
}
}
async function handleRemoveUser(userId: number) {
try {
await removeUserFromProblemSet(problemSetId.value, userId)
message.success("用户移除成功")
loadProgress()
} catch (err: any) {
message.error("移除用户失败:" + (err.data || "未知错误"))
}
}
function openAddProblemModal() {
showAddProblemModal.value = true
}
function openAddBadgeModal() {
showAddBadgeModal.value = true
}
function openEditProblemModal(problem: ProblemSetProblem) {
editingProblem.value = problem
showEditProblemModal.value = true
}
function openEditBadgeModal(badge: ProblemSetBadge) {
editingBadge.value = badge
showEditBadgeModal.value = true
}
onMounted(() => {
loadProblemSetDetail()
loadProblems()
loadBadges()
loadProgress()
})
</script>
<template>
<div v-if="problemSet">
<n-flex class="titleWrapper" justify="space-between" align="center">
<h2 class="title">{{ problemSet.title }}</h2>
<n-button
type="primary"
@click="
router.push({
name: 'admin problemset edit',
params: { problemSetId },
})
"
>
编辑题单
</n-button>
</n-flex>
<ProblemSetInfo :problem-set="problemSet" />
<n-tabs type="line">
<n-tab-pane name="problems" tab="题目管理">
<ProblemManagement
:problems="problems"
@add-problem="openAddProblemModal"
@edit-problem="openEditProblemModal"
@remove-problem="handleRemoveProblem"
/>
</n-tab-pane>
<n-tab-pane name="badges" tab="奖章管理">
<BadgeManagement
:badges="badges"
@add-badge="openAddBadgeModal"
@edit-badge="openEditBadgeModal"
@delete-badge="handleDeleteBadge"
/>
</n-tab-pane>
<n-tab-pane name="progress" tab="进度管理">
<ProgressManagement
:progress="progress"
@remove-user="handleRemoveUser"
/>
</n-tab-pane>
</n-tabs>
<!-- 模态框组件 -->
<AddProblemModal
v-model:show="showAddProblemModal"
@confirm="handleAddProblem"
/>
<EditProblemModal
v-model:show="showEditProblemModal"
:problem="editingProblem"
@confirm="handleEditProblem"
/>
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
<EditBadgeModal
v-model:show="showEditBadgeModal"
:badge="editingBadge"
@confirm="handleEditBadge"
/>
</div>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const isEdit = computed(() => !!problemSetId.value)
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
title: "",
description: "",
difficulty: "Easy",
status: "draft",
visible: false,
})
const difficultyOptions = [
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
const loading = ref(false)
async function loadProblemSetDetail() {
if (!isEdit.value) return
try {
const res = await getProblemSetDetail(problemSetId.value)
const data = res.data
formData.value = {
id: data.id,
title: data.title,
description: data.description,
difficulty: data.difficulty,
status: data.status,
visible: data.visible,
}
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
}
}
async function handleSubmit() {
if (!formData.value.title?.trim()) {
message.error("请输入题单标题")
return
}
if (!formData.value.description?.trim()) {
message.error("请输入题单描述")
return
}
loading.value = true
try {
if (isEdit.value) {
await editProblemSet(formData.value as EditProblemSetData)
message.success("题单更新成功")
} else {
await createProblemSet(formData.value as CreateProblemSetData)
message.success("题单创建成功")
}
router.push({ name: "admin problemset list" })
} catch (err: any) {
message.error(
(isEdit.value ? "更新" : "创建") +
"题单失败:" +
(err.data || "未知错误"),
)
} finally {
loading.value = false
}
}
onMounted(() => {
if (isEdit.value) {
loadProblemSetDetail()
}
})
</script>
<template>
<div>
<h2 class="title">{{ isEdit ? "编辑题单" : "创建题单" }}</h2>
<n-form :model="formData" label-placement="top">
<n-flex>
<n-form-item label="题单标题" required>
<n-input
v-model:value="formData.title"
placeholder="请输入题单标题"
maxlength="200"
show-count
/>
</n-form-item>
<n-form-item label="难度">
<n-select
style="width: 100px"
v-model:value="formData.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
/>
</n-form-item>
<n-form-item label="状态">
<n-select
style="width: 100px"
v-model:value="formData.status"
:options="statusOptions"
placeholder="选择状态"
/>
</n-form-item>
<n-form-item v-if="isEdit" label="是否可见">
<n-switch v-model:value="formData.visible" />
</n-form-item>
</n-flex>
<n-form-item label="题单描述" required>
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="请输入题单描述"
:rows="4"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? "更新" : "创建" }}
</n-button>
</n-form-item>
</n-form>
</div>
</template>
<style scoped>
.title {
margin: 0 0 16px 0;
}
</style>

View File

@@ -0,0 +1,209 @@
<script setup lang="ts">
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
import { ProblemSetList } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api"
import Actions from "./components/Actions.vue"
import { NTag, NSwitch } from "naive-ui"
const total = ref(0)
const problemSets = ref<ProblemSetList[]>([])
interface ProblemSetQuery {
keyword: string
difficulty: string
status: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>({
keyword: "",
difficulty: "",
status: "",
})
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "全部", value: "" },
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
const columns: DataTableColumn<ProblemSetList>[] = [
{ title: "ID", key: "id", width: 80 },
{ title: "标题", key: "title", minWidth: 200 },
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
{
title: "创建者",
key: "created_by",
width: 120,
render: (row) => row.created_by.username,
},
{
title: "难度",
key: "difficulty",
width: 100,
render: (row) => {
const difficultyMap = {
Easy: { type: "success" as const, text: "简单" },
Medium: { type: "warning" as const, text: "中等" },
Hard: { type: "error" as const, text: "困难" },
}
const config = difficultyMap[row.difficulty]
return h(NTag, { type: config.type }, { default: () => config.text })
},
},
{
title: "状态",
key: "status",
width: 100,
render: (row) => {
const statusMap = {
active: { type: "success" as const, text: "活跃" },
archived: { type: "default" as const, text: "已归档" },
draft: { type: "info" as const, text: "草稿" },
}
const config = statusMap[row.status]
return h(NTag, { type: config.type }, { default: () => config.text })
},
},
{
title: "题目数量",
key: "problems_count",
width: 100,
},
{
title: "创建时间",
key: "create_time",
width: 180,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "可见",
key: "visible",
width: 80,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row.id),
}),
},
{
title: "选项",
key: "actions",
width: 300,
render: (row) =>
h(Actions, {
problemSetId: row.id,
onUpdated: listProblemSets,
}),
},
]
async function listProblemSets() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemSetList(
offset,
query.limit,
query.keyword,
query.difficulty,
query.status,
)
total.value = res.data.total
problemSets.value = res.data.results
}
async function toggleVisible(problemSetId: number) {
await toggleProblemSetVisible(problemSetId)
problemSets.value = problemSets.value.map((it) => {
if (it.id === problemSetId) {
it.visible = !it.visible
}
return it
})
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [query.page, query.limit, query.difficulty, query.status],
listProblemSets,
)
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">题单管理</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin problemset create' })"
>
新建题单
</n-button>
</n-flex>
<n-flex align="center">
<n-flex align="center">
<span>难度</span>
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
style="width: 120px"
clearable
/>
</n-flex>
<n-flex align="center">
<span>状态</span>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="选择状态"
style="width: 120px"
clearable
/>
</n-flex>
<n-input
v-model:value="query.keyword"
placeholder="输入标题关键字"
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-flex>
</n-flex>
<n-data-table striped :columns="columns" :data="problemSets" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -2,6 +2,8 @@
import { NButton, NTag } from "naive-ui"
import { parseTime } from "utils/functions"
import { Server } from "utils/types"
import { useConfigStore } from "shared/store/config"
import { useConfigWebSocket } from "shared/composables/websocket"
import {
deleteJudgeServer,
editWebsite,
@@ -10,6 +12,7 @@ import {
listInvalidTestcases,
pruneInvalidTestcases,
} from "../api"
import { useUserStore } from "shared/store/user"
interface Testcase {
id: string
@@ -17,6 +20,21 @@ interface Testcase {
}
const message = useMessage()
const configStore = useConfigStore()
const userStore = useUserStore()
const { updateConfig } = useConfigWebSocket()
// 确保只有登录用户才能使用WebSocket
watch(
() => userStore.isAuthed,
(isAuthed) => {
if (!isAuthed) {
// 如果用户未登录禁用WebSocket功能
console.warn("用户未登录WebSocket配置更新功能已禁用")
}
},
{ immediate: true },
)
const testcaseColumns: DataTableColumn<Testcase>[] = [
{ title: "测试用例 ID", key: "id" },
@@ -106,6 +124,7 @@ const websiteConfig = reactive({
allow_register: true,
submission_list_show_all: true,
class_list: [],
enable_maxkb: true,
})
async function getWebsiteConfig() {
@@ -117,12 +136,21 @@ async function getWebsiteConfig() {
websiteConfig.allow_register = res.data.allow_register
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
websiteConfig.class_list = res.data.class_list
websiteConfig.enable_maxkb = res.data.enable_maxkb
}
async function saveWebsiteConfig() {
await editWebsite(websiteConfig)
message.success("网站配置保存成功")
getWebsiteConfig()
configStore.getConfig()
// 通过 WebSocket 广播配置变化,实现实时切换
updateConfig("enable_maxkb", websiteConfig.enable_maxkb)
updateConfig(
"submission_list_show_all",
websiteConfig.submission_list_show_all,
)
}
async function deleteTestcase(id?: string) {
@@ -198,6 +226,10 @@ onMounted(() => {
<span>显示所有提交</span>
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
</n-flex>
<n-flex align="center">
<span>启用AI小助手</span>
<n-switch v-model:value="websiteConfig.enable_maxkb" />
</n-flex>
</n-flex>
</n-card>
<n-card class="box">

View File

@@ -45,7 +45,6 @@ const adminOptions = [
const sortOptions = [
{ label: "默认排序", value: "" },
{ label: "最近登录", value: "-last_login" },
{ label: "最早登录", value: "last_login" },
]
const [create, toggleCreate] = useToggle(false)
const password = ref("")

1
src/env.d.ts vendored
View File

@@ -8,6 +8,7 @@ interface ImportMetaEnv {
readonly PUBLIC_JUDGE0_URL: string
readonly PUBLIC_ICONIFY_URL: string
readonly PUBLIC_SIGNALING_URL: string
readonly PUBLIC_WS_URL: string
}
interface ImportMeta {

View File

@@ -8,19 +8,29 @@ import storage from "utils/storage"
import App from "./App.vue"
import { admins, ojs } from "./routes"
import { toggleLogin } from "./shared/composables/modal"
import { useUserStore } from "./shared/store/user"
const router = createRouter({
history: createWebHistory(),
routes: [ojs, admins],
})
const pinia = createPinia()
// 创建 app 并安装插件
const app = createApp(App)
app.use(pinia)
app.use(router)
// 现在可以安全地使用 Store
import { useAuthModalStore } from "./shared/store/authModal"
import { useUserStore } from "./shared/store/user"
const authStore = useAuthModalStore()
router.beforeEach(async (to, from, next) => {
// 检查是否需要认证
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!storage.get(STORAGE_KEY.AUTHED)) {
toggleLogin(true)
authStore.openLoginModal()
next("/")
return
}
@@ -34,7 +44,7 @@ router.beforeEach(async (to, from, next) => {
)
) {
if (!storage.get(STORAGE_KEY.AUTHED)) {
toggleLogin(true)
authStore.openLoginModal()
next("/")
return
}
@@ -66,10 +76,6 @@ router.beforeEach(async (to, from, next) => {
next()
})
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
app.mount("#app")
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {

View File

@@ -1,7 +1,7 @@
<template>
<n-spin :show="aiStore.loading.fetching">
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="2">
<n-spin :show="aiStore.loading.fetching" :delay="50">
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1">
<n-flex vertical size="large">
<n-flex align="center" justify="space-between">
<n-h3 style="margin: 0">请选择时间范围智能分析学习情况</n-h3>
@@ -29,20 +29,20 @@
<SolvedTable />
</n-flex>
</n-gi>
<n-gi :span="3">
<n-gi :span="1">
<n-flex vertical size="large">
<Heatmap />
<ProgressChart />
<EfficiencyChart />
<DurationChart />
<AI v-if="aiStore.detailsData.solved.length >= 10" />
<AI v-if="aiStore.detailsData.solved.length > 10" />
</n-flex>
</n-gi>
<n-gi :span="5">
<n-gi :span="2">
<AI
v-if="
aiStore.detailsData.solved.length > 0 &&
aiStore.detailsData.solved.length < 10
aiStore.detailsData.solved.length <= 10
"
/>
</n-gi>
@@ -50,7 +50,7 @@
</n-spin>
</template>
<script setup lang="ts">
import { isDesktop } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
import { formatISO, sub, type Duration } from "date-fns"
import TagsRadarChart from "./components/TagsRadarChart.vue"
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
@@ -68,6 +68,8 @@ import { DURATION_OPTIONS } from "utils/constants"
const aiStore = useAIStore()
const { isDesktop } = useBreakpoints()
const options = [...DURATION_OPTIONS]
const subOptions = computed<Duration>(() => {

View File

@@ -5,7 +5,7 @@
<span class="title-text">AI 帮你分析</span>
</div>
</template>
<n-spin :show="aiStore.loading.ai">
<n-spin :show="aiStore.loading.ai" :delay="50">
<div class="container">
<MdPreview :model-value="aiStore.mdContent" />
</div>

View File

@@ -162,7 +162,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
},
tooltip: {
callbacks: {
label: (ctx: TooltipItem<"bar">) => {
label: (ctx: TooltipItem<"bar" | "line">) => {
const dsLabel = ctx.dataset.label || ""
if ((ctx.dataset as any).yAxisID === "y1") {
const idx = Number(ctx.parsed.y)
@@ -170,7 +170,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
}
return `${dsLabel}: ${ctx.formattedValue}`
},
footer: (items: TooltipItem<"bar">[]) => {
footer: (items: TooltipItem<"bar" | "line">[]) => {
const barItems = items.filter(
(item) => (item.dataset as any).yAxisID === "y",
)

View File

@@ -125,12 +125,12 @@ const data = computed<ChartData<"line">>(() => {
})
// 图表配置
const options = computed<ChartOptions<"line">>(() => {
const options = computed(() => {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
mode: "index" as const,
intersect: false,
},
scales: {
@@ -142,8 +142,8 @@ const options = computed<ChartOptions<"line">>(() => {
},
},
y: {
type: "linear",
position: "left",
type: "linear" as const,
position: "left" as const,
title: {
display: true,
text: "平均提交次数(次/题)",
@@ -159,8 +159,8 @@ const options = computed<ChartOptions<"line">>(() => {
},
},
y1: {
type: "linear",
position: "right",
type: "linear" as const,
position: "right" as const,
min: 0,
max: 100,
title: {

View File

@@ -1,8 +1,31 @@
<template>
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
<div align="center" style="display: inline-flex; margin: 0 10px">
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16" style="cursor: help">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
/>
</svg>
</n-icon>
</template>
<div style="max-width: 300px; line-height: 1.4">
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
<div>使用加权平均方法计算综合等级</div>
<div> S级 = 4A级 = 3B级 = 2C级 = 1</div>
<div> 根据平均分数确定最终等级</div>
<div>- S级3.5</div>
<div>- A级2.5-3.5</div>
<div>- B级1.5-2.5</div>
<div>- C级<1.5分</div>
</div>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
@@ -14,7 +37,6 @@ img {
animation: shake 0.5s infinite;
width: 30px;
height: 30px;
margin: 0 10px -10px;
}
@keyframes shake {

View File

@@ -3,7 +3,7 @@
<template #header-extra>
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
</template>
<n-spin :show="aiStore.loading.heatmap">
<n-spin :show="aiStore.loading.heatmap" :delay="50">
<div class="heatmap-container" ref="containerRef">
<svg
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"

View File

@@ -1,8 +1,28 @@
<template>
<n-tabs animated v-if="submissions.length && flowcharts.length">
<n-tab-pane name="代码提交">
<n-data-table
v-if="submissions.length"
striped
:data="submissions"
:columns="columns"
:max-height="isDesktop ? 1500 : 500"
/>
</n-tab-pane>
<n-tab-pane name="流程图提交">
<n-data-table
v-if="flowcharts.length"
striped
:data="flowcharts"
:columns="flowchartsColumns"
:max-height="isDesktop ? 1500 : 500"
/>
</n-tab-pane>
</n-tabs>
<n-data-table
v-if="solvedProblems.length"
v-if="submissions.length && !flowcharts.length"
striped
:data="solvedProblems"
:data="submissions"
:columns="columns"
:max-height="isDesktop ? 1500 : 500"
/>
@@ -11,15 +31,18 @@
<script lang="ts" setup>
import { NButton } from "naive-ui"
import TagTitle from "./TagTitle.vue"
import { SolvedProblem } from "utils/types"
import { FlowchartSummary, SolvedProblem } from "utils/types"
import { useAIStore } from "oj/store/ai"
import { isDesktop } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
const router = useRouter()
const aiStore = useAIStore()
const solvedProblems = computed(() => aiStore.detailsData.solved)
const { isDesktop } = useBreakpoints()
const submissions = computed(() => aiStore.detailsData.solved)
const flowcharts = computed(() => aiStore.detailsData.flowcharts)
const columns: DataTableColumn<SolvedProblem>[] = [
{
title: "完成的题目",
@@ -65,4 +88,39 @@ const columns: DataTableColumn<SolvedProblem>[] = [
align: "center",
},
]
const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
{
title: "完成的题目",
key: "problem_title",
width: 300,
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
router.push("/problem/" + row.problem__id)
},
},
() => `${row.problem__id} ${row.problem_title}`,
),
},
{ title: "提交次数", key: "submission_count", width: 100, align: "center" },
{
title: "最高分",
key: "best",
width: 100,
align: "center",
render: (row) => `${row.best_score} (${row.best_grade})`,
},
{
title: "最新提交时间",
key: "latest_submission_time",
width: 200,
align: "center",
render: (row) => parseTime(row.latest_submission_time),
},
{ title: "平均分", key: "avg_score", width: 100, align: "center" },
]
</script>

View File

@@ -3,7 +3,7 @@
<template #header-extra>
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
</template>
<n-spin :show="aiStore.loading.heatmap">
<n-spin :show="aiStore.loading.heatmap" :delay="50">
<n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-gi>
<n-statistic label="当前连续" :value="currentStreak">

View File

@@ -2,7 +2,7 @@
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 { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import { Announcement } from "utils/types"
@@ -12,6 +12,9 @@ const total = ref(0)
const content = ref("")
const title = ref("")
const [show, toggleShow] = useToggle(false)
const { isDesktop } = useBreakpoints()
const query = reactive({
limit: 10,
page: 1,

View File

@@ -19,6 +19,7 @@ function filterResult(result: Problem) {
rate: getACRate(result.accepted_number, result.submission_number),
status: "",
author: result.created_by.username,
allow_flowchart: result.allow_flowchart,
}
if (result.my_status === null || result.my_status === undefined) {
newResult.status = "not_test"
@@ -264,3 +265,110 @@ export function getAIDurationData(end: string, duration: string) {
export function getAIHeatmapData() {
return http.get("ai/heatmap")
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
problem_id: number
mermaid_code: string
flowchart_data: any // 这个是压缩之后的,元数据太长了
}) {
return http.post("flowchart/submission", data)
}
export function getFlowchartSubmission(id: string) {
return http.get("flowchart/submission", {
params: { id },
})
}
export function getFlowchartSubmissions(params: {
username?: string
problem_id?: string
myself?: string
offset?: number
limit?: number
}) {
return http.get("flowchart/submissions", { params })
}
export function retryFlowchartSubmission(submissionId: string) {
return http.post("flowchart/submission/retry", {
submission_id: submissionId,
})
}
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return http.get("flowchart/submission/current", {
params: { problem_id: problemId },
})
}
// ==================== 题单相关API ====================
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return http.get("problemset", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
})
}
export function getProblemSetDetail(id: number) {
return http.get(`problemset/${id}`)
}
export function getProblemSetProblems(problemSetId: number) {
return http.get(`problemset/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
return http.post("problemset/progress", {
problemset_id: problemSetId,
})
}
export function updateProblemSetProgress(
problemSetId: number,
problemId: number,
submissionId: string,
) {
return http.put("problemset/progress", {
problemset_id: problemSetId,
problem_id: problemId,
submission_id: submissionId,
})
}
// 获取用户徽章列表
export function getUserBadges(username?: string) {
return http.get("user/badges", { params: username ? { username } : {} })
}
// 获取题单徽章列表
export function getProblemSetBadges(problemSetId: number) {
return http.get(`problemset/${problemSetId}/badges`)
}
// 获取题单用户进度列表
export function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
offset?: number
class_name?: string
completion_status?: "" | "completed" | "in_progress" | "not_started"
},
) {
return http.get(`problemset/${problemSetId}/users_progress`, { params })
}

View File

@@ -1,11 +0,0 @@
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { Code } from "utils/types"
export const code = reactive<Code>({
value: "",
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3",
})
export const input = ref("")
export const output = ref("")

View File

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

View File

@@ -1,19 +1,33 @@
// 同步状态管理 composable
import { ref, provide, inject } from "vue"
/**
* 同步状态管理 composable
* 使用 provide/inject 模式在组件树中共享状态
*/
export interface SyncStatusState {
otherUser?: { name: string; isSuperAdmin: boolean }
hadConnection: boolean
otherUser?: { name: string; isSuperAdmin: boolean }
lastLeftUser?: { name: string; isSuperAdmin: boolean } // 保存离开之人的信息
}
// 提供/注入的 key
export const SYNC_STATUS_KEY = Symbol("syncStatus")
// 创建同步状态
/**
* 创建同步状态实例
* 每次调用创建新的状态实例
*/
export function createSyncStatus() {
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
const hadConnection = ref(false)
const lastLeftUser = ref<{ name: string; isSuperAdmin: boolean }>()
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
// 如果之前有其他用户,现在没有了,说明用户离开了
if (otherUser.value && !user) {
lastLeftUser.value = otherUser.value
}
otherUser.value = user
if (user) {
hadConnection.value = true
@@ -23,24 +37,32 @@ export function createSyncStatus() {
const reset = () => {
otherUser.value = undefined
hadConnection.value = false
lastLeftUser.value = undefined
}
return {
otherUser,
hadConnection,
lastLeftUser,
setOtherUser,
reset,
}
}
// 提供同步状态
/**
* 提供同步状态到子组件
* 在父组件中调用
*/
export function provideSyncStatus() {
const syncStatus = createSyncStatus()
provide(SYNC_STATUS_KEY, syncStatus)
return syncStatus
}
// 注入同步状态
/**
* 注入同步状态
* 在子组件中调用,获取父组件提供的状态
*/
export function injectSyncStatus() {
const syncStatus =
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { useContestStore } from "oj/store/contest"
import { isDesktop } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
import { ContestStatus } from "utils/constants"
const route = useRoute()
const router = useRouter()
const contestStore = useContestStore()
const { isDesktop } = useBreakpoints()
const contestMenuVisible = computed(() => {
if (contestStore.isContestAdmin) return true
if (!contestStore.isPrivate) {

View File

@@ -0,0 +1,231 @@
<template>
<div class="chart" v-if="showChart">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { ContestRank } from "utils/types"
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
)
interface Props {
ranks: ContestRank[]
problems: Array<{ id: number; title: string }>
}
const props = defineProps<Props>()
const showChart = computed(() => {
const hasRanks = props.ranks.length > 0
const hasProblems = props.problems.length >= 3
return hasProblems && hasRanks
})
// 预定义的颜色方案 - 更现代和可访问的颜色
const colorPalette = [
"#3B82F6", // 蓝色
"#EF4444", // 红色
"#10B981", // 绿色
"#F59E0B", // 黄色
"#8B5CF6", // 紫色
"#EC4899", // 粉色
"#06B6D4", // 青色
"#84CC16", // 青绿色
"#F97316", // 橙色
"#6366F1", // 靛蓝色
]
// 数据处理函数
const processChartData = () => {
if (!props.ranks || props.ranks.length === 0) {
return {
labels: [],
datasets: [],
}
}
// 获取前10名用户的数据
const topUsers = props.ranks.slice(0, 10)
// 获取所有题目ID从所有用户的submission_info中收集
const allProblemIds = new Set<string>()
topUsers.forEach((rank) => {
Object.keys(rank.submission_info).forEach((problemId) => {
allProblemIds.add(problemId)
})
})
// 按题目ID排序
const problemIds = Array.from(allProblemIds).sort()
// 创建题目标签
const labels = problemIds.map((id) => {
if (props.problems) {
const problem = props.problems.find((p) => p.id.toString() === id)
return problem ? problem.title : `题目${id}`
}
return `题目${id}`
})
// 找到所有用户中最早的提交时间
let earliestTime = Infinity
topUsers.forEach((rank) => {
Object.values(rank.submission_info).forEach((submissionInfo) => {
if (submissionInfo.is_ac && submissionInfo.ac_time < earliestTime) {
earliestTime = submissionInfo.ac_time
}
})
})
// 如果没有找到任何通过记录使用0作为基准
if (earliestTime === Infinity) {
earliestTime = 0
}
// 为每个用户创建数据集
const datasets = topUsers.map((rank, userIndex) => {
const userData = problemIds.map((problemId) => {
const submissionInfo = rank.submission_info[problemId]
if (!submissionInfo || !submissionInfo.is_ac) {
return null
}
return submissionInfo.ac_time - earliestTime
})
const actualRank = userIndex + 1
const colorIndex = userIndex % colorPalette.length
const color = colorPalette[colorIndex]
return {
label: `${actualRank}名: ${rank.user.username}`,
data: userData,
borderColor: color,
backgroundColor: color + "20",
tension: 0.3,
fill: false,
pointRadius: 6,
pointHoverRadius: 8,
pointBackgroundColor: color,
pointBorderColor: "#fff",
pointBorderWidth: 2,
spanGaps: false,
}
})
return {
labels,
datasets,
}
}
// 监听数据变化,重新处理
watch(
() => [props.ranks, props.problems],
() => {
if (props.ranks && props.ranks.length > 0) {
// 数据变化时重新处理
}
},
{ deep: true, immediate: true },
)
const chartData = computed(() => {
return processChartData()
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "top" as const,
maxHeight: 80,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
usePointStyle: true,
font: {
size: 11,
},
},
},
tooltip: {
mode: "index" as const,
intersect: false,
callbacks: {
title: function (context: any) {
return `题目: ${context[0].label}`
},
label: function (context: any) {
const value = context.parsed.y
const label = context.dataset.label
if (value === null) {
return `${label}: 未通过`
}
const hours = Math.floor(value / 3600)
const minutes = Math.floor((value % 3600) / 60)
const seconds = Math.floor(value % 60)
let timeStr = ""
if (hours > 0) timeStr += `${hours}小时`
if (minutes > 0) timeStr += `${minutes}分钟`
if (seconds > 0 || timeStr === "") timeStr += `${seconds}`
return `${label}: +${timeStr}`
},
},
},
},
scales: {
y: {
title: {
display: true,
text: "相对通过时间",
},
min: 0,
ticks: {
callback: function (value: any) {
const hours = Math.floor(value / 3600)
const minutes = Math.floor((value % 3600) / 60)
const seconds = Math.floor(value % 60)
if (hours > 0) return `+${hours}h${minutes}m`
if (minutes > 0) return `+${minutes}m${seconds}s`
return `+${seconds}s`
},
},
},
},
}))
</script>
<style scoped>
.chart {
height: 500px;
width: 100%;
margin-bottom: 24px;
}
</style>

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 { useBreakpoints } from "shared/composables/breakpoints"
import { useContestStore } from "../store/contest"
import ContestInfo from "./components/ContestInfo.vue"
import ContestMenu from "./components/ContestMenu.vue"
@@ -12,6 +12,8 @@ const props = defineProps<{
const contestStore = useContestStore()
const message = useMessage()
const { isDesktop } = useBreakpoints()
const password = ref("")
async function check() {

View File

@@ -6,7 +6,7 @@ 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 { useAuthModalStore } from "shared/store/authModal"
import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user"
import { CONTEST_STATUS, ContestType } from "utils/constants"
@@ -14,6 +14,7 @@ import { renderTableTitle } from "utils/renders"
const router = useRouter()
const userStore = useUserStore()
const authStore = useAuthModalStore()
interface ContestQuery {
keyword: string
@@ -120,7 +121,7 @@ function rowProps(row: Contest) {
style: "cursor: pointer",
onClick() {
if (!userStore.isAuthed && row.contest_type === ContestType.private) {
toggleLogin(true)
authStore.openLoginModal()
} else {
router.push("/contest/" + row.id)
}

View File

@@ -9,6 +9,7 @@ import { ContestStatus } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import { ContestRank, ProblemFiltered } from "utils/types"
import AcAndSubmission from "../components/AcAndSubmission.vue"
import LineChart from "../components/LineChart.vue"
interface Props {
contestID: string
@@ -26,7 +27,7 @@ const total = ref(0)
const data = ref<ContestRank[]>([])
const chart = ref<ContestRank[]>([])
const problems = ref<ProblemFiltered[]>([])
const [autoRefresh] = useToggle()
const [autoRefresh] = useToggle(true)
const { resume, pause } = useIntervalFn(
() => {
query.page = 1
@@ -82,13 +83,13 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
align: "center",
render: (row) => h(AcAndSubmission, { rank: row }),
},
// {
// title: "总时间",
// key: "total_time",
// width: 120,
// align: "center",
// render: (row) => secondsToDuration(row.total_time),
// },
{
title: "总时间",
key: "total_time",
width: 120,
align: "center",
render: (row) => secondsToDuration(row.total_time),
},
])
async function listRanks() {
@@ -207,6 +208,10 @@ onMounted(() => {
</script>
<template>
<!-- 排名变化图表 -->
<LineChart :ranks="chart" :problems="problems" v-if="chart.length > 0" />
<!-- 排名表格 -->
<n-data-table
striped
:single-line="false"

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import FlowchartEditor from "shared/components/FlowchartEditor/index.vue"
</script>
<template>
<FlowchartEditor />
</template>
<style scoped></style>

View File

@@ -105,16 +105,18 @@ import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { Tutorial } from "utils/types"
import { getTutorial, getTutorials } from "../api"
import { isDesktop } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
const isDark = useDark()
const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"),
)
const isDark = useDark()
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const step = computed(() => {
if (!route.params.step || !route.params.step.length) return 1
else {

View File

@@ -1,19 +1,31 @@
<script lang="ts" setup>
import { code } from "oj/composables/code"
import { problem } from "oj/composables/problem"
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { SOURCES } from "utils/constants"
import CodeEditor from "shared/components/CodeEditor.vue"
import { isDesktop } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
import { provideSyncStatus } from "oj/composables/syncStatus"
import storage from "utils/storage"
import { LANGUAGE } from "utils/types"
import Form from "./Form.vue"
const route = useRoute()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
// 提供空的同步状态,避免 Form 组件注入错误
// 在竞赛模式下,同步功能会被 showSyncFeature 自动禁用
provideSyncStatus()
const contestID = route.params.contestID || null
const storageKey = computed(
() =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
const editorHeight = computed(() =>
@@ -22,10 +34,11 @@ const editorHeight = computed(() =>
onMounted(() => {
const savedCode = storage.get(storageKey.value)
code.value =
codeStore.setCode(
savedCode ||
problem.value!.template[code.language] ||
SOURCES[code.language]
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
})
const changeCode = (v: string) => {
@@ -34,10 +47,12 @@ const changeCode = (v: string) => {
const changeLanguage = (v: LANGUAGE) => {
const savedCode = storage.get(storageKey.value)
code.value =
codeStore.setCode(
savedCode && storageKey.value.split("_").pop() === v
? savedCode
: problem.value!.template[code.language] || SOURCES[code.language]
: problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
</script>
@@ -45,8 +60,8 @@ const changeLanguage = (v: LANGUAGE) => {
<n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" />
<CodeEditor
v-model:value="code.value"
:language="code.language"
v-model:value="codeStore.code.value"
:language="codeStore.code.language"
:height="editorHeight"
@update:model-value="changeCode"
/>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { code, input, output } from "oj/composables/code"
import { problem } from "oj/composables/problem"
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { SOURCES } from "utils/constants"
import CodeEditor from "shared/components/CodeEditor.vue"
import storage from "utils/storage"
@@ -13,17 +14,24 @@ const message = useMessage()
const route = useRoute()
const contestID = !!route.params.contestID ? route.params.contestID : null
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { input, output } = storeToRefs(codeStore)
const { problem } = storeToRefs(problemStore)
const storageKey = computed(
() =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
onMounted(() => {
if (storage.get(storageKey.value)) {
code.value = storage.get(storageKey.value)
codeStore.setCode(storage.get(storageKey.value))
} else {
code.value =
problem.value!.template[code.language] || SOURCES[code.language]
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
})
@@ -36,26 +44,31 @@ function changeLanguage(v: string) {
storage.get(storageKey.value) &&
storageKey.value.split("_").pop() === v
) {
code.value = storage.get(storageKey.value)
codeStore.setCode(storage.get(storageKey.value))
} else {
code.value =
problem.value!.template[code.language] || SOURCES[code.language]
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
}
const copy = async () => {
const success = await copyToClipboard(code.value)
const success = await copyToClipboard(codeStore.code.value)
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
}
const reset = () => {
code.value = problem.value!.template[code.language] || SOURCES[code.language]
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
storage.remove(storageKey.value)
message.success("代码重置成功")
}
const runCode = async () => {
const res = await createTestSubmission(code, input.value)
const res = await createTestSubmission(codeStore.code, input.value)
output.value = res.output
}
@@ -81,7 +94,7 @@ const languageOptions: DropdownOption[] = problem.value!.languages.map(
<n-flex vertical>
<n-flex align="center">
<n-select
v-model:value="code.language"
v-model:value="codeStore.code.language"
style="width: 120px"
:options="languageOptions"
@update:value="changeLanguage"
@@ -93,9 +106,9 @@ const languageOptions: DropdownOption[] = problem.value!.languages.map(
</n-button>
</n-flex>
<CodeEditor
v-model:value="code.value"
v-model:value="codeStore.code.value"
@update:model-value="changeCode"
:language="code.language"
:language="codeStore.code.language"
/>
</n-flex>
</template>

View File

@@ -1,17 +1,30 @@
<script setup lang="ts">
import { copyToClipboard } from "utils/functions"
import { code } from "oj/composables/code"
import { problem } from "oj/composables/problem"
import { storeToRefs } from "pinia"
import { copyToClipboard, utoa } from "utils/functions"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/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 {
ICON_SET,
LANGUAGE_FORMAT_VALUE,
LANGUAGE_SHOW_VALUE,
SOURCES,
STORAGE_KEY,
} from "utils/constants"
import { useBreakpoints } 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"
import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui"
import SubmitCode from "./SubmitCode.vue"
const SubmitFlowchart = defineAsyncComponent(
() => import("./SubmitFlowchart.vue"),
)
interface Props {
storageKey: string
@@ -34,6 +47,11 @@ const message = useMessage()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem, languages } = storeToRefs(problemStore)
const { isMobile, isDesktop } = useBreakpoints()
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
const statisticPanel = ref(false)
@@ -42,7 +60,11 @@ const statisticPanel = ref(false)
const isContestMode = computed(() => route.name === "contest problem")
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
const showSyncFeature = computed(
() => isDesktop.value && userStore.isAuthed && !isContestMode.value,
() =>
isDesktop.value &&
userStore.isAuthed &&
codeStore.code.language !== "Flowchart" &&
!isContestMode.value,
)
const menu = computed<DropdownOption[]>(() => [
@@ -51,27 +73,35 @@ const menu = computed<DropdownOption[]>(() => [
{ 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 showGoSubmissionButton = computed(() => {
if (isContestMode.value) return true
else if (userStore.isAdminRole) return true
else if (userStore.showSubmissions) return true
else return false
})
const languageOptions: DropdownOption[] = languages.value.map((it) => ({
label: () =>
h(NFlex, { align: "center" }, () => [
h(Icon, {
icon: ICON_SET[it],
width: 16,
}),
LANGUAGE_SHOW_VALUE[it],
]),
value: it,
}))
const copy = async () => {
const success = await copyToClipboard(code.value)
const success = await copyToClipboard(codeStore.code.value)
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
}
const reset = () => {
code.value = problem.value!.template[code.language] || SOURCES[code.language]
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
storage.remove(props.storageKey)
message.success("代码重置成功")
}
@@ -82,7 +112,15 @@ const changeLanguage = (v: LANGUAGE) => {
}
const goTestCat = () => {
window.open(import.meta.env.PUBLIC_CODE_URL, "_blank")
const lang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
const data = {
lang,
code: codeStore.code.value,
input: problemStore.problem?.samples[0].input,
}
const base64 = utoa(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
const goSubmissions = () => {
@@ -116,22 +154,30 @@ defineExpose({
syncEnabled.value = false
},
})
onMounted(() => {
if (!languages.value.includes(codeStore.code.language)) {
codeStore.code.language = "Python3"
}
})
</script>
<template>
<n-flex align="center">
<n-select
v-model:value="code.language"
v-model:value="codeStore.code.language"
style="width: 120px"
:size="buttonSize"
:options="languageOptions"
@update:value="changeLanguage"
/>
<Submit />
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
<SubmitCode v-else />
<n-button
v-if="!userStore.isSuperAdmin && userStore.showSubmissions"
v-if="showGoSubmissionButton"
:size="buttonSize"
@click="goSubmissions"
>
@@ -146,9 +192,19 @@ defineExpose({
{{ isDesktop ? "统计信息" : "统计" }}
</n-button>
<n-button v-if="isDesktop" @click="goTestCat">自测猫</n-button>
<n-button
v-if="isDesktop && codeStore.code.language !== 'Flowchart'"
@click="goTestCat"
>
自测猫
</n-button>
<n-dropdown size="large" :options="menu" @select="handleMenuSelect">
<n-dropdown
v-if="codeStore.code.language !== 'Flowchart'"
size="large"
:options="menu"
@select="handleMenuSelect"
>
<n-button :size="buttonSize">操作</n-button>
</n-dropdown>
@@ -184,7 +240,7 @@ defineExpose({
"
type="warning"
>
{{ SYNC_MESSAGES.STUDENT_LEFT }}
{{ SYNC_MESSAGES.STUDENT_LEFT(syncStatus.lastLeftUser.value?.name) }}
</n-tag>
</template>
</template>

View File

@@ -107,7 +107,8 @@
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { problem } from "oj/composables/problem"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY } from "utils/constants"
import { createComment, getComment, getCommentStatistics } from "oj/api"
import { useUserStore } from "shared/store/user"
@@ -121,6 +122,8 @@ const props = withDefaults(defineProps<Props>(), {
})
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { code } from "oj/composables/code"
import { problem } from "oj/composables/problem"
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge"
import { Problem, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue"
@@ -17,6 +18,13 @@ type Sample = Problem["samples"][number] & {
const theme = useThemeVars()
const style = computed(() => "color: " + theme.value.primaryColor)
const route = useRoute()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId)
// 判断用户是否尝试过但未通过
// my_status === 0: 已通过
// my_status !== 0 && my_status !== null: 尝试过但未通过
@@ -46,7 +54,7 @@ async function test(sample: Sample, index: number) {
}
return sample
})
const res = await createTestSubmission(code, sample.input)
const res = await createTestSubmission(codeStore.code, sample.input)
samples.value = samples.value.map((sample) => {
if (sample.id === index) {
const status =
@@ -101,24 +109,27 @@ function type(status: ProblemStatus) {
<template>
<div v-if="problem" class="problemContent">
<!-- 已通过 -->
<n-alert
class="status-alert"
v-if="problem.my_status === 0"
type="success"
title="🎉 本 题 已 经 被 你 解 决 啦"
>
</n-alert>
<template v-if="!problemSetId">
<!-- 已通过 -->
<n-alert
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>
</template>
<!-- 尝试过但未通过 -->
<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>
@@ -326,7 +337,7 @@ function type(status: ProblemStatus) {
.content :deep(img) {
max-width: 100%;
height: auto;
height: auto !important;
}
.content :deep(a) {

View File

@@ -1,16 +1,28 @@
<script lang="ts" setup>
import { code } from "oj/composables/code"
import { problem } from "oj/composables/problem"
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/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 { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage"
import { LANGUAGE } from "utils/types"
import Form from "./Form.vue"
const FlowchartEditor = defineAsyncComponent(
() => import("shared/components/FlowchartEditor/index.vue"),
)
const route = useRoute()
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const sync = ref(false)
// 提供同步状态给子组件使用
@@ -19,7 +31,7 @@ const syncStatus = provideSyncStatus()
const contestID = route.params.contestID || null
const storageKey = computed(
() =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
const editorHeight = computed(() =>
@@ -28,10 +40,11 @@ const editorHeight = computed(() =>
onMounted(() => {
const savedCode = storage.get(storageKey.value)
code.value =
codeStore.setCode(
savedCode ||
problem.value!.template[code.language] ||
SOURCES[code.language]
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
})
const changeCode = (v: string) => {
@@ -40,10 +53,12 @@ const changeCode = (v: string) => {
const changeLanguage = (v: LANGUAGE) => {
const savedCode = storage.get(storageKey.value)
code.value =
codeStore.setCode(
savedCode && storageKey.value.split("_").pop() === v
? savedCode
: problem.value!.template[code.language] || SOURCES[code.language]
: problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
const toggleSync = (value: boolean) => {
@@ -64,6 +79,9 @@ const handleSyncStatusChange = (status: {
}) => {
syncStatus.setOtherUser(status.otherUser)
}
// 提供FlowchartEditor的ref给子组件
provide("flowchartEditorRef", flowchartEditorRef)
</script>
<template>
@@ -75,11 +93,16 @@ const handleSyncStatusChange = (status: {
@change-language="changeLanguage"
@toggle-sync="toggleSync"
/>
<FlowchartEditor
v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef"
/>
<SyncCodeEditor
v-model:value="code.value"
v-else
v-model:value="codeStore.code.value"
:sync="sync"
:problem="problem!._id"
:language="code.language"
:language="codeStore.code.language"
:height="editorHeight"
@update:model-value="changeCode"
@sync-closed="handleSyncClosed"

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useProblemStore } from "oj/store/problem"
import { useMermaid } from "shared/composables/useMermaid"
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
// 使用 mermaid composable
const { renderError, renderFlowchart } = useMermaid()
// 渲染流程图的函数
const renderProblemFlowchart = async () => {
if (problem.value?.mermaid_code) {
await renderFlowchart(mermaidContainer.value, problem.value.mermaid_code)
}
}
// 初始化Mermaid并渲染
onMounted(() => {
renderProblemFlowchart()
})
</script>
<template>
<div>
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
<template #default>
{{ renderError }}
</template>
</n-alert>
<div v-else ref="mermaidContainer" class="container"></div>
</div>
</template>
<style scoped>
.container {
width: 100%;
max-width: 100%;
min-height: 300px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { problem } from "oj/composables/problem"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
import { Pie } from "vue-chartjs"
@@ -13,11 +14,16 @@ import {
Colors,
} from "chart.js"
import { getProblemBeatRate } from "oj/api"
import { isDesktop } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
// 仅注册饼图所需的 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const beatRate = ref("0")
const data = computed(() => {
@@ -119,18 +125,16 @@ onMounted(getBeatRate)
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="10" :y-gap="10" class="cards">
<n-gi v-for="item in numbers" :key="item.content">
<n-card hoverable>
<n-flex align="center">
<n-flex vertical align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
<div>
<n-h2 class="number">
<n-number-animation
:to="item.title"
:precision="item.int ? 0 : 2"
/>
<span v-if="item.suffix">{{ item.suffix }}</span>
</n-h2>
<n-h4 class="number-label">{{ item.content }}</n-h4>
</div>
<n-h2 class="number">
<n-number-animation
:to="item.title"
:precision="item.int ? 0 : 2"
/>
<span v-if="item.suffix">{{ item.suffix }}</span>
</n-h2>
<n-h4 class="number-label">{{ item.content }}</n-h4>
</n-flex>
</n-card>
</n-gi>
@@ -145,7 +149,7 @@ onMounted(getBeatRate)
}
.number {
margin-bottom: 0;
margin: 0;
font-weight: bold;
}

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { ProblemFiltered } from "utils/types"
import { Icon } from "@iconify/vue"
defineProps<{
problem: ProblemFiltered
}>()
</script>
<template>
<n-flex align="center">
<span>{{ problem.title }}</span>
<Icon
v-if="problem.allow_flowchart"
icon="streamline-freehand-color:programming-flowchart"
/>
</n-flex>
</template>

View File

@@ -8,10 +8,25 @@ import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import { Submission } from "utils/types"
import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
// 弹框状态管理
const [codePanelVisible, toggleCodePanel] = useToggle(false)
const submissionID = ref("")
const problemID = ref("")
// 显示代码弹框
function showCodePanel(id: string, problem: string) {
submissionID.value = id
problemID.value = problem
toggleCodePanel(true)
}
const columns: DataTableColumn<Submission>[] = [
{
@@ -25,22 +40,17 @@ const columns: DataTableColumn<Submission>[] = [
key: "id",
minWidth: 160,
render: (row) => {
if (row.show_link) {
return h(
NButton,
{
text: true,
type: "info",
onClick: () => {
const data = router.resolve("/submission/" + row.id)
window.open(data.href, "_blank")
},
return h(
NButton,
{
text: true,
type: "info",
onClick: () => {
showCodePanel(row.id, <string>route.params.problemID ?? "")
},
() => row.id.slice(0, 12),
)
} else {
return row.id.slice(0, 12)
}
},
() => row.id.slice(0, 12),
)
},
},
{
@@ -258,6 +268,21 @@ watch(query, listSubmissions)
v-model:page="query.page"
/>
</template>
<!-- 代码详情弹框 -->
<n-modal
v-model:show="codePanelVisible"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
title="代码详情"
>
<SubmissionDetail
:problemID="problemID"
:submissionID="submissionID"
hideList
/>
</n-modal>
</template>
<style scoped>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions"
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
const props = defineProps<{
submission?: Submission
}>()
// 错误信息格式化
const msg = computed(() => {
if (!props.submission) return ""
let msg = ""
const result = props.submission.result
// 编译错误或运行时错误时给出提示
if (
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error
) {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
}
if (props.submission.statistic_info?.err_info) {
msg += props.submission.statistic_info.err_info
}
return msg
})
// 测试用例表格数据(只在部分通过时显示)
const infoTable = computed(() => {
if (!props.submission?.info?.data?.length) return []
const result = props.submission.result
// AC、编译错误、运行时错误不显示测试用例表格
if (
result === SubmissionStatus.accepted ||
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error
) {
return []
}
const data = props.submission.info.data
// 只有存在失败的测试用例时才显示
return data.some((item) => item.result === 0) ? data : []
})
// 测试用例表格列配置
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
{ title: "测试用例", key: "test_case" },
{
title: "测试状态",
key: "result",
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: "占用内存",
key: "memory",
render: (row) => submissionMemoryFormat(row.memory),
},
{
title: "执行耗时",
key: "real_time",
render: (row) => submissionTimeFormat(row.real_time),
},
{ title: "信号", key: "signal" },
]
</script>
<template>
<div v-if="submission">
<n-alert
:type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['name']"
class="mb-3"
/>
<n-flex vertical v-if="msg || infoTable.length">
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table
v-if="infoTable.length"
striped
:data="infoTable"
:columns="columns"
/>
</n-flex>
</div>
</template>
<style scoped>
.msg {
white-space: pre;
word-break: break-all;
line-height: 1.5;
}
</style>

View File

@@ -1,351 +0,0 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import confetti from "canvas-confetti"
import { getComment, getSubmission, submitCode } from "oj/api"
import { code } from "oj/composables/code"
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 {
useSubmissionWebSocket,
type SubmissionUpdate,
} from "shared/composables/websocket"
import { useUserStore } from "shared/store/user"
const ProblemComment = defineAsyncComponent(
() => import("./ProblemComment.vue"),
)
const userStore = useUserStore()
const route = useRoute()
const contestID = <string>route.params.contestID ?? ""
const submissionId = ref("")
const submission = ref<Submission>()
const [submitted] = useToggle()
const [commentPanel] = useToggle()
const { start: submitPending, isPending } = useTimeout(5000, {
controls: true,
immediate: false,
})
const { start: showCommentPanel } = useTimeoutFn(
async () => {
const res = await getComment(problem.value!.id)
if (res.data) return
commentPanel.value = true
},
2000,
{ immediate: false },
)
const { start: fetchSubmission, stop: stopFetchSubmission } = useTimeoutFn(
async () => {
const res = await getSubmission(submissionId.value)
submission.value = res.data
const result = submission.value.result
if (
result === SubmissionStatus.judging ||
result === SubmissionStatus.pending
) {
fetchSubmission()
} else {
submitted.value = false
}
},
2000,
{ 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?.result === SubmissionStatus.judging,
)
const pending = computed(
() => submission.value?.result === SubmissionStatus.pending,
)
const submitting = computed(
() => submission.value?.result === SubmissionStatus.submitting,
)
const submitDisabled = computed(() => {
return (
!userStore.isAuthed ||
code.value.trim() === "" ||
judging.value ||
pending.value ||
submitting.value ||
submitted.value ||
isPending.value
)
})
const submitLabel = computed(() => {
if (!userStore.isAuthed) {
return "请先登录"
}
if (submitting.value) {
return "正在提交"
}
if (judging.value || pending.value) {
return "正在评分"
}
if (isPending.value) {
return "运行结果"
}
return "提交代码"
})
const msg = computed(() => {
if (!submission.value) return ""
let msg = ""
const result = submission.value.result
if (
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error
) {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
}
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 (
result === SubmissionStatus.accepted ||
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error
) {
return []
}
const data = submission.value.info.data
return data.some((item) => item.result === 0) ? data : []
})
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
{ title: "测试用例", key: "test_case" },
{
title: "测试状态",
key: "result",
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: "占用内存",
key: "memory",
render: (row) => submissionMemoryFormat(row.memory),
},
{
title: "执行耗时",
key: "real_time",
render: (row) => submissionTimeFormat(row.real_time),
},
{ title: "信号", key: "signal" },
]
async function submit() {
if (!userStore.isAuthed) return
const data: SubmitCodePayload = {
problem_id: problem.value!.id,
language: code.language,
code: code.value,
}
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
// 取消之前安排的断开倒计时(如果有新提交)
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,
(result) => {
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>
<template>
<n-popover
trigger="click"
placement="bottom-end"
scrollable
:show-arrow="false"
style="max-height: 600px"
>
<template #trigger>
<n-button
:size="isDesktop ? 'medium' : 'small'"
type="primary"
:disabled="submitDisabled"
@click="submit"
>
<template #icon>
<n-icon>
<Icon
v-if="judging || pending || submitting"
icon="eos-icons:loading"
></Icon>
<Icon v-else-if="isPending" icon="ph:lightbulb-fill"></Icon>
<Icon v-else icon="ph:play-fill"></Icon>
</n-icon>
</template>
{{ submitLabel }}
</n-button>
</template>
<template #header>
<n-alert
v-if="submission"
:type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['name']"
/>
</template>
<n-flex vertical v-if="msg || infoTable.length">
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table
v-if="infoTable.length"
striped
:data="infoTable"
:columns="columns"
/>
</n-flex>
</n-popover>
<n-modal
preset="card"
title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)"
:mask-closable="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel"
>
<ProblemComment :showStatistics="false" />
</n-modal>
</template>
<style scoped>
.msg {
white-space: pre;
word-break: break-all;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import { getComment, submitCode, updateProblemSetProgress } from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useFireworks } from "oj/problem/composables/useFireworks"
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
import { SubmissionStatus } from "utils/constants"
import type { SubmitCodePayload } from "utils/types"
import SubmissionResult from "./SubmissionResult.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
// ==================== 异步组件 ====================
const ProblemComment = defineAsyncComponent(
() => import("./ProblemComment.vue"),
)
// ==================== 基础状态 ====================
const userStore = useUserStore()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const route = useRoute()
const contestID = <string>route.params.contestID ?? ""
const problemSetId = <string>route.params.problemSetId ?? ""
const router = useRouter()
const [commentPanel] = useToggle()
const { isDesktop } = useBreakpoints()
// ==================== 烟花效果 ====================
const { celebrate } = useFireworks()
// ==================== 判题监控 ====================
const {
submission,
judging,
pending,
submitting,
isProcessing,
startMonitoring,
} = useSubmissionMonitor()
// ==================== 提交冷却 ====================
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
controls: true,
immediate: false,
})
// ==================== AC后显示评论框 ====================
const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => {
const res = await getComment(problem.value!.id)
if (!res.data) {
commentPanel.value = true
}
},
1500,
{ immediate: false },
)
const { start: goToProblemSetDelayed } = useTimeoutFn(
() => {
router.push({
name: "problemset",
params: {
problemSetId: problemSetId,
},
})
},
1500,
{ immediate: false },
)
// ==================== 计算属性 ====================
// 按钮禁用逻辑
const submitDisabled = computed(() => {
return (
!userStore.isAuthed ||
codeStore.code.value.trim() === "" ||
isProcessing.value ||
isCooldown.value
)
})
// 按钮文案
const submitLabel = computed(() => {
if (!userStore.isAuthed) return "请先登录"
if (submitting.value) return "正在提交"
if (judging.value || pending.value) return "正在评分"
if (isCooldown.value) return "正在冷却"
return "提交代码"
})
// 按钮图标
const submitIcon = computed(() => {
if (isProcessing.value) return "eos-icons:loading"
if (isCooldown.value) return "ph:lightbulb-fill"
return "ph:play-fill"
})
// ==================== 提交函数 ====================
async function submit() {
if (!userStore.isAuthed) return
// 1. 构建提交数据
const data: SubmitCodePayload = {
problem_id: problem.value!.id,
language: codeStore.code.language,
code: codeStore.code.value,
}
if (contestID) {
data.contest_id = parseInt(contestID)
}
// 2. 提交代码到后端
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
// 3. 启动冷却 + 监控
startCooldown()
startMonitoring(res.data.submission_id)
}
// ==================== AC庆祝效果 ====================
watch(
() => submission.value?.result,
async (result) => {
if (result !== SubmissionStatus.accepted) return
// 1. 刷新题目状态
problem.value!.my_status = 0
// 2. 创建ProblemSetSubmission记录更新题单进度
if (problemSetId) {
await updateProblemSetProgress(
Number(problemSetId),
problem.value!.id,
submission.value!.id,
)
}
// 3. 放烟花
celebrate()
// 4. 显示评价框
if (!contestID && !problemSetId) {
showCommentPanelDelayed()
}
if (problemSetId) {
// 延迟回到题单页面
goToProblemSetDelayed()
}
},
)
</script>
<template>
<!-- 提交按钮 + 结果弹窗 -->
<n-popover
trigger="click"
placement="bottom-end"
scrollable
:show-arrow="false"
style="max-height: 600px"
>
<template #trigger>
<n-button
:size="isDesktop ? 'medium' : 'small'"
type="primary"
:disabled="submitDisabled"
@click="submit"
>
<template #icon>
<n-icon>
<Icon :icon="submitIcon" />
</n-icon>
</template>
{{ submitLabel }}
</n-button>
</template>
<!-- 结果展示 -->
<SubmissionResult :submission="submission" />
</n-popover>
<!-- 评价弹窗 -->
<n-modal
preset="card"
title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)"
:mask-closable="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel"
>
<ProblemComment :showStatistics="false" />
</n-modal>
</template>

View File

@@ -0,0 +1,360 @@
<script lang="ts" setup>
import { toRefs } from "vue"
// 工具函数
import { atou, utoa } from "utils/functions"
// 组合式函数
import { useBreakpoints } from "shared/composables/breakpoints"
import { useMermaid } from "shared/composables/useMermaid"
import { useMermaidConverter } from "../composables/useMermaidConverter"
import {
useFlowchartWebSocket,
type FlowchartEvaluationUpdate,
} from "shared/composables/websocket"
// API 和状态管理
import {
getCurrentProblemFlowchartSubmission,
submitFlowchart,
updateProblemSetProgress,
} from "oj/api"
import { useProblemStore } from "oj/store/problem"
// ==================== 类型定义 ====================
interface Rating {
score: number
grade: string
}
interface Evaluation extends Rating {
feedback: string
suggestions: string
criteria_details: {
[key: string]: { score: number; max: number; comment: string }
}
}
// ==================== 组合式函数和响应式变量 ====================
// 通过inject获取FlowchartEditor组件的引用
const flowchartEditorRef = inject<any>("flowchartEditorRef")
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
// 基础组合式函数
const message = useMessage()
const problemStore = useProblemStore()
const { problem } = toRefs(problemStore)
const { isDesktop } = useBreakpoints()
const { convertToMermaid } = useMermaidConverter()
const { renderError, renderFlowchart } = useMermaid()
const route = useRoute()
// 状态管理
const rendering = ref(false)
const loading = ref(false)
const rating = ref<Rating>({ score: 0, grade: "" })
const submissionCount = ref(0)
const myFlowchartZippedStr = ref("")
const myMermaidCode = ref("")
const showDetailModal = ref(false)
const evaluation = ref<Evaluation>({
score: 0,
grade: "",
feedback: "",
suggestions: "",
criteria_details: {},
})
// ==================== WebSocket 相关函数 ====================
// 处理 WebSocket 消息
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
console.log("收到流程图评分更新:", data)
if (data.type === "flowchart_evaluation_completed") {
loading.value = false
rating.value = {
score: data.score || 0,
grade: data.grade || "",
}
message.success(`流程图评分完成!得分: ${data.score}分 (${data.grade}级)`)
} else if (data.type === "flowchart_evaluation_failed") {
console.log("处理评分失败消息")
loading.value = false
message.error(`流程图评分失败: ${data.error}`)
} else {
console.log("未知的消息类型:", data.type)
}
}
// 创建 WebSocket 连接
const { connect, disconnect, subscribe } = useFlowchartWebSocket(
handleWebSocketMessage,
)
// 订阅提交更新
function subscribeToSubmission(submissionId: string) {
console.log("开始订阅WebSocket更新")
subscribe(submissionId)
}
// ==================== 提交相关函数 ====================
// 提交流程图
async function submitFlowchartData(flowchartEditorRef: any) {
if (!flowchartEditorRef?.value) return
// 获取流程图的JSON数据
const flowchartData = flowchartEditorRef.value.getFlowchartData()
if (flowchartData.nodes.length === 0 || flowchartData.edges.length === 0) {
message.error("流程图节点或边不能为空")
return
}
const mermaidCode = convertToMermaid(flowchartData)
const compressed = utoa(JSON.stringify(flowchartData))
loading.value = true
rating.value = { score: 0, grade: "" }
try {
const response = await submitFlowchart({
problem_id: problem.value!.id,
mermaid_code: mermaidCode,
flowchart_data: {
compressed: true,
data: compressed,
},
})
// 获取提交ID并订阅更新
const submissionId = response.data.submission_id
if (submissionId) {
subscribeToSubmission(submissionId)
}
message.success("流程图已提交,请耐心等待评分")
} catch (error) {
loading.value = false
message.error("流程图提交失败")
console.error("提交流程图失败:", error)
}
}
// 提交函数
function submit() {
submitFlowchartData(flowchartEditorRef)
}
// ==================== 数据获取和处理函数 ====================
async function getSubmission() {
if (!problem.value?.id) return
const { data } = await getCurrentProblemFlowchartSubmission(problem.value.id)
submissionCount.value = data.count
const submission = data.submission
myMermaidCode.value = submission.mermaid_code
myFlowchartZippedStr.value = submission.flowchart_data.data
if (submission && submission.status === 2) {
rating.value = {
score: submission.ai_score,
grade: submission.ai_grade,
}
evaluation.value = {
score: submission.ai_score,
grade: submission.ai_grade,
feedback: submission.ai_feedback,
suggestions: submission.ai_suggestions,
criteria_details: submission.ai_criteria_details,
}
}
}
// ==================== 模态框相关函数 ====================
async function openDetailModal() {
rendering.value = true
showDetailModal.value = true
await getSubmission()
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
rendering.value = false
}
function closeModal() {
showDetailModal.value = false
}
function loadToEditor() {
if (myFlowchartZippedStr.value) {
const str = atou(myFlowchartZippedStr.value)
const json = JSON.parse(str)
const processedData = {
nodes: json.nodes || [],
edges: json.edges || [],
}
if (flowchartEditorRef?.value) {
flowchartEditorRef.value.setFlowchartData(processedData)
}
}
closeModal()
}
// ==================== 工具函数 ====================
const getGradeType = (grade: string) => {
if (grade === "S") return "primary"
if (grade === "A") return "info"
if (grade === "B") return "warning"
return "error"
}
const getPercentType = (percent: number) => {
if (percent >= 0.8) return "primary"
else if (percent >= 0.6) return "info"
else if (percent >= 0.4) return "warning"
return "error"
}
// ==================== 生命周期钩子 ====================
// 组件挂载时连接 WebSocket 并检查状态
onMounted(() => {
connect()
getSubmission()
})
// 组件卸载时断开连接
onUnmounted(() => {
disconnect()
})
</script>
<template>
<!-- 主要操作区域 -->
<n-flex align="center">
<!-- 提交按钮 -->
<n-button
:size="isDesktop ? 'medium' : 'small'"
type="primary"
:loading="loading"
:disabled="loading"
@click="submit"
>
{{ loading ? "AI 点评中..." : "提交流程图" }}
</n-button>
<!-- 提交次数显示 -->
<n-button secondary v-if="submissionCount > 0" type="info">
{{ submissionCount }}
</n-button>
<!-- 评分结果按钮 -->
<n-button
secondary
v-if="rating.grade"
@click="openDetailModal"
:type="getGradeType(rating.grade)"
>
{{ rating.score }} {{ rating.grade }}
</n-button>
<!-- 评分详情模态框 -->
<n-modal
v-model:show="showDetailModal"
preset="card"
title="评分详情"
style="width: 1000px"
>
<n-grid :cols="5" :x-gap="16">
<!-- 左侧流程图预览区域 -->
<n-gi :span="3">
<n-card title="流程图预览">
<div class="flowchart">
<n-spin v-if="rendering" />
<!-- 错误提示 -->
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
<template #default>
{{ renderError }}
</template>
</n-alert>
<!-- 流程图容器 -->
<div class="flowchart" v-else ref="mermaidContainer"></div>
</div>
</n-card>
<!-- 加载到编辑器按钮 -->
<n-flex style="margin-top: 16px" justify="center">
<n-button @click="loadToEditor" type="primary">
加载到流程图编辑器
</n-button>
</n-flex>
</n-gi>
<!-- 右侧评分详情区域 -->
<n-gi :span="2">
<!-- AI反馈 -->
<n-card
v-if="evaluation.feedback"
size="small"
title="AI反馈"
style="margin-bottom: 16px"
>
<n-text>{{ evaluation.feedback }}</n-text>
</n-card>
<!-- 改进建议 -->
<n-card
v-if="evaluation.suggestions"
size="small"
title="改进建议"
style="margin-bottom: 16px"
>
<n-text>{{ evaluation.suggestions }}</n-text>
</n-card>
<!-- 详细评分 -->
<n-card
v-if="evaluation.criteria_details"
size="small"
title="详细评分"
>
<div
v-for="(detail, key) in evaluation.criteria_details"
:key="key"
style="margin-bottom: 12px"
>
<!-- 评分项标题和分数 -->
<n-flex
justify="space-between"
align="center"
style="margin-bottom: 4px"
>
<n-text strong>{{ key }}</n-text>
<n-tag
:type="getPercentType(detail.score / detail.max)"
size="small"
round
>
{{ detail.score || 0 }} / {{ detail.max }}
</n-tag>
</n-flex>
<!-- 评分项详细说明 -->
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
{{ detail.comment }}
</n-text>
</div>
</n-card>
</n-gi>
</n-grid>
</n-modal>
</n-flex>
</template>
<style scoped>
/* ==================== 流程图样式 ==================== */
.flowchart {
height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
/* 确保 SVG 图表占满容器 */
:deep(.flowchart > svg) {
height: 100%;
}
</style>

View File

@@ -0,0 +1,228 @@
import confetti from "canvas-confetti"
/**
* 随机烟花效果 Composable
* 提供7种不同风格的烟花庆祝效果
*/
export function useFireworks() {
/**
* 触发随机烟花效果
*/
function celebrate() {
const fireworkTypes = [
// 效果1: 经典烟花秀
() => {
const duration = 3000
const animationEnd = Date.now() + duration
const defaults = {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0,
}
const interval: any = setInterval(() => {
const timeLeft = animationEnd - Date.now()
if (timeLeft <= 0) return clearInterval(interval)
const particleCount = 50 * (timeLeft / duration)
confetti({
...defaults,
particleCount,
origin: { x: Math.random() * 0.3 + 0.1, y: Math.random() - 0.2 },
colors: ["#ff6b6b", "#ffd93d", "#6bcf7f", "#4ecdc4", "#a29bfe"],
})
confetti({
...defaults,
particleCount,
origin: { x: Math.random() * 0.3 + 0.7, y: Math.random() - 0.2 },
colors: ["#ff6b6b", "#ffd93d", "#6bcf7f", "#4ecdc4", "#a29bfe"],
})
}, 250)
},
// 效果2: 星星雨
() => {
const count = 10
const defaults = {
origin: { y: 0.7 },
shapes: ["star"],
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
}
function fire(particleRatio: number, opts: any) {
confetti({
...defaults,
...opts,
particleCount: Math.floor(200 * particleRatio),
})
}
fire(0.25, { spread: 26, startVelocity: 55 })
fire(0.2, { spread: 60 })
fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8 })
fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 })
fire(0.1, { spread: 120, startVelocity: 45 })
},
// 效果3: 爆炸波浪
() => {
function randomInRange(min: number, max: number) {
return Math.random() * (max - min) + min
}
for (let i = 0; i < 5; i++) {
setTimeout(() => {
confetti({
angle: randomInRange(55, 125),
spread: randomInRange(50, 70),
particleCount: randomInRange(50, 100),
origin: { y: 0.6 },
colors: ["#26ccff", "#a25afd", "#ff5e7e", "#88ff5a", "#fcff42"],
})
}, i * 200)
}
},
// 效果4: 彩虹喷泉
() => {
const end = Date.now() + 2000
const colors = ["#bb0000", "#ffffff"]
const frame = () => {
confetti({
particleCount: 2,
angle: 60,
spread: 55,
origin: { x: 0 },
colors: colors,
})
confetti({
particleCount: 2,
angle: 120,
spread: 55,
origin: { x: 1 },
colors: colors,
})
if (Date.now() < end) {
requestAnimationFrame(frame)
}
}
frame()
},
// 效果5: 烟花雨
() => {
const duration = 2500
const animationEnd = Date.now() + duration
const interval: any = setInterval(() => {
const timeLeft = animationEnd - Date.now()
if (timeLeft <= 0) return clearInterval(interval)
const particleCount = 50
confetti({
particleCount,
startVelocity: 30,
spread: 360,
ticks: 60,
origin: {
x: Math.random(),
y: Math.random() - 0.2,
},
colors: [
"#ff0000",
"#00ff00",
"#0000ff",
"#ffff00",
"#ff00ff",
"#00ffff",
],
})
}, 200)
},
// 效果6: 炮竹齐鸣
() => {
const count = 200
const defaults = {
origin: { y: 0.7 },
}
function fire(particleRatio: number, opts: any) {
confetti({
...defaults,
...opts,
particleCount: Math.floor(count * particleRatio),
})
}
fire(0.25, {
spread: 26,
startVelocity: 55,
})
fire(0.2, {
spread: 60,
})
fire(0.35, {
spread: 100,
decay: 0.91,
scalar: 0.8,
})
fire(0.1, {
spread: 120,
startVelocity: 25,
decay: 0.92,
scalar: 1.2,
})
fire(0.1, {
spread: 120,
startVelocity: 45,
})
},
// 效果7: 螺旋上升
() => {
const defaults = {
spread: 360,
ticks: 100,
gravity: 0,
decay: 0.94,
startVelocity: 30,
}
function shoot() {
confetti({
...defaults,
particleCount: 50,
scalar: 1.2,
shapes: ["circle", "square"],
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"],
})
}
setTimeout(shoot, 0)
setTimeout(shoot, 100)
setTimeout(shoot, 200)
setTimeout(shoot, 300)
setTimeout(shoot, 400)
},
]
// 随机选择一种效果
const randomEffect =
fireworkTypes[Math.floor(Math.random() * fireworkTypes.length)]
randomEffect()
}
return {
celebrate,
}
}

View File

@@ -0,0 +1,109 @@
/**
* 将流程图JSON数据转换为Mermaid格式
*/
export function useMermaidConverter() {
const convertToMermaid = (flowchartData: any) => {
const { nodes, edges } = flowchartData
if (!nodes || nodes.length === 0) {
return "graph TD\n A[空流程图]"
}
let mermaid = "graph TD\n"
// 处理节点 - 根据原始类型和自定义标签
nodes.forEach((node: any) => {
const nodeId = node.id
const label = node.data?.customLabel || node.data?.label || "节点"
const originalType = node.data?.originalType || node.type
// 根据节点原始类型确定Mermaid语法
switch (originalType) {
case "start":
mermaid += ` ${nodeId}(("${label}"))\n`
break
case "end":
mermaid += ` ${nodeId}(("${label}"))\n`
break
case "input":
// 输入框使用平行四边形
mermaid += ` ${nodeId}[/"${label}"/]\n`
break
case "output":
// 输出框使用平行四边形
mermaid += ` ${nodeId}[/"${label}"/]\n`
break
case "default":
mermaid += ` ${nodeId}["${label}"]\n`
break
case "decision":
mermaid += ` ${nodeId}{"${label}"}\n`
break
case "loop":
// 循环使用菱形
mermaid += ` ${nodeId}{"${label}"}\n`
break
default:
mermaid += ` ${nodeId}["${label}"]\n`
}
})
// 处理边
edges.forEach((edge: any) => {
const source = edge.source
const target = edge.target
const label = edge.label ?? ""
if (label && label.trim() !== "") {
mermaid += ` ${source} -->|"${label}"| ${target}\n`
} else {
mermaid += ` ${source} --> ${target}\n`
}
})
// 添加样式定义来区分不同类型的节点
mermaid += "\n"
mermaid +=
" classDef startEnd fill:#e1f5fe,stroke:#01579b,stroke-width:2px\n"
mermaid +=
" classDef input fill:#e3f2fd,stroke:#1976d2,stroke-width:2px\n"
mermaid +=
" classDef output fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px\n"
mermaid +=
" classDef process fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px\n"
mermaid +=
" classDef decision fill:#fff3e0,stroke:#e65100,stroke-width:2px\n"
mermaid += "\n"
// 为节点应用样式
nodes.forEach((node: any) => {
const nodeId = node.id
const originalType = node.data?.originalType || node.type
switch (originalType) {
case "start":
case "end":
mermaid += ` class ${nodeId} startEnd\n`
break
case "input":
mermaid += ` class ${nodeId} input\n`
break
case "output":
mermaid += ` class ${nodeId} output\n`
break
case "decision":
case "loop":
mermaid += ` class ${nodeId} decision\n`
break
default:
mermaid += ` class ${nodeId} process\n`
}
})
return mermaid
}
return {
convertToMermaid,
}
}

View File

@@ -0,0 +1,175 @@
import { ref, computed, watch, onUnmounted } from "vue"
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
import { getSubmission } from "oj/api"
import { SubmissionStatus } from "utils/constants"
import type { Submission } from "utils/types"
import {
useSubmissionWebSocket,
type SubmissionUpdate,
} from "shared/composables/websocket"
/**
* 判题监控 Composable
* 负责通过 WebSocket + 轮询双保险机制监控判题结果
*/
export function useSubmissionMonitor() {
// ==================== 状态 ====================
const submissionId = ref("")
const submission = ref<Submission>()
// ==================== 轮询机制 ====================
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
async () => {
if (!submissionId.value) return
try {
const res = await getSubmission(submissionId.value)
submission.value = res.data
const result = res.data.result
// 判题完成,停止轮询
if (
result !== SubmissionStatus.judging &&
result !== SubmissionStatus.pending
) {
pausePolling()
}
} catch (error) {
console.error("[SubmissionMonitor] 轮询失败:", error)
pausePolling()
}
},
2000,
{ immediate: false },
)
// ==================== WebSocket 处理 ====================
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
if (data.submission_id !== submissionId.value) {
console.log("[SubmissionMonitor] 提交ID不匹配忽略")
return
}
if (!submission.value) {
submission.value = {} as Submission
}
submission.value.result = data.result as Submission["result"]
// 判题完成或出错,获取完整详情
if (data.status === "finished" || data.status === "error") {
console.log(
`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`,
)
// 停止轮询WebSocket已成功
pausePolling()
getSubmission(submissionId.value).then((res) => {
submission.value = res.data
// 15分钟无新提交则断开WebSocket节省资源
scheduleDisconnect(15 * 60 * 1000)
})
}
}
// 初始化 WebSocket
const {
connect,
subscribe,
scheduleDisconnect,
cancelScheduledDisconnect,
status: wsStatus,
} = useSubmissionWebSocket(handleSubmissionUpdate)
// ==================== 轮询保底启动 ====================
const { start: startPollingFallback } = useTimeoutFn(
() => {
if (
submission.value &&
(submission.value.result === SubmissionStatus.judging ||
submission.value.result === SubmissionStatus.pending ||
submission.value.result === 9) // 9 = submitting
) {
console.log("[SubmissionMonitor] WebSocket未及时响应启动轮询保底")
resumePolling()
}
},
5000,
{ immediate: false },
)
// ==================== 启动监控 ====================
const startMonitoring = (id: string) => {
submissionId.value = id
submission.value = { id, result: 9 } as Submission // 9 = submitting
// 取消之前的断开计划
cancelScheduledDisconnect()
// 如果WebSocket未连接先连接
if (wsStatus.value !== "connected") {
console.log("[SubmissionMonitor] 启动WebSocket连接...")
connect()
}
// 等待WebSocket连接并订阅
let unwatch: (() => void) | null = null
unwatch = watch(
wsStatus,
(status) => {
if (status === "connected") {
console.log("[SubmissionMonitor] WebSocket已连接订阅提交:", id)
subscribe(id)
if (unwatch) {
unwatch() // 订阅成功后停止监听
}
}
},
{ immediate: true },
)
// 5秒后启动轮询保底防止WebSocket失败
startPollingFallback()
}
// ==================== 计算属性 ====================
const judging = computed(
() => submission.value?.result === SubmissionStatus.judging,
)
const pending = computed(
() => submission.value?.result === SubmissionStatus.pending,
)
const submitting = computed(
() => submission.value?.result === SubmissionStatus.submitting,
)
const isProcessing = computed(() => {
return judging.value || pending.value || submitting.value
})
// ==================== 清理 ====================
onUnmounted(() => {
pausePolling()
})
return {
// 状态
submissionId,
submission,
// 计算属性
judging,
pending,
submitting,
isProcessing,
// 方法
startMonitoring,
pausePolling,
}
}

View File

@@ -1,13 +1,9 @@
<script setup lang="ts">
import { getProblem } from "oj/api"
import { ScreenMode } from "utils/constants"
import { isDesktop, isMobile } from "shared/composables/breakpoints"
import {
bothAndProblem,
resetScreenMode,
screenMode,
} from "shared/composables/switchScreen"
import { problem } from "../composables/problem"
import { useBreakpoints } from "shared/composables/breakpoints"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useScreenModeStore } from "shared/store/screenMode"
const ProblemEditor = defineAsyncComponent(
() => import("./components/ProblemEditor.vue"),
@@ -30,22 +26,37 @@ const ProblemSubmission = defineAsyncComponent(
const ProblemComment = defineAsyncComponent(
() => import("./components/ProblemComment.vue"),
)
const ProblemFlowchart = defineAsyncComponent(
() => import("./components/ProblemFlowchart.vue"),
)
interface Props {
problemID: string
contestID?: string
problemSetId?: string
}
const props = withDefaults(defineProps<Props>(), {
contestID: "",
problemSetId: "",
})
const errMsg = ref("无数据")
const route = useRoute()
const router = useRouter()
const problemStore = useProblemStore()
const screenModeStore = useScreenModeStore()
const { problem } = storeToRefs(problemStore)
const { shouldShowProblem } = storeToRefs(screenModeStore)
const { isMobile, isDesktop } = useBreakpoints()
const tabOptions = computed(() => {
const options: string[] = ["content"]
if (problem.value?.show_flowchart) {
options.push("flowchart")
}
if (isMobile.value) {
options.push("editor")
}
@@ -59,7 +70,7 @@ const tabOptions = computed(() => {
const currentTab = ref("content")
const shouldUseProblemEditor = computed(() => route.name === "problem")
const inProblem = computed(() => route.name === "problem")
watch(
[() => route.query.tab, () => tabOptions.value],
@@ -81,7 +92,7 @@ watch(currentTab, (tab) => {
})
async function init() {
resetScreenMode()
screenModeStore.resetScreenMode()
try {
const res = await getProblem(props.problemID, props.contestID)
problem.value = res.data
@@ -96,33 +107,49 @@ onMounted(init)
onBeforeUnmount(() => {
problem.value = null
errMsg.value = "无数据"
resetScreenMode()
screenModeStore.resetScreenMode()
})
watch(isMobile, (value) => {
if (value) resetScreenMode()
if (value) screenModeStore.resetScreenMode()
})
</script>
<template>
<n-grid
v-if="problem"
x-gap="16"
:cols="screenMode === ScreenMode.both ? 2 : 1"
>
<n-gi :span="isDesktop ? 1 : 2" v-if="bothAndProblem">
<n-grid v-if="problem" x-gap="16" :cols="screenModeStore.isBothMode ? 7 : 1">
<n-gi :span="isDesktop ? 3 : 7" v-if="shouldShowProblem">
<n-scrollbar v-if="isDesktop" style="max-height: calc(100vh - 92px)">
<n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane name="info" tab="题目统计">
<n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code"
name="flowchart"
tab="流程图表"
>
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane
name="info"
tab="题目统计"
:disabled="!!props.problemSetId"
>
<ProblemInfo />
</n-tab-pane>
<n-tab-pane v-if="!props.contestID" name="comment" tab="题目点评">
<n-tab-pane
v-if="!props.contestID"
name="comment"
tab="题目点评"
:disabled="!!props.problemSetId"
>
<ProblemComment />
</n-tab-pane>
<n-tab-pane name="submission" tab="我的提交">
<n-tab-pane
name="submission"
tab="我的提交"
:disabled="!!props.problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
</n-tabs>
@@ -131,26 +158,36 @@ watch(isMobile, (value) => {
<n-tab-pane name="content" tab="描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane name="editor" tab="编辑">
<ProblemEditor v-if="shouldUseProblemEditor" />
<ContestEditor v-else />
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="info" tab="统计">
<n-tab-pane name="editor" tab="代码">
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!props.problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane v-if="!props.contestID" name="comment" tab="点评">
<n-tab-pane
v-if="!props.contestID"
name="comment"
tab="点评"
:disabled="!!props.problemSetId"
>
<ProblemComment />
</n-tab-pane>
<n-tab-pane name="submission" tab="提交">
<n-tab-pane
name="submission"
tab="提交"
:disabled="!!props.problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
</n-tabs>
</n-gi>
<n-gi v-if="isDesktop && screenMode === ScreenMode.both">
<ProblemEditor v-if="shouldUseProblemEditor" />
<ContestEditor v-else />
<n-gi :span="4" v-if="isDesktop && screenModeStore.isBothMode">
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</n-gi>
<n-gi v-if="isDesktop && screenMode === ScreenMode.code">
<n-gi v-if="isDesktop && screenModeStore.isCodeOnlyMode">
<EditorForTest />
</n-gi>
</n-grid>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { NSpace, NTag } from "naive-ui"
import { NFlex, NTag } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import { getProblemList, getRandomProblemID } from "oj/api"
import { getTagColor } from "utils/functions"
@@ -8,12 +8,13 @@ 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 { useBreakpoints } 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"
import ProblemListTitle from "./components/ProblemListTitle.vue"
interface Tag {
id: number
@@ -26,6 +27,7 @@ interface ProblemQuery {
difficulty: string
tag: string
author: string
sort: string
}
const difficultyOptions = [
@@ -35,9 +37,21 @@ const difficultyOptions = [
{ label: "困难", value: "High" },
]
const sortOptions = [
{ label: "最新创建", value: "" },
{ label: "最早创建", value: "create_time" },
{ label: "最多提交", value: "-submission_number" },
{ label: "最少提交", value: "submission_number" },
{ label: "最多通过", value: "-accepted_number" },
{ label: "最少通过", value: "accepted_number" },
]
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const problems = ref<ProblemFiltered[]>([])
const total = ref(0)
const tags = ref<Tag[]>([])
@@ -49,6 +63,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
difficulty: useRouteQuery("difficulty", "").value,
tag: useRouteQuery("tag", "").value,
author: useRouteQuery("author", "").value,
sort: useRouteQuery("sort", "").value,
})
async function listProblems() {
@@ -59,6 +74,7 @@ async function listProblems() {
tag: query.tag,
difficulty: query.difficulty,
author: query.author,
sort: query.sort,
})
total.value = res.total
problems.value = res.results
@@ -97,7 +113,14 @@ watchDebounced(() => query.keyword, listProblems, {
// 监听其他查询条件变化
watch(
() => [query.tag, query.difficulty, query.limit, query.page, query.author],
() => [
query.tag,
query.difficulty,
query.limit,
query.page,
query.author,
query.sort,
],
listProblems,
)
@@ -143,6 +166,7 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
title: renderTableTitle("题目", "streamline-emojis:watermelon-2"),
key: "title",
minWidth: 200,
render: (row) => h(ProblemListTitle, { problem: row }),
},
{
title: renderTableTitle("难度", "streamline-emojis:lady-beetle"),
@@ -156,7 +180,7 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
key: "tags",
width: 260,
render: (row) =>
h(NSpace, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
},
{
title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"),
@@ -210,6 +234,13 @@ function rowProps(row: ProblemFiltered) {
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item label="排序">
<n-select
style="width: 120px"
v-model:value="query.sort"
:options="sortOptions"
/>
</n-form-item>
<n-form-item>
<n-input
clearable
@@ -218,6 +249,8 @@ function rowProps(row: ProblemFiltered) {
placeholder="编号或者标题"
/>
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item>
<n-button @click="clearQuery" quaternary>重置</n-button>
</n-form-item>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
import UserBadge from "shared/components/UserBadge.vue"
import { useUserStore } from "shared/store/user"
interface Props {
problemSet: ProblemSet
isJoined: boolean
isJoining: boolean
userBadges: UserBadgeType[]
}
interface Emits {
(e: "join"): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const userStore = useUserStore()
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function getProgressPercentage() {
if (!props.problemSet) return 0
return Math.round(
(props.problemSet.completed_count / props.problemSet.problems_count) * 100,
)
}
function handleJoin() {
emit("join")
}
</script>
<template>
<n-card style="margin-bottom: 24px">
<n-flex justify="space-between" align="center">
<n-flex align="center">
<n-tag type="warning" v-if="problemSet.status === 'archived'">
已归档
</n-tag>
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
<n-tooltip trigger="hover" v-if="problemSet.description">
<template #trigger>
<Icon width="20" icon="emojione:information" />
</template>
{{ problemSet.description }}
</n-tooltip>
</n-flex>
<n-flex align="center" v-if="userStore.isAuthed">
<!-- 用户徽章显示区域 - 只在已加入且有徽章时显示 -->
<n-flex v-if="isJoined && userBadges.length > 0" align="center">
<n-text>已获徽章</n-text>
<UserBadge
v-for="badge in userBadges"
:key="badge.id"
:badge="badge"
/>
</n-flex>
<!-- 完成进度 - 只在已加入时显示 -->
<n-flex align="center" v-if="isJoined">
<n-text strong>完成进度</n-text>
<n-text>
{{ problemSet.completed_count }} / {{ problemSet.problems_count }}
</n-text>
</n-flex>
<n-progress
v-if="isJoined"
:percentage="getProgressPercentage()"
:height="8"
:border-radius="4"
style="width: 200px"
/>
<n-button
v-if="!isJoined"
type="primary"
size="large"
:loading="isJoining"
@click="handleJoin"
>
加入题单
</n-button>
<n-tag v-else type="success" size="large">
<template #icon>
<Icon icon="material-symbols:check-circle" />
</template>
已加入
</n-tag>
</n-flex>
</n-flex>
</n-card>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { ProblemSetProblem } from "utils/types"
import { DIFFICULTY } from "utils/constants"
import { getTagColor } from "utils/functions"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
problems: ProblemSetProblem[]
isJoined: boolean
}
interface Emits {
(e: "problem-click", problemId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { isDesktop } = useBreakpoints()
function handleProblemClick(problemId: string) {
emit("problem-click", problemId)
}
</script>
<template>
<div>
<n-grid :cols="isDesktop ? 4 : 1" :x-gap="16" :y-gap="16">
<n-grid-item
v-for="(problemSetProblem, index) in problems"
:key="problemSetProblem.id"
>
<n-card
hoverable
@click="handleProblemClick(problemSetProblem.problem._id)"
style="cursor: pointer"
>
<n-flex align="center">
<Icon
style="margin-right: 10px"
width="48"
icon="noto:check-mark-button"
v-if="problemSetProblem.is_completed"
/>
<n-flex vertical style="flex: 1">
<n-flex align="center">
<n-h4 style="margin: 0">#{{ index + 1 }}</n-h4>
<n-h4 style="margin: 0">
{{ problemSetProblem.problem.title }}
</n-h4>
</n-flex>
<n-flex align="center" size="small">
<n-tag
:type="getTagColor(problemSetProblem.problem.difficulty)"
size="small"
>
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
</n-tag>
<n-text type="info">分数{{ problemSetProblem.score }}</n-text>
<n-text v-if="!problemSetProblem.is_required">选做</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
<div class="tip">
<n-text depth="3">题目完成后会自动返回题单页面</n-text>
</div>
</div>
</template>
<style scoped>
.tip {
padding-top: 24px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { h, computed, ref, onMounted, watch } from "vue"
import { watchDebounced } from "@vueuse/core"
import { parseTime } from "utils/functions"
import { ProblemSetProgress } from "utils/types"
import { getProblemSetUserProgress } from "../../api"
import { NFlex, NTag } from "naive-ui"
import { usePagination } from "shared/composables/pagination"
import Pagination from "shared/components/Pagination.vue"
const route = useRoute()
const problemSetId = computed(() => Number(route.params.problemSetId))
const progress = ref<ProblemSetProgress[]>([])
const loading = ref(false)
const total = ref(0)
const statistics = ref<{
total: number
completed: number
avg_progress: number
} | null>(null)
const classFilter = ref<string>("")
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">("")
const allProblems = ref<Array<{ id: number; _id: string; title: string }>>([])
// 完成度筛选选项
const completionOptions = [
{ label: "全部", value: "" },
{ label: "未开始", value: "not_started"},
{ label: "进行中", value: "in_progress" },
{ label: "已完成", value: "completed" },
]
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 10 })
// 加载用户进度数据
async function loadUserProgress() {
loading.value = true
const offset = (query.page - 1) * query.limit
const params: {
limit?: number
offset?: number
class_name?: string
completion_status?: "" | "completed" | "in_progress" | "not_started"
} = {
limit: query.limit,
offset,
}
if (classFilter.value.trim()) {
params.class_name = classFilter.value.trim()
}
if (completionFilter.value) {
params.completion_status = completionFilter.value
}
const res = await getProblemSetUserProgress(problemSetId.value, params)
progress.value = res.data.results
total.value = res.data.total
// 使用后端返回的统计数据(基于所有数据)
if (res.data.statistics) {
statistics.value = res.data.statistics
}
// 保存所有题目信息
if (res.data.problems) {
allProblems.value = res.data.problems
}
loading.value = false
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], loadUserProgress)
// 监听班级过滤变化(防抖)
watchDebounced(
classFilter,
() => {
query.page = 1 // 重置到第一页
loadUserProgress()
},
{ debounce: 500 },
)
// 监听完成度筛选变化
watch(completionFilter, () => {
query.page = 1 // 重置到第一页
loadUserProgress()
})
// 使用后端返回的统计数据
const stats = computed(() => {
if (statistics.value) {
return {
total: statistics.value.total,
completed: statistics.value.completed,
avgProgress: Math.round(statistics.value.avg_progress),
}
}
// 如果后端还没有返回统计数据,使用默认值
return {
total: total.value,
completed: 0,
avgProgress: 0,
}
})
onMounted(loadUserProgress)
// 定义表格列
const progressColumns = [
{
title: "排名",
key: "rank",
width: 80,
render: (row: ProblemSetProgress, index: number) => {
// 计算全局排名:当前页偏移 + 当前行索引 + 1
const globalRank = (query.page - 1) * query.limit + index + 1
return globalRank
},
},
{
title: "用户",
key: "user.username",
width: 120,
render: (row: ProblemSetProgress) => row.user.username,
},
{
title: "加入时间",
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) =>
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "已完成数量",
key: "completed_problems_count",
width: 100,
},
{
title: "已/未完成题目",
key: "completed_problems",
width: 300,
render: (row: ProblemSetProgress) => {
if (row.progress_percentage === 100) {
return "全部题目已完成"
}
if (row.progress_percentage > 50 && row.progress_percentage < 100) {
const completedProblemIds = new Set(
row.completed_problems.map((p: any) => p.id),
)
const incompleteProblems = allProblems.value.filter(
(p) => !completedProblemIds.has(p.id),
)
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(
NFlex,
{},
() => incompleteProblems.map((problem) =>
h(
NTag,
{ type: "warning", size: "small", style: "margin: 2px" },
() => `${problem._id}: ${problem.title}`,
),
),
),
])
}
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(
NFlex,
{},
() => row.completed_problems.map((problem: any) =>
h(
NTag,
{
type: "success",
size: "small",
style: "margin: 2px",
},
() => `${problem._id}: ${problem.title}`,
),
),
),
])
},
},
{
title: "进度",
key: "progress_percentage",
width: 120,
render: (row: ProblemSetProgress) => {
return `${row.progress_percentage.toFixed(0)}%`
},
},
{
title: "状态",
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => {
if (row.is_completed) {
return h(NTag, { type: "success" }, () => "已完成")
} else {
return h(NTag, { type: "warning" }, () => "进行中")
}
},
},
]
</script>
<template>
<div>
<!-- 过滤条件 -->
<n-form label-placement="left" inline>
<n-form-item label="班级">
<n-input
v-model:value="classFilter"
placeholder="输入班级名称"
style="width: 200px"
clearable
/>
</n-form-item>
<n-form-item label="完成度:">
<n-select
v-model:value="completionFilter"
:options="completionOptions"
placeholder="完成度"
style="width: 160px"
clearable
/>
</n-form-item>
</n-form>
<!-- 统计信息卡片 -->
<n-grid :cols="3" :x-gap="16" style="margin-bottom: 16px">
<n-grid-item>
<n-card size="small">
<n-statistic label="总参与人数" :value="stats.total" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic label="已完成人数" :value="stats.completed" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic
label="平均进度"
:value="stats.avgProgress.toFixed(0) + '%'"
/>
</n-card>
</n-grid-item>
</n-grid>
<n-data-table
:loading="loading"
:columns="progressColumns"
:data="progress"
:pagination="false"
:bordered="false"
:single-line="false"
/>
<Pagination
:total="total"
:limit="query.limit"
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import {
getProblemSetDetail,
getProblemSetProblems,
joinProblemSet,
getUserBadges,
} from "../api"
import {
ProblemSet,
ProblemSetProblem,
UserBadge as UserBadgeType,
} from "utils/types"
import { useFireworks } from "../problem/composables/useFireworks"
import ProblemSetHeader from "./components/ProblemSetHeader.vue"
import ProblemSetProblemsList from "./components/ProblemSetProblemsList.vue"
import UserProgressView from "./components/UserProgressView.vue"
import { useUserStore } from "shared/store/user"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { celebrate } = useFireworks()
const userStore = useUserStore()
const problemSetId = computed(() => Number(route.params.problemSetId))
const problemSet = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([])
const isJoined = ref(false)
const isJoining = ref(false)
const userBadges = ref<UserBadgeType[]>([])
const activeTab = ref("problems")
async function loadProblemSetDetail() {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data
isJoined.value = res.data.user_progress?.is_joined || false
}
async function loadProblems() {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res.data
}
async function loadUserBadges() {
if (!isJoined.value) return
const res = await getUserBadges()
userBadges.value = res.data.filter(
(badge: UserBadgeType) => badge.badge.problemset === problemSetId.value,
)
}
async function init() {
await Promise.all([loadProblemSetDetail(), loadProblems()])
if (isJoined.value) {
if (problemSet.value?.user_progress?.is_completed) {
celebrate()
}
loadUserBadges()
}
}
async function handleProblemClick(problemId: string) {
if (!userStore.isAuthed) {
message.warning("请先登录!")
return
}
if (!isJoined.value) {
message.warning("请先点击【加入题单】按钮!")
return
}
router.push({
name: "problemset problem",
params: {
problemSetId: problemSetId.value,
problemID: problemId,
},
})
}
async function handleJoinProblemSet() {
if (isJoining.value) return
isJoining.value = true
try {
await joinProblemSet(problemSetId.value)
isJoined.value = true
message.success("成功加入题单!")
// 加入题单后加载用户徽章
await loadUserBadges()
} catch (err: any) {
message.error("加入题单失败:" + (err.data || "未知错误"))
} finally {
isJoining.value = false
}
}
const showTabs = computed(
() =>
userStore.isSuperAdmin ||
(isJoined.value && problemSet.value?.user_progress?.is_completed),
)
onMounted(init)
</script>
<template>
<div v-if="problemSet">
<ProblemSetHeader
:problem-set="problemSet"
:is-joined="isJoined"
:is-joining="isJoining"
:user-badges="userBadges"
@join="handleJoinProblemSet"
/>
<n-tabs v-if="showTabs" v-model:value="activeTab" animated>
<n-tab-pane name="problems" tab="题目列表">
<ProblemSetProblemsList
:problems="problems"
:is-joined="isJoined"
@problem-click="handleProblemClick"
/>
</n-tab-pane>
<n-tab-pane name="progress" tab="用户进度">
<UserProgressView />
</n-tab-pane>
</n-tabs>
<ProblemSetProblemsList
v-else
:problems="problems"
:is-joined="isJoined"
@problem-click="handleProblemClick"
/>
</div>
</template>
<style scoped></style>

269
src/oj/problemset/list.vue Normal file
View File

@@ -0,0 +1,269 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useRouteQuery } from "@vueuse/router"
import { getProblemSetList } from "../api"
import { parseTime } from "utils/functions"
import { ProblemSetList } from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { useBreakpoints } from "shared/composables/breakpoints"
const router = useRouter()
const { isDesktop } = useBreakpoints()
const total = ref(0)
const problemSets = ref<ProblemSetList[]>([])
interface ProblemSetQuery {
keyword: string
difficulty: string
status: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>({
keyword: useRouteQuery("keyword", "").value,
difficulty: useRouteQuery("difficulty", "").value,
status: useRouteQuery("status", "").value,
})
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "全部", value: "" },
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
]
async function listProblemSets() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemSetList(
offset,
query.limit,
query.keyword,
query.difficulty,
query.status,
)
total.value = res.data.total
problemSets.value = res.data.results
}
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function goToProblemSet(problemSetId: number) {
router.push(`/problemset/${problemSetId}`)
}
function getConditionText(
conditionType: string,
conditionValue: number,
): string {
const conditionMap: Record<string, string> = {
all_problems: "完成所有题目",
problem_count: `完成 ${conditionValue} 道题目`,
score: `达到 ${conditionValue}`,
}
return conditionMap[conditionType] || "未知条件"
}
function getProgressColor(percentage: number) {
if (percentage >= 80) return "#18a058" // 绿色
if (percentage >= 50) return "#f0a020" // 橙色
return "#d03050" // 红色
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [query.page, query.limit, query.difficulty, query.status],
listProblemSets,
)
</script>
<template>
<n-flex vertical size="large">
<n-space>
<n-space align="center">
<n-text>难度</n-text>
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
style="width: 120px"
clearable
/>
</n-space>
<n-space align="center">
<n-text>状态</n-text>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="选择状态"
style="width: 120px"
clearable
/>
</n-space>
<n-input
v-model:value="query.keyword"
placeholder="搜索题单..."
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-space>
<n-grid
v-if="problemSets.length > 0"
:cols="isDesktop ? 3 : 1"
:x-gap="16"
:y-gap="16"
>
<n-grid-item v-for="problemSet in problemSets" :key="problemSet.id">
<n-card
hoverable
@click="goToProblemSet(problemSet.id)"
style="cursor: pointer"
>
<template #header>
<n-flex justify="space-between" align="center">
<n-text strong>{{ problemSet.title }}</n-text>
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
</n-flex>
</template>
<n-flex vertical size="large">
<n-flex justify="space-between" align="center">
<n-flex>
<Icon width="20" icon="streamline-emojis:blossom" />
<n-text>{{ problemSet.problems_count }} 道题目</n-text>
</n-flex>
<n-flex align="center" style="height: 28px">
<!-- 用户进度显示 -->
<n-progress
v-if="
problemSet.user_progress?.is_joined &&
!problemSet.user_progress?.is_completed
"
type="line"
:percentage="
Math.round(problemSet.user_progress.progress_percentage)
"
:height="4"
:border-radius="2"
style="width: 100px"
:color="
getProgressColor(
problemSet.user_progress.progress_percentage,
)
"
/>
<n-tag type="warning" v-if="problemSet.status === 'archived'">
已归档
</n-tag>
<n-tag
v-if="
problemSet.user_progress?.is_joined &&
!problemSet.user_progress?.is_completed
"
type="warning"
>
已加入
</n-tag>
<n-tag
v-if="problemSet.user_progress?.is_completed"
type="error"
>
已完成
</n-tag>
</n-flex>
</n-flex>
<!-- 奖章显示 -->
<n-flex align="center" justify="space-between">
<n-text depth="3">
创建于
{{ parseTime(problemSet.create_time, "YYYY-MM-DD") }}
</n-text>
<n-flex>
<n-tooltip
v-for="badge in problemSet.badges"
:key="badge.id"
trigger="hover"
>
<template #trigger>
<n-image
:src="badge.icon"
:alt="badge.name"
width="24"
height="24"
object-fit="cover"
:class="{ 'earned-badge': badge.is_earned }"
/>
</template>
<n-flex vertical size="small">
<span style="font-weight: bold">
徽章: {{ badge.name }}
</span>
<span>
获取条件:
{{
getConditionText(
badge.condition_type,
badge.condition_value,
)
}}
</span>
<n-text type="primary" v-if="badge.is_earned">
✓ 已获得
</n-text>
</n-flex>
</n-tooltip>
</n-flex>
</n-flex>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
<Pagination
v-if="problemSets.length > 0"
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</n-flex>
<n-empty v-if="problemSets.length === 0"></n-empty>
</template>
<style scoped>
.earned-badge {
border: 2px solid #ffd700;
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
}
</style>

View File

@@ -15,6 +15,7 @@ export const useAIStore = defineStore("ai", () => {
difficulty: {},
contest_count: 0,
solved: [],
flowcharts: [],
})
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
@@ -36,6 +37,7 @@ export const useAIStore = defineStore("ai", () => {
detailsData.tags = res.data.tags
detailsData.difficulty = res.data.difficulty
detailsData.contest_count = res.data.contest_count
detailsData.flowcharts = res.data.flowcharts
}
async function fetchDurationData(end: string, duration: string) {

94
src/oj/store/code.ts Normal file
View File

@@ -0,0 +1,94 @@
import { defineStore } from "pinia"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { Code, LANGUAGE } from "utils/types"
/**
* 代码编辑器状态管理 Store
* 管理全局的代码、输入、输出状态
*/
export const useCodeStore = defineStore("code", () => {
// ==================== 状态 ====================
const code = reactive<Code>({
value: "",
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3",
})
const input = ref("")
const output = ref("")
// ==================== 计算属性 ====================
const isEmpty = computed(() => code.value.trim() === "")
// ==================== 操作 ====================
/**
* 设置代码内容
*/
function setCode(value: string) {
code.value = value
}
/**
* 设置编程语言
*/
function setLanguage(language: LANGUAGE) {
code.language = language
storage.set(STORAGE_KEY.LANGUAGE, language)
}
/**
* 设置输入
*/
function setInput(value: string) {
input.value = value
}
/**
* 设置输出
*/
function setOutput(value: string) {
output.value = value
}
/**
* 重置所有状态
*/
function reset() {
code.value = ""
input.value = ""
output.value = ""
}
/**
* 清空输出
*/
function clearOutput() {
output.value = ""
}
// 监听语言变化,保存到本地存储
watch(
() => code.language,
(newLanguage) => {
storage.set(STORAGE_KEY.LANGUAGE, newLanguage)
},
)
return {
// 状态
code,
input,
output,
// 计算属性
isEmpty,
// 操作
setCode,
setLanguage,
setInput,
setOutput,
reset,
clearOutput,
}
})

27
src/oj/store/problem.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineStore } from "pinia"
import { LANGUAGE, Problem } from "utils/types"
/**
* 题目状态管理 Store
* 管理当前题目的信息
*/
export const useProblemStore = defineStore("problem", () => {
// ==================== 状态 ====================
const problem = ref<Problem | null>(null)
// ==================== 计算属性 ====================
const languages = computed<LANGUAGE[]>(() => {
if (problem.value?.allow_flowchart) {
return ["Flowchart", ...problem.value?.languages]
}
return problem.value?.languages ?? []
})
return {
// 状态
problem,
// 计算属性
languages,
}
})

View File

@@ -23,7 +23,9 @@ function filterClass() {
</script>
<template>
<n-flex align="center">
<n-button text type="info" @click="$emit('click')"><slot></slot></n-button>
<n-button text type="info" @click="$emit('click')">
<slot></slot>
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="$emit('search')">

View File

@@ -0,0 +1,25 @@
<template>
<n-button v-if="showLink" type="info" text @click="goto">
{{ flowchart.id.slice(0, 12) }}
</n-button>
<n-text v-else>{{ flowchart.id.slice(0, 12) }}</n-text>
</template>
<script setup lang="ts">
import { useUserStore } from "shared/store/user"
import { FlowchartSubmissionListItem } from "utils/types"
const userStore = useUserStore()
interface Props {
flowchart: FlowchartSubmissionListItem
}
const props = defineProps<Props>()
const showLink = computed(() => {
if (!userStore.isAuthed) return false
if (userStore.isSuperAdmin) return true
return props.flowchart.username === userStore.user?.username
})
function goto() {}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<n-text :type="gradeType(grade)">
<span>{{ score }}</span>
<span>({{ grade }})</span>
</n-text>
</template>
<script setup lang="ts">
import { Grade } from "utils/types"
defineProps<{
score: number
grade: Grade
}>()
function gradeType(grade: Grade) {
return (
{
S: "success",
A: "info",
B: "warning",
C: "error",
} as const
)[grade]
}
</script>
<style scoped></style>

View File

@@ -14,7 +14,7 @@ import {
} from "utils/functions"
import { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { isDesktop, isMobile } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
const props = defineProps<{
submissionID: string
@@ -23,9 +23,12 @@ const props = defineProps<{
hideList?: boolean
}>()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { isMobile, isDesktop } = useBreakpoints()
const submission = ref<Submission>()
async function init() {
@@ -69,13 +72,13 @@ function copyToCat() {
async function copyToProblem() {
const success = await copyToClipboard(submission.value!.code)
if (success) {
message.success("代码复制成功")
message.success("代码复制成功,需要手动粘贴到题目")
} else {
message.error("代码复制失败")
}
// 判断是否是竞赛题目
const contestID = submission.value!.contest
const problemSetId = <string>route.params.problemSetId ?? ""
if (contestID) {
// 竞赛题目
router.push({
@@ -85,6 +88,15 @@ async function copyToProblem() {
problemID: props.problemID,
},
})
} else if (problemSetId) {
// 题单题目
router.push({
name: "problemset problem",
params: {
problemSetId: problemSetId,
problemID: props.problemID,
},
})
} else {
// 普通题目
router.push({
@@ -115,7 +127,7 @@ onMounted(init)
</n-alert>
<n-flex :vertical="isDesktop" justify="center">
<n-button secondary @click="copyToCat">复制到自测猫</n-button>
<n-button secondary @click="copyToProblem">回到题目</n-button>
<n-button secondary @click="copyToProblem">复制回到题目</n-button>
</n-flex>
</n-flex>
<n-card embedded>

View File

@@ -1,12 +1,21 @@
<script setup lang="ts">
import { NButton, NH2, NText } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import { adminRejudge, getSubmissions, getTodaySubmissionCount } from "oj/api"
import {
adminRejudge,
getFlowchartSubmissions,
getSubmissions,
getTodaySubmissionCount,
} from "oj/api"
import { parseTime } from "utils/functions"
import { LANGUAGE, SubmissionListItem } from "utils/types"
import {
FlowchartSubmissionListItem,
LANGUAGE,
SubmissionListItem,
} from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { isDesktop, isMobile } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
@@ -15,7 +24,8 @@ import ButtonWithSearch from "./components/ButtonWithSearch.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"
import Grade from "./components/Grade.vue"
import FlowchartLink from "./components/FlowchartLink.vue"
interface SubmissionQuery {
username: string
@@ -30,7 +40,10 @@ const router = useRouter()
const userStore = useUserStore()
const message = useMessage()
const { isMobile, isDesktop } = useBreakpoints()
const submissions = ref<SubmissionListItem[]>([])
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
const total = ref(0)
const todayCount = ref(0)
@@ -45,6 +58,8 @@ const { query, clearQuery } = usePagination<SubmissionQuery>({
const submissionID = ref("")
const problemDisplayID = ref("")
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
const [flowchartStatisticPanel, toggleFlowchartStatisticPanel] =
useToggle(false)
const [codePanel, toggleCodePanel] = useToggle(false)
const resultOptions: SelectOption[] = [
@@ -56,7 +71,8 @@ const resultOptions: SelectOption[] = [
]
const languageOptions: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "流程图", value: "Flowchart" },
{ label: "全部语言", value: "" },
{ label: "Python", value: "Python3" },
{ label: "C语言", value: "C" },
{ label: "C++", value: "C++" },
@@ -64,7 +80,17 @@ const languageOptions: SelectOption[] = [
async function listSubmissions() {
if (query.page < 1) query.page = 1
const offset = query.limit * (query.page - 1)
try {
if (query.language === "Flowchart") {
const res = await getFlowchartSubmissions({
username: query.username,
problem_id: query.problem,
myself: query.myself,
offset,
limit: query.limit,
})
total.value = res.data.total
flowcharts.value = res.data.results
} else {
const res = await getSubmissions({
...query,
offset,
@@ -74,10 +100,6 @@ async function listSubmissions() {
})
submissions.value = res.data.results
total.value = res.data.total
} catch (error: any) {
if (error.data === "Problem doesn't exist") {
message.error("题目不存在")
}
}
}
@@ -108,7 +130,7 @@ async function rejudge(submissionID: string) {
listSubmissions()
}
function problemClicked(row: SubmissionListItem) {
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
if (route.name === "contest submissions") {
const path = router.resolve({
name: "contest problem",
@@ -140,27 +162,18 @@ watch(
listSubmissions,
)
watch(
() =>
(route.name === "submissions" || route.name === "contest submissions") &&
route.query,
(newVal) => {
if (newVal) listSubmissions()
},
)
const columns = computed(() => {
const res: DataTableColumn<SubmissionListItem>[] = [
{
title: renderTableTitle("提交时间", "noto:seven-oclock"),
key: "create_time",
width: 200,
minWidth: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
key: "id",
minWidth: 180,
minWidth: 200,
render: (row) =>
h(SubmissionLink, {
submission: row,
@@ -170,13 +183,13 @@ const columns = computed(() => {
{
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
key: "status",
width: 140,
minWidth: 140,
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: renderTableTitle("题目", "streamline-emojis:blossom"),
key: "problem",
width: 160,
minWidth: 300,
render: (row) =>
h(
ButtonWithSearch,
@@ -185,7 +198,7 @@ const columns = computed(() => {
onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem),
},
() => row.problem,
() => `${row.problem} ${row.problem_title}`,
),
},
{
@@ -194,7 +207,7 @@ const columns = computed(() => {
"streamline-emojis:globe-showing-europe-africa",
),
key: "language",
width: 120,
minWidth: 120,
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
},
{
@@ -237,6 +250,58 @@ const columns = computed(() => {
}
return res
})
const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
{
title: renderTableTitle("提交时间", "noto:seven-oclock"),
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
key: "id",
render: (row) => h(FlowchartLink, { flowchart: row }),
},
{
title: renderTableTitle("题目", "streamline-emojis:blossom"),
key: "problem_title",
render: (row) =>
h(
ButtonWithSearch,
{
type: "题目",
onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem),
},
() => `${row.problem} ${row.problem_title}`,
),
},
{
title: renderTableTitle("评分", "streamline-emojis:bar-chart"),
key: "ai_score",
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
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,
),
},
]
</script>
<template>
<n-flex vertical size="large">
@@ -249,13 +314,6 @@ const columns = computed(() => {
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="语言" v-if="route.name !== 'contest submissions'">
<n-select
class="select"
@@ -263,10 +321,19 @@ const columns = computed(() => {
:options="languageOptions"
/>
</n-form-item>
<n-form-item label="状态">
<n-select
:disabled="query.language === 'Flowchart'"
class="select"
v-model:value="query.result"
:options="resultOptions"
/>
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item>
<n-input
:disabled="query.myself === '1'"
class="input"
clearable
v-model:value="query.username"
@@ -301,11 +368,7 @@ const columns = computed(() => {
<n-form-item
v-if="userStore.isSuperAdmin && route.name === 'submissions'"
>
<IconButton
icon="streamline-emojis:bar-chart"
tip="数据统计"
@click="toggleStatisticPanel(true)"
/>
<n-button @click="toggleStatisticPanel(true)">数据统计</n-button>
</n-form-item>
</n-form>
<n-form
@@ -321,7 +384,18 @@ const columns = computed(() => {
</n-form-item>
</n-form>
</n-space>
<n-data-table :bordered="false" :columns="columns" :data="submissions" />
<n-data-table
v-if="query.language === 'Flowchart'"
:bordered="false"
:columns="flowchartColumns"
:data="flowcharts"
/>
<n-data-table
v-else
:bordered="false"
:columns="columns"
:data="submissions"
/>
</n-flex>
<Pagination
:total="total"
@@ -369,4 +443,11 @@ const columns = computed(() => {
.todayCount {
margin: 0;
}
.flowchart-iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
</style>

View File

@@ -2,10 +2,11 @@
import { Icon } from "@iconify/vue"
import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api"
import { isDesktop } from "shared/composables/breakpoints"
import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions"
import { Profile } from "utils/types"
import { getMetrics } from "../api"
import { Profile, UserBadge as UserBadgeType } from "utils/types"
import { getMetrics, getUserBadges } from "../api"
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
const route = useRoute()
const router = useRouter()
@@ -15,9 +16,46 @@ const firstSubmissionAt = ref("")
const latestSubmissionAt = ref("")
const toLatestAt = ref("")
const learnDuration = ref("")
const userBadges = ref<GroupedBadge[]>([])
const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false)
const { isDesktop } = useBreakpoints()
// 分组徽章接口
interface GroupedBadge {
icon: string
count: number
badges: UserBadgeType[]
latestEarnedTime: Date
}
// 按图标分组徽章
function groupBadgesByIcon(badges: UserBadgeType[]): GroupedBadge[] {
const grouped = new Map<string, UserBadgeType[]>()
// 按图标分组
badges.forEach((badge) => {
const icon = badge.badge.icon
if (!grouped.has(icon)) {
grouped.set(icon, [])
}
grouped.get(icon)!.push(badge)
})
// 转换为数组并排序
return Array.from(grouped.entries())
.map(([icon, badgeList]) => ({
icon,
count: badgeList.length,
badges: badgeList,
latestEarnedTime: new Date(
Math.max(...badgeList.map((b) => new Date(b.earned_time).getTime())),
),
}))
.sort((a, b) => a.icon.localeCompare(b.icon))
}
async function init() {
toggle(true)
try {
@@ -35,8 +73,23 @@ async function init() {
}
ac.sort()
problems.value = ac
const promises: Promise<{ data: any }>[] = []
if (profile.value.submission_number > 0) {
const metricsRes = await getMetrics(profile.value.user.id)
promises.push(getMetrics(profile.value.user.id))
}
if (route.query.name) {
promises.push(getUserBadges(<string>route.query.name))
} else {
promises.push(getUserBadges())
}
const results = await Promise.all(promises)
// 处理 metrics 结果
if (profile.value.submission_number > 0) {
const metricsRes = results[0]
firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest)
toLatestAt.value = durationToDays(
@@ -48,6 +101,9 @@ async function init() {
metricsRes.data.latest,
)
}
// 处理 badges 结果
userBadges.value = groupBadgesByIcon(results[1].data)
} finally {
toggle(false)
}
@@ -131,6 +187,24 @@ onMounted(init)
</n-gi>
</n-grid>
<!-- 徽章展示卡片 -->
<n-card
v-if="!loading && profile && userBadges.length > 0"
class="wrapper"
hoverable
>
<template #header>
<n-h4 style="margin: 0">获得的徽章</n-h4>
</template>
<n-flex wrap>
<GroupedUserBadge
v-for="groupedBadge in userBadges"
:key="groupedBadge.icon"
:grouped-badge="groupedBadge"
/>
</n-flex>
</n-card>
<n-descriptions v-if="!loading && profile" class="wrapper" bordered>
<n-descriptions-item v-if="!!problems.length">
<template #label>
@@ -197,6 +271,8 @@ h2 {
}
.desc {
margin: 0;
margin: 0 auto;
word-wrap: break-word;
max-width: 100%;
}
</style>

Some files were not shown because too many files have changed in this diff Show More