Compare commits
99 Commits
389393b70d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71a903e28b | |||
| 2a14fe138f | |||
| 5fcbe4703b | |||
| 0e9b289f7c | |||
| f80b82e09d | |||
| ef38fa5d97 | |||
| 9888a8124b | |||
| 88b3a041c4 | |||
| 1cb96a1073 | |||
| f0f9c74973 | |||
| eb0122ffc2 | |||
| 91e2f8bf05 | |||
| 082d7843df | |||
| c77851aa97 | |||
| d7ef6abedf | |||
| bd414cfc3f | |||
| 9ff9c7ae54 | |||
| fe2411083f | |||
| d88da0739c | |||
| 37925c1be6 | |||
| b87cd85889 | |||
| d384d15aa6 | |||
| d766433068 | |||
| df24bf7f54 | |||
| 791828b9e1 | |||
| 67dce91dbe | |||
| 0a31cc3d2f | |||
| 42ce9ac63b | |||
| 9e7fbd8ff2 | |||
| ee59039968 | |||
| 9f07fcb0a0 | |||
| 9789b86920 | |||
| 6bc2140052 | |||
| 0bf265b5d9 | |||
| 3770627df8 | |||
| a2094dd659 | |||
| ca2ae54af5 | |||
| 63e58593d7 | |||
| d32af7e6e7 | |||
| 5127ded7d5 | |||
| 07c67d2694 | |||
| 43e0376fba | |||
| d1acd24cd0 | |||
| c6fefba081 | |||
| 41eabef923 | |||
| 943724d613 | |||
| c503745832 | |||
| 0bca7eccec | |||
| 9ecabf9eb9 | |||
| 7c21ce0e2e | |||
| b991688edd | |||
| ab4e12e0e5 | |||
| 04d77a3e9d | |||
| 88b7224b49 | |||
| 34413704c3 | |||
| 6f1720acd5 | |||
| 854b1f0769 | |||
| eda470cb78 | |||
| 41819b6d9b | |||
| 2c31acaba7 | |||
| 2aec0abca2 | |||
| 696f5ae2c9 | |||
| 87d2b828a1 | |||
| 4335ffb1e1 | |||
| f06b7313b0 | |||
| 1103789cd5 | |||
| 4a67717f42 | |||
| ead086c5ac | |||
| 234f149ee5 | |||
| 0651d7f9fb | |||
| 6c23634bb7 | |||
| a08f4a1935 | |||
| 6ee8fe02ba | |||
| 70306a8ef2 | |||
| d3694a8d28 | |||
| d7316d4681 | |||
| 33aca892f4 | |||
| df4bab8bd1 | |||
| cdaf85b89e | |||
| 5cec55acd6 | |||
| 76f8a03177 | |||
| b65bb3d53c | |||
| 0725e222dc | |||
| f45d07dbe7 | |||
| bf745be372 | |||
| 010beaaafb | |||
| 232e2d75e1 | |||
| ffcb436a82 | |||
| 93d4837c6d | |||
| 9e820f57f4 | |||
| 021e53ee59 | |||
| b14316b919 | |||
| b8c622dde1 | |||
| 6151f002bd | |||
| 1e0becb1f8 | |||
| 657047793d | |||
| 87e9c4120e | |||
| fa5cd00ab4 | |||
| 7938e02421 |
3
.env
@@ -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_OJ_URL=http://localhost:8000
|
||||||
PUBLIC_CODE_URL=http://localhost:3000
|
PUBLIC_CODE_URL=http://localhost:3000
|
||||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||||
|
PUBLIC_WS_URL=ws://localhost:8001/ws
|
||||||
@@ -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_OJ_URL=https://oj.xuyue.cc
|
||||||
PUBLIC_CODE_URL=https://code.xuyue.cc
|
PUBLIC_CODE_URL=https://code.xuyue.cc
|
||||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
||||||
|
PUBLIC_WS_URL=wss://oj.xuyue.cc/ws
|
||||||
@@ -4,4 +4,5 @@ PUBLIC_OJ_URL=http://10.13.114.114:81
|
|||||||
PUBLIC_CODE_URL=http://10.13.114.114:82
|
PUBLIC_CODE_URL=http://10.13.114.114:82
|
||||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||||
|
PUBLIC_WS_URL=ws://10.13.114.114:81/ws
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
PUBLIC_ENV=test
|
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_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_CODE_URL=http://10.13.114.114:82
|
||||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||||
|
PUBLIC_WS_URL=ws://10.13.114.114:93/ws
|
||||||
127
README.md
@@ -1 +1,126 @@
|
|||||||
OJ next
|
# OJ Next
|
||||||
|
|
||||||
|
基于 Vue 3 + TypeScript 的在线判题系统前端项目。
|
||||||
|
|
||||||
|
## 项目特性
|
||||||
|
|
||||||
|
- 🎯 **现代化技术栈** - Vue 3 + TypeScript + Vite
|
||||||
|
- 📊 **数据可视化** - 丰富的图表和统计功能
|
||||||
|
- 🎨 **流程图编辑器** - 内置 FlowchartEditor 组件
|
||||||
|
- 📱 **响应式设计** - 支持多端适配
|
||||||
|
- ⚡ **高性能** - 优化的组件和状态管理
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
### FlowchartEditor 流程图编辑器
|
||||||
|
|
||||||
|
一个功能完整的流程图编辑器组件,支持:
|
||||||
|
|
||||||
|
- 🎯 **拖拽创建节点** - 从工具栏拖拽节点到画布
|
||||||
|
- 🔗 **节点连接** - 支持节点间的连线操作
|
||||||
|
- ✏️ **节点编辑** - 双击节点进行文本编辑
|
||||||
|
- 📋 **节点操作** - 复制、删除、批量操作
|
||||||
|
- ↩️ **撤销重做** - 完整的历史记录管理
|
||||||
|
- ⌨️ **键盘快捷键** - Ctrl+Z/Ctrl+Y 撤销重做,Delete 删除
|
||||||
|
- 💾 **自动保存** - 本地缓存,防止数据丢失
|
||||||
|
- 🎨 **多种节点类型** - 开始、输入、处理、判断、循环、输出、结束
|
||||||
|
- 🖱️ **交互优化** - 流畅的拖拽体验和视觉反馈
|
||||||
|
|
||||||
|
**核心特性**:
|
||||||
|
- 基于 Vue Flow 构建,性能优异
|
||||||
|
- 模块化设计,易于扩展
|
||||||
|
- 响应式布局,支持多端适配
|
||||||
|
- 完整的 TypeScript 支持
|
||||||
|
|
||||||
|
详细文档:[FlowchartEditor 文档](./docs/FlowchartEditor.md)
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **Vue 3** - 渐进式 JavaScript 框架
|
||||||
|
- **TypeScript** - JavaScript 的超集
|
||||||
|
- **Rsbuild** - 基于 Rspack 的构建工具
|
||||||
|
- **Vue Flow** - 流程图组件库
|
||||||
|
- **Composition API** - Vue 3 组合式 API
|
||||||
|
- **Naive UI** - Vue 3 组件库
|
||||||
|
- **Chart.js** - 图表可视化库
|
||||||
|
- **CodeMirror** - 代码编辑器
|
||||||
|
- **Yjs** - 实时协作框架
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── shared/ # 共享组件和工具
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── FlowchartEditor/ # 流程图编辑器
|
||||||
|
│ │ ├── index.vue # 主组件
|
||||||
|
│ │ ├── CustomNode.vue # 自定义节点
|
||||||
|
│ │ ├── Toolbar.vue # 工具栏
|
||||||
|
│ │ ├── NodeHandles.vue # 节点操作
|
||||||
|
│ │ ├── NodeActions.vue # 节点动作
|
||||||
|
│ │ ├── useCache.ts # 缓存管理
|
||||||
|
│ │ ├── useDnD.ts # 拖拽处理
|
||||||
|
│ │ ├── useFlowOperations.ts # 流程操作
|
||||||
|
│ │ ├── useHistory.ts # 历史记录
|
||||||
|
│ │ └── useNodeStyles.ts # 节点样式
|
||||||
|
│ ├── composables/ # 组合式函数
|
||||||
|
│ ├── layout/ # 布局组件
|
||||||
|
│ ├── store/ # 状态管理
|
||||||
|
│ └── themes/ # 主题配置
|
||||||
|
├── oj/ # 主要业务组件
|
||||||
|
│ ├── ai/ # AI分析模块
|
||||||
|
│ ├── problem/ # 题目相关
|
||||||
|
│ ├── contest/ # 竞赛相关
|
||||||
|
│ ├── submission/ # 提交相关
|
||||||
|
│ └── store/ # 业务状态管理
|
||||||
|
├── admin/ # 管理后台组件
|
||||||
|
└── utils/ # 工具函数
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发指南
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
# 或
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建测试环境版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建预发布版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:staging
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [FlowchartEditor 流程图编辑器](./docs/FlowchartEditor.md)
|
||||||
|
- [图表组件说明](./docs/图表.md)
|
||||||
|
- [API 接口对比分析](./docs/API接口对比分析.md)
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -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智能分析模块的文档说明
|
- ✨ **AI分析功能增强**:完善了AI智能分析模块的文档说明
|
||||||
- 详细说明了4个AI相关接口的功能和参数
|
- 详细说明了4个AI相关接口的功能和参数
|
||||||
- 新增等级系统说明(S/A/B/C),包含特殊规则
|
- 新增等级系统说明(S/A/B/C),包含特殊规则
|
||||||
@@ -204,7 +220,6 @@
|
|||||||
- `GET /api/admin/versions` - 版本信息
|
- `GET /api/admin/versions` - 版本信息
|
||||||
|
|
||||||
### 6. 题目管理相关(admin)
|
### 6. 题目管理相关(admin)
|
||||||
- `POST /api/admin/compile_spj` - 编译Special Judge
|
|
||||||
- `POST /api/admin/export_problem` - 导出题目
|
- `POST /api/admin/export_problem` - 导出题目
|
||||||
- `POST /api/admin/import_problem` - 导入题目
|
- `POST /api/admin/import_problem` - 导入题目
|
||||||
- `POST /api/admin/import_fps` - 导入FPS格式题目
|
- `POST /api/admin/import_fps` - 导入FPS格式题目
|
||||||
@@ -278,7 +293,6 @@
|
|||||||
- **版本信息**: versions(显示系统版本)
|
- **版本信息**: versions(显示系统版本)
|
||||||
- **FPS导入**: import_fps(特定格式题目导入)
|
- **FPS导入**: import_fps(特定格式题目导入)
|
||||||
- **文件上传**: upload_file(目前只有图片上传)
|
- **文件上传**: upload_file(目前只有图片上传)
|
||||||
- **Special Judge编译**: compile_spj(高级题目功能)
|
|
||||||
|
|
||||||
### 4. 可选功能
|
### 4. 可选功能
|
||||||
- **生成用户**: generate_user(批量生成测试用户)
|
- **生成用户**: 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
@@ -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
|
||||||
257
docs/图表.md
@@ -1,41 +1,216 @@
|
|||||||
📊 可以添加的图表类型
|
# 图表组件说明
|
||||||
1. 提交效率趋势图 (折线图)
|
|
||||||
数据来源: durationData 中的 submission_count / problem_count
|
基于 Chart.js 和 Vue-ChartJS 构建的数据可视化组件库,为 OJ Next 项目提供丰富的图表展示功能。
|
||||||
展示内容: 每个时间段的提交效率(提交次数/完成题目数),值越接近1说明一次AC率越高
|
|
||||||
价值: 反映刷题质量的提升
|
## 技术栈
|
||||||
2. 排名分布图 (直方图/箱线图)
|
|
||||||
数据来源: solved 数组中每道题的 rank 和 ac_count
|
- **Chart.js** - 强大的图表库,支持多种图表类型
|
||||||
展示内容: 用户解题排名的分布情况(如:前10%、10-30%、30-50%等区间的题目数量)
|
- **Vue-ChartJS** - Chart.js 的 Vue 3 封装
|
||||||
价值: 了解解题速度和竞争力
|
- **Vue 3 Composition API** - 现代化的组件开发方式
|
||||||
3. 等级分布饼图/环形图
|
- **TypeScript** - 完整的类型支持
|
||||||
数据来源: solved 数组中每道题的 grade
|
|
||||||
展示内容: S/A/B/C 各等级题目的数量和占比
|
## 现有图表组件
|
||||||
价值: 直观看出题目质量分布
|
|
||||||
4. 标签雷达图
|
### 1. DurationChart 混合图表
|
||||||
数据来源: tags 对象
|
**文件位置**: `src/oj/ai/components/DurationChart.vue`
|
||||||
展示内容: 多维度展示各类标签的掌握程度(可以归一化处理)
|
|
||||||
价值: 可视化知识点覆盖面
|
**功能描述**: 展示用户学习进度的时间趋势,结合柱状图和折线图。
|
||||||
5. 时间活跃度分析 (热力矩阵)
|
|
||||||
数据来源: solved 数组中的 ac_time
|
**数据来源**: `durationData` API 接口
|
||||||
展示内容: 按星期几和时间段统计做题分布(如:工作日vs周末,早中晚时段)
|
- 每周/每月的综合情况
|
||||||
价值: 了解学习习惯和时间规律
|
- 题目数、提交数、等级变化
|
||||||
6. 难度-等级关联散点图
|
|
||||||
数据来源: solved 数组中的难度信息和 grade
|
**图表类型**:
|
||||||
展示内容: X轴为难度,Y轴为等级,每个点代表一道题
|
- 柱状图:完成题目数
|
||||||
价值: 分析在不同难度下的表现
|
- 折线图:提交次数、等级变化
|
||||||
7. 做题加速度图
|
|
||||||
数据来源: durationData
|
**使用场景**: AI分析页面的主要图表
|
||||||
展示内容: 每个时间段完成题目数的变化率
|
|
||||||
价值: 看出学习动力的变化趋势
|
### 2. Heatmap 热力图
|
||||||
8. 竞赛题目占比
|
**文件位置**: `src/oj/ai/components/Heatmap.vue`
|
||||||
数据来源: solved 数组中的 contest_id 和 contest_count
|
|
||||||
展示内容: 竞赛题 vs 常规题的数量对比
|
**功能描述**: 展示用户的提交活跃度分布。
|
||||||
价值: 了解竞赛参与情况
|
|
||||||
9. 连续做题天数统计
|
**数据来源**: `heatmapData` API 接口
|
||||||
数据来源: heatmapData
|
- 按日期统计提交数
|
||||||
展示内容: 最长连续做题天数、当前连续天数等
|
- 可视化用户活跃度
|
||||||
价值: 激励持续学习
|
|
||||||
10. 月度对比雷达图
|
**图表类型**: 热力图矩阵
|
||||||
数据来源: durationData
|
|
||||||
展示内容: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
|
**使用场景**: 展示学习习惯和时间规律
|
||||||
价值: 全面评估进步情况
|
|
||||||
|
### 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
46
package.json
@@ -12,39 +12,47 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@vueuse/core": "^13.9.0",
|
"@vue-flow/background": "^1.3.2",
|
||||||
"@vueuse/router": "^13.9.0",
|
"@vue-flow/controls": "^1.1.3",
|
||||||
"@wangeditor-next/editor": "^5.6.46",
|
"@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",
|
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.13.2",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.4",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"copy-text-to-clipboard": "^3.2.2",
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"md-editor-v3": "^6.0.1",
|
"md-editor-v3": "^6.2.1",
|
||||||
"naive-ui": "^2.43.1",
|
"mermaid": "^11.12.2",
|
||||||
|
"naive-ui": "^2.43.2",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.26",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-codemirror": "^6.1.1",
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.6.4",
|
||||||
"y-codemirror.next": "^0.3.5",
|
"y-codemirror.next": "^0.3.5",
|
||||||
"y-webrtc": "^10.3.0",
|
"y-webrtc": "^10.3.0",
|
||||||
"yjs": "^13.6.27"
|
"yjs": "^13.6.28"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.0",
|
"@iconify/vue": "^5.0.0",
|
||||||
"@rsbuild/core": "^1.5.13",
|
"@rsbuild/core": "^1.6.15",
|
||||||
"@rsbuild/plugin-vue": "^1.1.2",
|
"@rsbuild/plugin-vue": "^1.2.2",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^24.6.2",
|
"@types/node": "^25.0.3",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"unplugin-auto-import": "^20.2.0",
|
"unplugin-auto-import": "^20.3.0",
|
||||||
"unplugin-vue-components": "^29.1.0"
|
"unplugin-vue-components": "^30.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="28.45" height="32" viewBox="0 0 256 288">
|
|
||||||
<path fill="#649AD2"
|
|
||||||
d="M255.987 84.59c-.002-4.837-1.037-9.112-3.13-12.781c-2.054-3.608-5.133-6.632-9.261-9.023c-34.08-19.651-68.195-39.242-102.264-58.913c-9.185-5.303-18.09-5.11-27.208.27c-13.565 8-81.48 46.91-101.719 58.632C4.071 67.6.015 74.984.013 84.58C0 124.101.013 163.62 0 203.141c0 4.73.993 8.923 2.993 12.537c2.056 3.717 5.177 6.824 9.401 9.269c20.24 11.722 88.164 50.63 101.726 58.631c9.121 5.382 18.027 5.575 27.215.27c34.07-19.672 68.186-39.262 102.272-58.913c4.224-2.444 7.345-5.553 9.401-9.267c1.997-3.614 2.992-7.806 2.992-12.539c0 0 0-79.018-.013-118.539" />
|
|
||||||
<path fill="#004482"
|
|
||||||
d="m128.392 143.476l-125.4 72.202c2.057 3.717 5.178 6.824 9.402 9.269c20.24 11.722 88.164 50.63 101.726 58.631c9.121 5.382 18.027 5.575 27.215.27c34.07-19.672 68.186-39.262 102.272-58.913c4.224-2.444 7.345-5.553 9.401-9.267l-124.616-72.192" />
|
|
||||||
<path fill="#1A4674"
|
|
||||||
d="M91.25 164.863c7.297 12.738 21.014 21.33 36.75 21.33c15.833 0 29.628-8.7 36.888-21.576l-36.496-21.141l-37.142 21.387" />
|
|
||||||
<path fill="#01589C"
|
|
||||||
d="M255.987 84.59c-.002-4.837-1.037-9.112-3.13-12.781l-124.465 71.667l124.616 72.192c1.997-3.614 2.99-7.806 2.992-12.539c0 0 0-79.018-.013-118.539" />
|
|
||||||
<path fill="#FFF"
|
|
||||||
d="M249.135 148.636h-9.738v9.74h-9.74v-9.74h-9.737V138.9h9.737v-9.738h9.74v9.738h9.738v9.737ZM128 58.847c31.135 0 58.358 16.74 73.17 41.709l.444.759l-37.001 21.307c-7.333-12.609-20.978-21.094-36.613-21.094c-23.38 0-42.333 18.953-42.333 42.332a42.13 42.13 0 0 0 5.583 21.003c7.297 12.738 21.014 21.33 36.75 21.33c15.659 0 29.325-8.51 36.647-21.153l.241-.423l36.947 21.406c-14.65 25.597-42.228 42.851-73.835 42.851c-31.549 0-59.084-17.185-73.754-42.707c-7.162-12.459-11.26-26.904-11.26-42.307c0-46.95 38.061-85.013 85.014-85.013Zm75.865 70.314v9.738h9.737v9.737h-9.737v9.74h-9.738v-9.74h-9.738V138.9h9.738v-9.738h9.738Z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="28.45" height="32" viewBox="0 0 256 288">
|
|
||||||
<path fill="#A9B9CB"
|
|
||||||
d="M255.987 85.672c-.002-4.843-1.037-9.122-3.129-12.794c-2.055-3.612-5.134-6.638-9.262-9.032c-34.081-19.67-68.195-39.28-102.264-58.97c-9.185-5.307-18.091-5.114-27.208.27c-13.565 8.008-81.481 46.956-101.719 58.689C4.071 68.665.015 76.056.013 85.663C0 125.221.013 164.777 0 204.336c.002 4.736.993 8.932 2.993 12.55c2.056 3.72 5.177 6.83 9.401 9.278c20.239 11.733 88.164 50.678 101.726 58.688c9.121 5.387 18.027 5.579 27.215.27c34.07-19.691 68.186-39.3 102.272-58.97c4.224-2.447 7.345-5.559 9.401-9.276c1.997-3.618 2.99-7.814 2.992-12.551c0 0 0-79.094-.013-118.653" />
|
|
||||||
<path fill="#7F8B99"
|
|
||||||
d="M141.101 5.134c-9.17-5.294-18.061-5.101-27.163.269C100.395 13.39 32.59 52.237 12.385 63.94C4.064 68.757.015 76.129.013 85.711C0 125.166.013 164.62 0 204.076c.002 4.724.991 8.909 2.988 12.517c2.053 3.711 5.169 6.813 9.386 9.254a9008.51 9008.51 0 0 0 20.159 11.62L219.625 50.375c-26.178-15.074-52.363-30.136-78.524-45.241" />
|
|
||||||
<path fill="#FFF"
|
|
||||||
d="m154.456 126.968l39.839.281c0-16.599-16.802-57.249-64.973-57.249c-30.691 0-71.951 19.512-71.951 75.61c0 56.097 40.447 74.39 71.951 74.39c51.017 0 63.21-35.302 63.21-55.252l-38.007-2.173s1.017 23.075-25.406 23.075c-24.39 0-28.46-29.878-28.46-40.04c0-15.447 5.493-40.244 28.46-40.244c22.968 0 25.337 21.602 25.337 21.602" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="85.34" height="32" viewBox="0 0 512 192">
|
|
||||||
<path fill="#00ACD7"
|
|
||||||
d="m292.533 13.295l1.124.75c13.212 8.725 22.685 20.691 28.917 35.15c1.496 2.243.499 3.49-2.493 4.237l-5.063 1.296c-11.447 2.949-20.53 5.429-31.827 8.378l-6.443 1.678c-2.32.574-2.96.333-5.428-2.477l-.348-.399c-3.519-3.988-6.155-6.652-10.817-9.03l-.899-.443c-15.705-7.727-30.911-5.484-45.12 3.74c-16.952 10.968-25.677 27.172-25.428 47.364c.25 19.942 13.96 36.395 33.654 39.137c16.951 2.244 31.16-3.739 42.378-16.452c2.244-2.743 4.238-5.734 6.73-9.224h-48.11c-5.235 0-6.481-3.24-4.736-7.478l.864-2.035c3.204-7.454 8.173-18.168 11.4-24.294l.704-1.319c.862-1.494 2.612-3.513 5.977-3.513h80.224c3.603-11.415 9.449-22.201 17.246-32.407c18.198-23.931 40.135-36.396 69.8-41.63c25.427-4.488 49.359-1.995 71.046 12.713c19.694 13.461 31.909 31.66 35.15 55.59c4.237 33.654-5.485 61.075-28.668 84.508c-16.453 16.702-36.645 27.172-59.829 31.908c-6.73 1.247-13.461 1.496-19.942 2.244c-22.685-.499-43.376-6.98-60.826-21.937c-12.273-10.61-20.727-23.648-24.928-38.828a104.937 104.937 0 0 1-10.47 16.89c-17.949 23.683-41.381 38.39-71.046 42.38c-24.43 3.24-47.115-1.497-67.058-16.454c-18.447-13.96-28.917-32.407-31.66-55.34c-3.24-27.173 4.737-51.603 21.19-73.041c17.7-23.184 41.132-37.891 69.8-43.126c22.999-4.16 45.037-1.595 64.936 11.464ZM411.12 49.017l-.798.178c-23.183 5.235-38.14 19.942-43.624 43.375c-4.488 19.444 4.985 39.138 22.934 47.115c13.71 5.983 27.421 5.235 40.633-1.496c19.694-10.22 30.413-26.175 31.66-47.613c-.25-3.24-.25-5.734-.749-8.227c-4.436-24.401-26.664-38.324-50.056-33.332ZM116.416 94.564c.997 0 1.496.748 1.496 1.745l-.499 5.983c0 .997-.997 1.745-1.745 1.745l-54.344-.249c-.997 0-1.246-.748-.748-1.496l3.49-6.232c.499-.748 1.496-1.496 2.493-1.496h49.857ZM121.9 71.63c.997 0 1.496.748 1.247 1.496l-1.995 5.983c-.249.997-1.246 1.495-2.243 1.495l-117.912.25c-.997 0-1.246-.499-.748-1.247l5.235-6.73c.499-.748 1.745-1.247 2.742-1.247H121.9Zm12.963-22.934c.997 0 1.246.748.748 1.496l-4.238 6.481c-.499.748-1.745 1.496-2.493 1.496l-90.24-.25c-.998 0-1.247-.498-.749-1.246l5.235-6.73c.499-.748 1.745-1.247 2.742-1.247h88.995Z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="23.68" height="32" viewBox="0 0 256 346">
|
|
||||||
<path fill="#5382A1"
|
|
||||||
d="M82.554 267.473s-13.198 7.675 9.393 10.272c27.369 3.122 41.356 2.675 71.517-3.034c0 0 7.93 4.972 19.003 9.279c-67.611 28.977-153.019-1.679-99.913-16.517m-8.262-37.814s-14.803 10.958 7.805 13.296c29.236 3.016 52.324 3.263 92.276-4.43c0 0 5.526 5.602 14.215 8.666c-81.747 23.904-172.798 1.885-114.296-17.532" />
|
|
||||||
<path fill="#E76F00"
|
|
||||||
d="M143.942 165.515c16.66 19.18-4.377 36.44-4.377 36.44s42.301-21.837 22.874-49.183c-18.144-25.5-32.059-38.172 43.268-81.858c0 0-118.238 29.53-61.765 94.6" />
|
|
||||||
<path fill="#5382A1"
|
|
||||||
d="M233.364 295.442s9.767 8.047-10.757 14.273c-39.026 11.823-162.432 15.393-196.714.471c-12.323-5.36 10.787-12.8 18.056-14.362c7.581-1.644 11.914-1.337 11.914-1.337c-13.705-9.655-88.583 18.957-38.034 27.15c137.853 22.356 251.292-10.066 215.535-26.195M88.9 190.48s-62.771 14.91-22.228 20.323c17.118 2.292 51.243 1.774 83.03-.89c25.978-2.19 52.063-6.85 52.063-6.85s-9.16 3.923-15.787 8.448c-63.744 16.765-186.886 8.966-151.435-8.183c29.981-14.492 54.358-12.848 54.358-12.848m112.605 62.942c64.8-33.672 34.839-66.03 13.927-61.67c-5.126 1.066-7.411 1.99-7.411 1.99s1.903-2.98 5.537-4.27c41.37-14.545 73.187 42.897-13.355 65.647c0 .001 1.003-.895 1.302-1.697" />
|
|
||||||
<path fill="#E76F00"
|
|
||||||
d="M162.439.371s35.887 35.9-34.037 91.101c-56.071 44.282-12.786 69.53-.023 98.377c-32.73-29.53-56.75-55.526-40.635-79.72C111.395 74.612 176.918 57.393 162.439.37" />
|
|
||||||
<path fill="#5382A1"
|
|
||||||
d="M95.268 344.665c62.199 3.982 157.712-2.209 159.974-31.64c0 0-4.348 11.158-51.404 20.018c-53.088 9.99-118.564 8.824-157.399 2.421c.001 0 7.95 6.58 48.83 9.201" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256">
|
|
||||||
<path fill="#F7DF1E" d="M0 0h256v256H0V0Z" />
|
|
||||||
<path
|
|
||||||
d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 868 B |
@@ -1,16 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32.13" height="32" viewBox="0 0 256 255">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="svgIDa" x1="12.959%" x2="79.639%" y1="12.039%" y2="78.201%">
|
|
||||||
<stop offset="0%" stop-color="#387EB8" />
|
|
||||||
<stop offset="100%" stop-color="#366994" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="svgIDb" x1="19.128%" x2="90.742%" y1="20.579%" y2="88.429%">
|
|
||||||
<stop offset="0%" stop-color="#FFE052" />
|
|
||||||
<stop offset="100%" stop-color="#FFC331" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path fill="url(#svgIDa)"
|
|
||||||
d="M126.916.072c-64.832 0-60.784 28.115-60.784 28.115l.072 29.128h61.868v8.745H41.631S.145 61.355.145 126.77c0 65.417 36.21 63.097 36.21 63.097h21.61v-30.356s-1.165-36.21 35.632-36.21h61.362s34.475.557 34.475-33.319V33.97S194.67.072 126.916.072ZM92.802 19.66a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.13Z" />
|
|
||||||
<path fill="url(#svgIDb)"
|
|
||||||
d="M128.757 254.126c64.832 0 60.784-28.115 60.784-28.115l-.072-29.127H127.6v-8.745h86.441s41.486 4.705 41.486-60.712c0-65.416-36.21-63.096-36.21-63.096h-21.61v30.355s1.165 36.21-35.632 36.21h-61.362s-34.475-.557-34.475 33.32v56.013s-5.235 33.897 62.518 33.897Zm34.114-19.586a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.131a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13Z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,16 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32.13" height="32" viewBox="0 0 256 255">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="svgIDa" x1="12.959%" x2="79.639%" y1="12.039%" y2="78.201%">
|
|
||||||
<stop offset="0%" stop-color="#387EB8" />
|
|
||||||
<stop offset="100%" stop-color="#366994" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="svgIDb" x1="19.128%" x2="90.742%" y1="20.579%" y2="88.429%">
|
|
||||||
<stop offset="0%" stop-color="#FFE052" />
|
|
||||||
<stop offset="100%" stop-color="#FFC331" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path fill="url(#svgIDa)"
|
|
||||||
d="M126.916.072c-64.832 0-60.784 28.115-60.784 28.115l.072 29.128h61.868v8.745H41.631S.145 61.355.145 126.77c0 65.417 36.21 63.097 36.21 63.097h21.61v-30.356s-1.165-36.21 35.632-36.21h61.362s34.475.557 34.475-33.319V33.97S194.67.072 126.916.072ZM92.802 19.66a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.13Z" />
|
|
||||||
<path fill="url(#svgIDb)"
|
|
||||||
d="M128.757 254.126c64.832 0 60.784-28.115 60.784-28.115l-.072-29.127H127.6v-8.745h86.441s41.486 4.705 41.486-60.712c0-65.416-36.21-63.096-36.21-63.096h-21.61v30.355s1.165 36.21-35.632 36.21h-61.362s-34.475-.557-34.475 33.32v56.013s-5.235 33.897 62.518 33.897Zm34.114-19.586a11.12 11.12 0 0 1-11.13-11.13a11.12 11.12 0 0 1 11.13-11.131a11.12 11.12 0 0 1 11.13 11.13a11.12 11.12 0 0 1-11.13 11.13Z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
public/badge-1.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/badge-2.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/badge-3.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/badge-4.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/badge-5.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/badge-6.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
329
public/flowchart-data.html
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Python流程图作业 - 学情分析看板</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2.1.0/dist/echarts-wordcloud.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #f0f2f5;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--primary: #1890ff;
|
||||||
|
--text-main: #333;
|
||||||
|
--text-secondary: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header h1 { color: var(--text-main); margin: 0; }
|
||||||
|
.header p { color: var(--text-secondary); margin-top: 5px; }
|
||||||
|
|
||||||
|
/* 顶部概览卡片 */
|
||||||
|
.overview-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
flex: 1;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-5px); }
|
||||||
|
|
||||||
|
.stat-title { font-size: 14px; color: var(--text-secondary); }
|
||||||
|
.stat-value { font-size: 28px; font-weight: bold; color: var(--text-main); margin-top: 10px; }
|
||||||
|
.stat-sub { font-size: 12px; color: #52c41a; margin-top: 5px; }
|
||||||
|
|
||||||
|
/* 图表布局 */
|
||||||
|
.charts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-box {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 学生列表 */
|
||||||
|
.student-list {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
|
||||||
|
th { background-color: #fafafa; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.tag-S { background: #fff7e6; color: #fa8c16; }
|
||||||
|
.tag-A { background: #e6f7ff; color: #1890ff; }
|
||||||
|
.tag-B { background: #f6ffed; color: #52c41a; }
|
||||||
|
.tag-C { background: #fff1f0; color: #f5222d; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="overview-container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-title">班级平均分</div>
|
||||||
|
<div class="stat-value" id="avgScore">0</div>
|
||||||
|
<div class="stat-sub">↑ 比上周For循环 +2.5分</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-title">S+A 级别(卓越+优秀)人数</div>
|
||||||
|
<div class="stat-value" id="countA">0</div>
|
||||||
|
<div class="stat-sub">占比 35.7%</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-title">未掌握核心难点</div>
|
||||||
|
<div class="stat-value" style="color: #f5222d; font-size: 24px;">循环条件</div>
|
||||||
|
<div class="stat-sub">需重点讲解 a<100 边界</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="charts-grid">
|
||||||
|
<div class="chart-box">
|
||||||
|
<div class="chart-title">作业评级分布</div>
|
||||||
|
<div id="pieChart" style="width: 100%; height: 340px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-box">
|
||||||
|
<div class="chart-title">薄弱知识点词云 (AI分析)</div>
|
||||||
|
<div id="wordCloud" style="width: 100%; height: 340px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="charts-grid">
|
||||||
|
<div class="chart-box" style="grid-column: span 2;">
|
||||||
|
<div class="chart-title">全班分数段统计</div>
|
||||||
|
<div id="barChart" style="width: 100%; height: 340px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- 1. 模拟数据生成 (40位同学) ---
|
||||||
|
const totalStudents = 40;
|
||||||
|
const students = [];
|
||||||
|
const familyNames = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨";
|
||||||
|
|
||||||
|
// 精确分配等级人数:S 10% (4人), A 15% (6人), B 50% (20人), C 15% (6人), D 10% (4人)
|
||||||
|
const gradeDistribution = {
|
||||||
|
S: 4, // 10% = 4人
|
||||||
|
A: 6, // 15% = 6人
|
||||||
|
B: 20, // 50% = 20人
|
||||||
|
C: 6, // 15% = 6人
|
||||||
|
D: 4 // 10% = 4人
|
||||||
|
};
|
||||||
|
|
||||||
|
let gradeCounts = { S: 0, A: 0, B: 0, C: 0, D: 0 };
|
||||||
|
let totalScore = 0;
|
||||||
|
|
||||||
|
// 创建等级数组,确保精确分配
|
||||||
|
let levelQueue = [];
|
||||||
|
for (let level in gradeDistribution) {
|
||||||
|
for (let i = 0; i < gradeDistribution[level]; i++) {
|
||||||
|
levelQueue.push(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 打乱顺序
|
||||||
|
for (let i = levelQueue.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[levelQueue[i], levelQueue[j]] = [levelQueue[j], levelQueue[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= totalStudents; i++) {
|
||||||
|
let score;
|
||||||
|
let level = levelQueue[i - 1];
|
||||||
|
|
||||||
|
// 根据等级生成对应分数范围
|
||||||
|
if (level === 'S') {
|
||||||
|
// S级:95-100
|
||||||
|
score = Math.floor(Math.random() * (100 - 95 + 1) + 95);
|
||||||
|
} else if (level === 'A') {
|
||||||
|
// A级:85-94
|
||||||
|
score = Math.floor(Math.random() * (95 - 85) + 85);
|
||||||
|
} else if (level === 'B') {
|
||||||
|
// B级:70-84
|
||||||
|
score = Math.floor(Math.random() * (85 - 70) + 70);
|
||||||
|
} else if (level === 'C') {
|
||||||
|
// C级:60-69
|
||||||
|
score = Math.floor(Math.random() * (70 - 60) + 60);
|
||||||
|
} else {
|
||||||
|
// D级:50-59
|
||||||
|
score = Math.floor(Math.random() * (60 - 50) + 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
gradeCounts[level]++;
|
||||||
|
totalScore += score;
|
||||||
|
|
||||||
|
let comment = "";
|
||||||
|
if (level === 'S') comment = "完美!逻辑清晰,变量初始化正确,闭环完美,代码规范。";
|
||||||
|
else if (level === 'A') comment = "逻辑清晰,变量初始化正确,闭环完美。";
|
||||||
|
else if (level === 'B') comment = "整体逻辑正确,但部分连线方向有误。";
|
||||||
|
else if (level === 'C') comment = "循环条件判断错误,导致死循环或无法进入。";
|
||||||
|
else comment = "基础概念理解不足,需要重新学习。";
|
||||||
|
|
||||||
|
students.push({
|
||||||
|
id: 2025000 + i,
|
||||||
|
name: familyNames[i % familyNames.length] + "同学",
|
||||||
|
score: score,
|
||||||
|
level: level,
|
||||||
|
comment: comment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. 填充顶部数据 ---
|
||||||
|
document.getElementById('avgScore').innerText = (totalScore / totalStudents).toFixed(1);
|
||||||
|
const excellentCount = gradeCounts.S + gradeCounts.A; // S级和A级合计
|
||||||
|
document.getElementById('countA').innerText = excellentCount;
|
||||||
|
// 更新占比显示
|
||||||
|
const excellentPercent = ((excellentCount / totalStudents) * 100).toFixed(1);
|
||||||
|
const countACard = document.getElementById('countA').parentElement;
|
||||||
|
countACard.querySelector('.stat-sub').innerText = `占比 ${excellentPercent}%`;
|
||||||
|
|
||||||
|
// --- 3. 初始化图表 ---
|
||||||
|
|
||||||
|
// A. 饼图 - 等级分布
|
||||||
|
const pieChart = echarts.init(document.getElementById('pieChart'));
|
||||||
|
pieChart.setOption({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { top: '5%', left: 'center' },
|
||||||
|
color: ['#fa8c16', '#1890ff', '#52c41a', '#faad14', '#f5222d'],
|
||||||
|
series: [{
|
||||||
|
name: '评级占比',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
|
||||||
|
label: { show: false, position: 'center' },
|
||||||
|
emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } },
|
||||||
|
data: [
|
||||||
|
{ value: gradeCounts.S, name: 'S级 (卓越)' },
|
||||||
|
{ value: gradeCounts.A, name: 'A级 (优秀)' },
|
||||||
|
{ value: gradeCounts.B, name: 'B级 (良好)' },
|
||||||
|
{ value: gradeCounts.C, name: 'C级 (待改进)' }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// B. 词云图 - 知识点掌握情况
|
||||||
|
// 这里重点突出“循环条件”
|
||||||
|
const wordChart = echarts.init(document.getElementById('wordCloud'));
|
||||||
|
wordChart.setOption({
|
||||||
|
tooltip: {},
|
||||||
|
series: [{
|
||||||
|
type: 'wordCloud',
|
||||||
|
gridSize: 2,
|
||||||
|
sizeRange: [12, 60], // 字体大小范围
|
||||||
|
rotationRange: [-45, 45],
|
||||||
|
shape: 'circle',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
textStyle: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: function () {
|
||||||
|
return 'rgb(' + [
|
||||||
|
Math.round(Math.random() * 160),
|
||||||
|
Math.round(Math.random() * 160),
|
||||||
|
Math.round(Math.random() * 160)
|
||||||
|
].join(',') + ')';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{ name: '循环条件', value: 150, textStyle: { color: 'red' } }, // 核心痛点
|
||||||
|
{ name: '变量初始化', value: 80 },
|
||||||
|
{ name: 'i=i+1', value: 70 },
|
||||||
|
{ name: 'a<100', value: 65 },
|
||||||
|
{ name: '死循环', value: 60 },
|
||||||
|
{ name: '连线方向', value: 50 },
|
||||||
|
{ name: '退出逻辑', value: 45 },
|
||||||
|
{ name: 'While语法', value: 40 },
|
||||||
|
{ name: 'Print缩进', value: 35 },
|
||||||
|
{ name: 'Yes/No分支', value: 30 },
|
||||||
|
{ name: '流程结束符', value: 25 },
|
||||||
|
{ name: '变量定义', value: 20 }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// C. 柱状图 - 分数段
|
||||||
|
const barChart = echarts.init(document.getElementById('barChart'));
|
||||||
|
// 简单的分段统计
|
||||||
|
let ranges = { '90-100': 0, '80-89': 0, '70-79': 0, '60-69': 0, '<60': 0 };
|
||||||
|
students.forEach(s => {
|
||||||
|
if (s.score >= 90) ranges['90-100']++;
|
||||||
|
else if (s.score >= 80) ranges['80-89']++;
|
||||||
|
else if (s.score >= 70) ranges['70-79']++;
|
||||||
|
else if (s.score >= 60) ranges['60-69']++;
|
||||||
|
else ranges['<60']++;
|
||||||
|
});
|
||||||
|
|
||||||
|
barChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: { type: 'category', data: Object.keys(ranges) },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [{
|
||||||
|
name: '人数',
|
||||||
|
type: 'bar',
|
||||||
|
barWidth: '50%',
|
||||||
|
data: Object.values(ranges),
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#83bff6' },
|
||||||
|
{ offset: 0.5, color: '#188df0' },
|
||||||
|
{ offset: 1, color: '#188df0' }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 窗口缩放适配
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
pieChart.resize();
|
||||||
|
wordChart.resize();
|
||||||
|
barChart.resize();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,19 +10,15 @@ export default defineConfig(({ envMode }) => {
|
|||||||
mode: envMode,
|
mode: envMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = rawPublicVars["PUBLIC_OJ_URL"]
|
|
||||||
const proxyConfig = {
|
const proxyConfig = {
|
||||||
target: url,
|
target: rawPublicVars["PUBLIC_OJ_URL"],
|
||||||
headers: { Referer: url },
|
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket 代理配置(开发环境 Daphne 在 8001 端口)
|
|
||||||
const wsProxyConfig = {
|
const wsProxyConfig = {
|
||||||
target: url.replace(':8000', ':8001').replace('http:', 'ws:'), // http://localhost:8001 → ws://localhost:8001
|
target: rawPublicVars["PUBLIC_WS_URL"],
|
||||||
ws: true, // 启用 WebSocket 代理
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
logLevel: 'debug' as const, // 显示代理日志
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
plugins: [pluginVue()],
|
plugins: [pluginVue()],
|
||||||
@@ -80,61 +76,7 @@ export default defineConfig(({ envMode }) => {
|
|||||||
performance: {
|
performance: {
|
||||||
chunkSplit: {
|
chunkSplit: {
|
||||||
strategy: "split-by-module",
|
strategy: "split-by-module",
|
||||||
override: {
|
|
||||||
cacheGroups: {
|
|
||||||
// ===== 核心框架层 (100+) =====
|
|
||||||
// Vue 生态 - 框架基础,最高优先级
|
|
||||||
vue: {
|
|
||||||
test: /[\\/]node_modules[\\/](vue|vue-router|pinia|@vue|@vueuse)[\\/]/,
|
|
||||||
name: "vendor-vue",
|
|
||||||
priority: 100,
|
|
||||||
},
|
|
||||||
// ===== UI 层 (90+) =====
|
|
||||||
// Naive UI 及其依赖 - 核心 UI 框架
|
|
||||||
ui: {
|
|
||||||
test: /[\\/]node_modules[\\/](naive-ui|@css-render|css-render|seemly|vooks|vueuc|treemate|vdirs|evtd)[\\/]/,
|
|
||||||
name: "vendor-ui",
|
|
||||||
priority: 90,
|
|
||||||
},
|
|
||||||
// ===== 编辑器层 (70-80) =====
|
|
||||||
// CodeMirror - 代码编辑器(使用最频繁)
|
|
||||||
editor: {
|
|
||||||
test: /[\\/]node_modules[\\/](codemirror|@codemirror|vue-codemirror|y-codemirror\.next)[\\/]/,
|
|
||||||
name: "vendor-editor",
|
|
||||||
priority: 80,
|
|
||||||
},
|
|
||||||
// Markdown 编辑器
|
|
||||||
mdeditor: {
|
|
||||||
test: /[\\/]node_modules[\\/]md-editor-v3[\\/]/,
|
|
||||||
name: "vendor-mdeditor",
|
|
||||||
priority: 75,
|
|
||||||
},
|
|
||||||
// WangEditor - 富文本编辑器
|
|
||||||
wangeditor: {
|
|
||||||
test: /[\\/]node_modules[\\/]@wangeditor-next[\\/]/,
|
|
||||||
name: "vendor-wangeditor",
|
|
||||||
priority: 70,
|
|
||||||
},
|
|
||||||
// ===== 功能库层 (50-60) =====
|
|
||||||
// Chart.js - 图表库(按需加载)
|
|
||||||
charts: {
|
|
||||||
test: /[\\/]node_modules[\\/](chart\.js|vue-chartjs|@kurkle|canvas-confetti)[\\/]/,
|
|
||||||
name: "vendor-charts",
|
|
||||||
priority: 60,
|
|
||||||
},
|
|
||||||
// ===== 通用层 (10) =====
|
|
||||||
// 其他常用库 - 兜底分组
|
|
||||||
common: {
|
|
||||||
test: /[\\/]node_modules[\\/]/,
|
|
||||||
name: "vendor-common",
|
|
||||||
priority: 10,
|
|
||||||
minChunks: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// 移除 console.log(生产环境)
|
|
||||||
removeConsole: ["log"],
|
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -149,7 +91,7 @@ export default defineConfig(({ envMode }) => {
|
|||||||
proxy: {
|
proxy: {
|
||||||
"/api": proxyConfig,
|
"/api": proxyConfig,
|
||||||
"/public": proxyConfig,
|
"/public": proxyConfig,
|
||||||
"/ws": wsProxyConfig, // WebSocket 使用单独的代理配置
|
"/ws": wsProxyConfig,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/App.vue
@@ -2,8 +2,24 @@
|
|||||||
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||||
import "normalize.css"
|
import "normalize.css"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
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 isDark = useDark()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 初始化配置和实时更新
|
||||||
|
onMounted(() => {
|
||||||
|
configStore.getConfig()
|
||||||
|
userStore.getMyProfile()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用配置更新和 MaxKB 功能
|
||||||
|
useConfigUpdate()
|
||||||
|
useMaxKB()
|
||||||
|
|
||||||
// 延迟加载 highlight.js,避免阻塞首屏
|
// 延迟加载 highlight.js,避免阻塞首屏
|
||||||
const hljsInstance = ref<any>(null)
|
const hljsInstance = ref<any>(null)
|
||||||
|
|||||||
146
src/admin/api.ts
@@ -134,7 +134,6 @@ export async function uploadImage(file: File): Promise<string> {
|
|||||||
export function uploadTestcases(file: File) {
|
export function uploadTestcases(file: File) {
|
||||||
const form = new window.FormData()
|
const form = new window.FormData()
|
||||||
form.append("file", file)
|
form.append("file", file)
|
||||||
form.append("spj", "false")
|
|
||||||
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
||||||
headers: { "content-type": "multipart/form-data" },
|
headers: { "content-type": "multipart/form-data" },
|
||||||
})
|
})
|
||||||
@@ -286,3 +285,148 @@ export function updateACMHelperChecked(
|
|||||||
checked,
|
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}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { parseTime } from "utils/functions"
|
|||||||
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
||||||
import { getSubmission, getSubmissions } from "oj/api"
|
import { getSubmission, getSubmissions } from "oj/api"
|
||||||
import SubmissionDetail from "oj/submission/detail.vue"
|
import SubmissionDetail from "oj/submission/detail.vue"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contestID: string
|
contestID: string
|
||||||
@@ -28,6 +28,8 @@ interface HelperItem {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const submissions = ref<HelperItem[]>([])
|
const submissions = ref<HelperItem[]>([])
|
||||||
const contestStartTime = ref<Date | null>(null)
|
const contestStartTime = ref<Date | null>(null)
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
|
|||||||
@@ -42,13 +42,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
|
|
||||||
async function getList() {
|
async function getList() {
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await getProblemList(
|
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
|
||||||
offset,
|
|
||||||
query.limit,
|
|
||||||
query.keyword,
|
|
||||||
"",
|
|
||||||
"ACM",
|
|
||||||
)
|
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
problems.value = res.results
|
problems.value = res.results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "utils/constants"
|
} from "utils/constants"
|
||||||
import download from "utils/download"
|
import download from "utils/download"
|
||||||
import { unique } from "utils/functions"
|
import { unique } from "utils/functions"
|
||||||
import { BlankProblem, LANGUAGE, Tag } from "utils/types"
|
import { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
||||||
import {
|
import {
|
||||||
createContestProblem,
|
createContestProblem,
|
||||||
createProblem,
|
createProblem,
|
||||||
@@ -22,6 +22,10 @@ const CodeEditor = defineAsyncComponent(
|
|||||||
() => import("shared/components/CodeEditor.vue"),
|
() => import("shared/components/CodeEditor.vue"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MermaidEditor = defineAsyncComponent(
|
||||||
|
() => import("shared/components/MermaidEditor.vue"),
|
||||||
|
)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
problemID?: string
|
problemID?: string
|
||||||
contestID?: string
|
contestID?: string
|
||||||
@@ -50,33 +54,35 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
|||||||
output_description: "",
|
output_description: "",
|
||||||
time_limit: 1000,
|
time_limit: 1000,
|
||||||
memory_limit: 64,
|
memory_limit: 64,
|
||||||
difficulty: "Low",
|
difficulty: "Low" as "Low" | "Mid" | "High",
|
||||||
visible: false,
|
visible: false,
|
||||||
share_submission: false,
|
share_submission: false,
|
||||||
tags: [],
|
tags: [],
|
||||||
languages: ["C", "Python3"],
|
languages: ["Python3", "C"] as LANGUAGE[],
|
||||||
template: {},
|
template: {} as { [key in LANGUAGE]?: string },
|
||||||
samples: [
|
samples: [
|
||||||
{ input: "", output: "" },
|
{ input: "", output: "" },
|
||||||
{ input: "", output: "" },
|
{ input: "", output: "" },
|
||||||
{ input: "", output: "" },
|
{ input: "", output: "" },
|
||||||
],
|
],
|
||||||
spj: false,
|
|
||||||
spj_language: "",
|
|
||||||
spj_code: "",
|
|
||||||
spj_compile_ok: false,
|
|
||||||
test_case_id: "",
|
test_case_id: "",
|
||||||
test_case_score: [],
|
test_case_score: [] as Testcase[],
|
||||||
rule_type: "ACM",
|
rule_type: "ACM",
|
||||||
hint: "",
|
hint: "",
|
||||||
source: "",
|
source: "",
|
||||||
prompt: "",
|
prompt: "",
|
||||||
answers: [],
|
answers: [] as { language: LANGUAGE; code: string }[],
|
||||||
io_mode: {
|
io_mode: {
|
||||||
io_mode: "Standard IO",
|
io_mode: "Standard IO",
|
||||||
input: "input.txt",
|
input: "input.txt",
|
||||||
output: "output.txt",
|
output: "output.txt",
|
||||||
},
|
},
|
||||||
|
contest_id: "",
|
||||||
|
allow_flowchart: false,
|
||||||
|
mermaid_code: "",
|
||||||
|
flowchart_data: {},
|
||||||
|
flowchart_hint: "",
|
||||||
|
show_flowchart: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 从服务器来的tag列表
|
// 从服务器来的tag列表
|
||||||
@@ -101,6 +107,9 @@ const currentActiveAnswer = ref<LANGUAGE>("Python3")
|
|||||||
// 给 TextEditor 用
|
// 给 TextEditor 用
|
||||||
const [ready, toggleReady] = useToggle(false)
|
const [ready, toggleReady] = useToggle(false)
|
||||||
|
|
||||||
|
// Mermaid 渲染状态
|
||||||
|
const mermaidRenderSuccess = ref(false)
|
||||||
|
|
||||||
const difficultyOptions: SelectOption[] = [
|
const difficultyOptions: SelectOption[] = [
|
||||||
{ label: "简单", value: "Low" },
|
{ label: "简单", value: "Low" },
|
||||||
{ label: "中等", value: "Mid" },
|
{ label: "中等", value: "Mid" },
|
||||||
@@ -142,16 +151,18 @@ async function getProblemDetail() {
|
|||||||
problem.value.template = data.template
|
problem.value.template = data.template
|
||||||
problem.value.samples = data.samples
|
problem.value.samples = data.samples
|
||||||
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_id = data.test_case_id
|
||||||
problem.value.test_case_score = data.test_case_score
|
problem.value.test_case_score = data.test_case_score
|
||||||
problem.value.rule_type = data.rule_type
|
problem.value.rule_type = data.rule_type
|
||||||
problem.value.hint = data.hint
|
problem.value.hint = data.hint
|
||||||
problem.value.source = data.source
|
problem.value.source = data.source
|
||||||
problem.value.prompt = data.prompt
|
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) {
|
if (data.answers && data.answers.length) {
|
||||||
problem.value.answers = data.answers
|
problem.value.answers = data.answers
|
||||||
} else {
|
} else {
|
||||||
@@ -225,9 +236,6 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
|||||||
const testcases = res.data.info
|
const testcases = res.data.info
|
||||||
for (let file of testcases) {
|
for (let file of testcases) {
|
||||||
file.score = (100 / testcases.length).toFixed(0)
|
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_score = testcases
|
||||||
problem.value.test_case_id = res.data.id
|
problem.value.test_case_id = res.data.id
|
||||||
@@ -240,18 +248,23 @@ function downloadTestcases() {
|
|||||||
download("test_case?problem_id=" + problem.value.id)
|
download("test_case?problem_id=" + problem.value.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mermaid 渲染事件处理
|
||||||
|
function onMermaidRenderSuccess() {
|
||||||
|
mermaidRenderSuccess.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 题目是否有漏写的
|
// 题目是否有漏写的
|
||||||
function detectProblemCompletion() {
|
async function validateProblem() {
|
||||||
let flag = false
|
let hasErrors = false
|
||||||
// 标题
|
// 标题
|
||||||
if (!problem.value._id || !problem.value.title) {
|
if (!problem.value._id || !problem.value.title) {
|
||||||
message.error("编号或标题没有填写")
|
message.error("编号或标题没有填写")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 标签
|
// 标签
|
||||||
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) {
|
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) {
|
||||||
message.error("标签没有填写")
|
message.error("标签没有填写")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 题目
|
// 题目
|
||||||
else if (
|
else if (
|
||||||
@@ -260,12 +273,12 @@ function detectProblemCompletion() {
|
|||||||
!problem.value.output_description
|
!problem.value.output_description
|
||||||
) {
|
) {
|
||||||
message.error("题目或输入或输出没有填写")
|
message.error("题目或输入或输出没有填写")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 样例
|
// 样例
|
||||||
else if (problem.value.samples.length == 0) {
|
else if (problem.value.samples.length == 0) {
|
||||||
message.error("样例没有填写")
|
message.error("样例没有填写")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 样例是空的
|
// 样例是空的
|
||||||
else if (
|
else if (
|
||||||
@@ -274,21 +287,34 @@ function detectProblemCompletion() {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
message.error("空样例没有删干净")
|
message.error("空样例没有删干净")
|
||||||
flag = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 测试用例
|
// 测试用例
|
||||||
else if (problem.value.test_case_score.length === 0) {
|
else if (problem.value.test_case_score.length === 0) {
|
||||||
message.error("测试用例没有上传")
|
message.error("测试用例没有上传")
|
||||||
flag = true
|
hasErrors = true
|
||||||
} else if (problem.value.languages.length === 0) {
|
} else if (problem.value.languages.length === 0) {
|
||||||
message.error("编程语言没有选择")
|
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 {
|
else {
|
||||||
flag = false
|
hasErrors = false
|
||||||
}
|
}
|
||||||
return flag
|
return hasErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTemplate() {
|
function getTemplate() {
|
||||||
@@ -319,8 +345,8 @@ function filterAnswers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const notCompleted = detectProblemCompletion()
|
const hasValidationErrors = await validateProblem()
|
||||||
if (notCompleted) return
|
if (hasValidationErrors) return
|
||||||
filterHint()
|
filterHint()
|
||||||
getTemplate()
|
getTemplate()
|
||||||
filterAnswers()
|
filterAnswers()
|
||||||
@@ -499,14 +525,50 @@ watch(
|
|||||||
placeholder="比如来自某道题的改编等,或者网上的资料"
|
placeholder="比如来自某道题的改编等,或者网上的资料"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="本题的考察知识点(选填)">
|
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="problem.prompt"
|
v-model:value="problem.prompt"
|
||||||
placeholder="这里的内容是方便喂给 AI 进行辅助分析的"
|
placeholder="比如考察选择、循环、算法等知识点"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</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-grid :cols="2" x-gap="20">
|
||||||
<n-gi>
|
<n-gi>
|
||||||
<n-form>
|
<n-form>
|
||||||
@@ -525,7 +587,7 @@ watch(
|
|||||||
v-model:value="answer.code"
|
v-model:value="answer.code"
|
||||||
:language="answer.language"
|
:language="answer.language"
|
||||||
:font-size="16"
|
:font-size="16"
|
||||||
height="200px"
|
height="300px"
|
||||||
/>
|
/>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
@@ -534,7 +596,7 @@ watch(
|
|||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi>
|
<n-gi>
|
||||||
<n-form v-if="needTemplate">
|
<n-form v-if="needTemplate">
|
||||||
<n-form-item label="编写代码模板">
|
<n-form-item label="编写预制代码">
|
||||||
<n-tabs
|
<n-tabs
|
||||||
type="segment"
|
type="segment"
|
||||||
default-value="Python3"
|
default-value="Python3"
|
||||||
@@ -549,7 +611,7 @@ watch(
|
|||||||
v-model:value="template[lang]"
|
v-model:value="template[lang]"
|
||||||
:language="lang"
|
:language="lang"
|
||||||
:font-size="16"
|
:font-size="16"
|
||||||
height="200px"
|
height="300px"
|
||||||
/>
|
/>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
@@ -557,6 +619,36 @@ watch(
|
|||||||
</n-form>
|
</n-form>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</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
|
<n-alert
|
||||||
class="box"
|
class="box"
|
||||||
v-if="problem.test_case_score.length"
|
v-if="problem.test_case_score.length"
|
||||||
@@ -582,57 +674,24 @@ watch(
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-space style="margin-bottom: 100px" justify="space-between">
|
<n-flex style="margin-bottom: 120px" align="center" justify="end">
|
||||||
<n-form inline label-placement="left" :show-feedback="false">
|
<n-tooltip placement="left">
|
||||||
<n-form-item label="编程语言">
|
<template #trigger>
|
||||||
<n-checkbox-group v-model:value="problem.languages">
|
<n-button text>温馨提醒</n-button>
|
||||||
<n-flex align="center">
|
</template>
|
||||||
<n-checkbox
|
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||||
v-for="(language, index) in languageOptions"
|
</n-tooltip>
|
||||||
:key="index"
|
<div>
|
||||||
:value="language.value"
|
<n-upload
|
||||||
:label="language.label"
|
:show-file-list="false"
|
||||||
/>
|
accept=".zip"
|
||||||
</n-flex>
|
:custom-request="handleUploadTestcases"
|
||||||
</n-checkbox-group>
|
>
|
||||||
</n-form-item>
|
<n-button type="info">上传测试用例</n-button>
|
||||||
<n-form-item>
|
</n-upload>
|
||||||
<n-checkbox
|
</div>
|
||||||
v-model:checked="needTemplate"
|
<n-button type="primary" @click="submit">提交</n-button>
|
||||||
label="代码模板(一般用不到)"
|
</n-flex>
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
|||||||
type="primary"
|
type="primary"
|
||||||
@click="selectProblems"
|
@click="selectProblems"
|
||||||
>
|
>
|
||||||
从题库中选择
|
从题目中选择
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-flex align="center" v-if="!props.contestID">
|
<n-flex align="center" v-if="!props.contestID">
|
||||||
<span>出题人</span>
|
<span>出题人</span>
|
||||||
|
|||||||
105
src/admin/problemset/components/Actions.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
problemSetId: number
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits(["updated"])
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const showStatusModal = ref(false)
|
||||||
|
const newStatus = ref<"active" | "archived" | "draft">("active")
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: "活跃", value: "active" },
|
||||||
|
{ label: "已归档", value: "archived" },
|
||||||
|
{ label: "草稿", value: "draft" },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function handleDeleteProblemSet() {
|
||||||
|
try {
|
||||||
|
await deleteProblemSet(props.problemSetId)
|
||||||
|
message.success("删除成功")
|
||||||
|
emit("updated")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("删除失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEdit() {
|
||||||
|
router.push({
|
||||||
|
name: "admin problemset edit",
|
||||||
|
params: { problemSetId: props.problemSetId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail() {
|
||||||
|
router.push({
|
||||||
|
name: "admin problemset detail",
|
||||||
|
params: { problemSetId: props.problemSetId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusModal() {
|
||||||
|
showStatusModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateStatus() {
|
||||||
|
try {
|
||||||
|
await updateProblemSetStatus(props.problemSetId, newStatus.value)
|
||||||
|
message.success("状态更新成功")
|
||||||
|
showStatusModal.value = false
|
||||||
|
emit("updated")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("状态更新失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-flex>
|
||||||
|
<n-button size="small" secondary type="primary" @click="goEdit">
|
||||||
|
编辑
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" secondary type="info" @click="goDetail">
|
||||||
|
详情
|
||||||
|
</n-button>
|
||||||
|
<n-button size="small" secondary type="warning" @click="openStatusModal">
|
||||||
|
状态
|
||||||
|
</n-button>
|
||||||
|
<n-popconfirm @positive-click="handleDeleteProblemSet">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button secondary size="small" type="error">删除</n-button>
|
||||||
|
</template>
|
||||||
|
确定删除这个题单吗?删除后题单将不可见。
|
||||||
|
</n-popconfirm>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showStatusModal"
|
||||||
|
preset="card"
|
||||||
|
title="更新题单状态"
|
||||||
|
style="width: 400px"
|
||||||
|
>
|
||||||
|
<n-space vertical>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="状态" required>
|
||||||
|
<n-select
|
||||||
|
v-model:value="newStatus"
|
||||||
|
:options="statusOptions"
|
||||||
|
placeholder="选择状态"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</n-space>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="showStatusModal = false">取消</n-button>
|
||||||
|
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
162
src/admin/problemset/components/AddBadgeModal.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "update:show", value: boolean): void
|
||||||
|
(
|
||||||
|
e: "confirm",
|
||||||
|
data: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
condition_type: "all_problems" | "problem_count" | "score"
|
||||||
|
condition_value?: number
|
||||||
|
},
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const newBadgeName = ref("")
|
||||||
|
const newBadgeDescription = ref("")
|
||||||
|
const newBadgeIcon = ref("")
|
||||||
|
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||||
|
"all_problems",
|
||||||
|
)
|
||||||
|
const newBadgeConditionValue = ref(1)
|
||||||
|
|
||||||
|
const BADGE_LEN = 6
|
||||||
|
const badgeIconOptions = []
|
||||||
|
for (let i = 1; i <= BADGE_LEN; i++) {
|
||||||
|
badgeIconOptions.push({
|
||||||
|
label: `奖章${i}`,
|
||||||
|
value: `/badge-${i}.png`,
|
||||||
|
icon: `/badge-${i}.png`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionTypeOptions = [
|
||||||
|
{ label: "完成所有题目", value: "all_problems" },
|
||||||
|
{ label: "完成指定数量题目", value: "problem_count" },
|
||||||
|
{ label: "达到指定分数", value: "score" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
const data: any = {
|
||||||
|
name: newBadgeName.value,
|
||||||
|
description: newBadgeDescription.value,
|
||||||
|
icon: newBadgeIcon.value,
|
||||||
|
condition_type: newBadgeConditionType.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有非"完成所有题目"时才添加条件值
|
||||||
|
if (newBadgeConditionType.value !== "all_problems") {
|
||||||
|
data.condition_value = newBadgeConditionValue.value
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("confirm", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("update:show", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
newBadgeName.value = ""
|
||||||
|
newBadgeDescription.value = ""
|
||||||
|
newBadgeIcon.value = ""
|
||||||
|
newBadgeConditionType.value = "all_problems"
|
||||||
|
newBadgeConditionValue.value = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
title="添加奖章"
|
||||||
|
style="width: 500px"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="奖章名称" required>
|
||||||
|
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="描述">
|
||||||
|
<n-input
|
||||||
|
v-model:value="newBadgeDescription"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="奖章描述"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="图标" required>
|
||||||
|
<n-flex align="center" gap="small">
|
||||||
|
<div
|
||||||
|
v-for="option in badgeIconOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@click="newBadgeIcon = option.value"
|
||||||
|
:style="{
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
border:
|
||||||
|
newBadgeIcon === option.value
|
||||||
|
? '2px solid #1890ff'
|
||||||
|
: '1px solid #d9d9d9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor:
|
||||||
|
newBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<n-image
|
||||||
|
:src="option.icon"
|
||||||
|
width="50"
|
||||||
|
height="50"
|
||||||
|
object-fit="cover"
|
||||||
|
preview-disabled
|
||||||
|
style="border-radius: 2px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
</n-form-item>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-form-item label="获得条件">
|
||||||
|
<n-select
|
||||||
|
style="width: 200px"
|
||||||
|
v-model:value="newBadgeConditionType"
|
||||||
|
:options="conditionTypeOptions"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item
|
||||||
|
label="条件值"
|
||||||
|
v-if="newBadgeConditionType !== 'all_problems'"
|
||||||
|
>
|
||||||
|
<n-input-number
|
||||||
|
style="width: 120px"
|
||||||
|
v-model:value="newBadgeConditionValue"
|
||||||
|
placeholder="条件值"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-flex>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="handleCancel">取消</n-button>
|
||||||
|
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
103
src/admin/problemset/components/AddProblemModal.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "update:show", value: boolean): void
|
||||||
|
(
|
||||||
|
e: "confirm",
|
||||||
|
data: {
|
||||||
|
problem_id: string
|
||||||
|
order: number
|
||||||
|
is_required: boolean
|
||||||
|
score: number
|
||||||
|
hint: string
|
||||||
|
},
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const newProblemId = ref("")
|
||||||
|
const newProblemOrder = ref(0)
|
||||||
|
const newProblemRequired = ref(true)
|
||||||
|
const newProblemScore = ref(0)
|
||||||
|
const newProblemHint = ref("")
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit("confirm", {
|
||||||
|
problem_id: newProblemId.value,
|
||||||
|
order: newProblemOrder.value,
|
||||||
|
is_required: newProblemRequired.value,
|
||||||
|
score: newProblemScore.value,
|
||||||
|
hint: newProblemHint.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("update:show", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
newProblemId.value = ""
|
||||||
|
newProblemOrder.value = 0
|
||||||
|
newProblemRequired.value = true
|
||||||
|
newProblemScore.value = 0
|
||||||
|
newProblemHint.value = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
title="添加题目"
|
||||||
|
style="width: 500px"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<n-form>
|
||||||
|
<n-form-item label="题目ID" required>
|
||||||
|
<n-input
|
||||||
|
v-model:value="newProblemId"
|
||||||
|
placeholder="请输入题目的显示ID(如:1001)"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="顺序">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="newProblemOrder"
|
||||||
|
placeholder="题目在题单中的顺序"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="是否必做">
|
||||||
|
<n-switch v-model:value="newProblemRequired" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="分数">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="newProblemScore"
|
||||||
|
placeholder="题目分数"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="提示">
|
||||||
|
<n-input
|
||||||
|
v-model:value="newProblemHint"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="题目提示"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="handleCancel">取消</n-button>
|
||||||
|
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
96
src/admin/problemset/components/BadgeManagement.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { ProblemSetBadge } from "utils/types"
|
||||||
|
import { NButton, NImage } from "naive-ui"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
badges: ProblemSetBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "add-badge"): void
|
||||||
|
(e: "edit-badge", badge: ProblemSetBadge): void
|
||||||
|
(e: "delete-badge", badgeId: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
defineEmits<Emits>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||||
|
<h3>奖章列表</h3>
|
||||||
|
<n-button type="primary" @click="$emit('add-badge')"> 添加奖章 </n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table
|
||||||
|
:columns="[
|
||||||
|
{
|
||||||
|
title: '图标',
|
||||||
|
key: 'icon',
|
||||||
|
render: (row) =>
|
||||||
|
h(NImage, {
|
||||||
|
src: row.icon,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
objectFit: 'cover',
|
||||||
|
previewDisabled: true,
|
||||||
|
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ title: '名称', key: 'name' },
|
||||||
|
{
|
||||||
|
title: '条件类型',
|
||||||
|
key: 'condition_type',
|
||||||
|
render: (row) => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
all_problems: '完成所有题目',
|
||||||
|
problem_count: '完成指定数量题目',
|
||||||
|
score: '达到指定分数',
|
||||||
|
}
|
||||||
|
return typeMap[row.condition_type] || row.condition_type
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '条件值',
|
||||||
|
key: 'condition_value',
|
||||||
|
render: (row) => {
|
||||||
|
return row.condition_type === 'all_problems'
|
||||||
|
? '-'
|
||||||
|
: row.condition_value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '描述', key: 'description' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 160,
|
||||||
|
render: (row) =>
|
||||||
|
h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: 'small',
|
||||||
|
type: 'primary',
|
||||||
|
secondary: true,
|
||||||
|
onClick: () => $emit('edit-badge', row),
|
||||||
|
},
|
||||||
|
{ default: () => '编辑' },
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: 'small',
|
||||||
|
type: 'error',
|
||||||
|
secondary: true,
|
||||||
|
onClick: () => $emit('delete-badge', row.id),
|
||||||
|
},
|
||||||
|
{ default: () => '删除' },
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:data="badges"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
167
src/admin/problemset/components/EditBadgeModal.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ProblemSetBadge } from "utils/types"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
badge: ProblemSetBadge | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "update:show", value: boolean): void
|
||||||
|
(
|
||||||
|
e: "confirm",
|
||||||
|
data: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
condition_type: "all_problems" | "problem_count" | "score"
|
||||||
|
condition_value?: number
|
||||||
|
},
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const editBadgeName = ref("")
|
||||||
|
const editBadgeDescription = ref("")
|
||||||
|
const editBadgeIcon = ref("")
|
||||||
|
const editBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||||
|
"all_problems",
|
||||||
|
)
|
||||||
|
const editBadgeConditionValue = ref(1)
|
||||||
|
|
||||||
|
// 预设奖章图标选项
|
||||||
|
const BADGE_LEN = 6
|
||||||
|
const badgeIconOptions = []
|
||||||
|
for (let i = 1; i <= BADGE_LEN; i++) {
|
||||||
|
badgeIconOptions.push({
|
||||||
|
label: `奖章${i}`,
|
||||||
|
value: `/badge-${i}.png`,
|
||||||
|
icon: `/badge-${i}.png`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionTypeOptions = [
|
||||||
|
{ label: "完成所有题目", value: "all_problems" },
|
||||||
|
{ label: "完成指定数量题目", value: "problem_count" },
|
||||||
|
{ label: "达到指定分数", value: "score" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
const data: any = {
|
||||||
|
name: editBadgeName.value,
|
||||||
|
description: editBadgeDescription.value,
|
||||||
|
icon: editBadgeIcon.value,
|
||||||
|
condition_type: editBadgeConditionType.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有非"完成所有题目"时才添加条件值
|
||||||
|
if (editBadgeConditionType.value !== "all_problems") {
|
||||||
|
data.condition_value = editBadgeConditionValue.value
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("confirm", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("update:show", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当奖章数据变化时,更新表单数据
|
||||||
|
watch(
|
||||||
|
() => props.badge,
|
||||||
|
(newBadge) => {
|
||||||
|
if (newBadge) {
|
||||||
|
editBadgeName.value = newBadge.name
|
||||||
|
editBadgeDescription.value = newBadge.description
|
||||||
|
editBadgeIcon.value = newBadge.icon
|
||||||
|
editBadgeConditionType.value = newBadge.condition_type
|
||||||
|
editBadgeConditionValue.value = newBadge.condition_value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
title="编辑奖章"
|
||||||
|
style="width: 500px"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<n-form v-if="badge">
|
||||||
|
<n-form-item label="奖章名称" required>
|
||||||
|
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="描述">
|
||||||
|
<n-input
|
||||||
|
v-model:value="editBadgeDescription"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="奖章描述"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="图标" required>
|
||||||
|
<n-flex align="center" gap="small">
|
||||||
|
<div
|
||||||
|
v-for="option in badgeIconOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@click="editBadgeIcon = option.value"
|
||||||
|
:style="{
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
border:
|
||||||
|
editBadgeIcon === option.value
|
||||||
|
? '2px solid #1890ff'
|
||||||
|
: '1px solid #d9d9d9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor:
|
||||||
|
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<n-image
|
||||||
|
:src="option.icon"
|
||||||
|
width="50"
|
||||||
|
height="50"
|
||||||
|
object-fit="cover"
|
||||||
|
style="border-radius: 2px"
|
||||||
|
preview-disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
</n-form-item>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-form-item label="获得条件">
|
||||||
|
<n-select
|
||||||
|
style="width: 200px"
|
||||||
|
v-model:value="editBadgeConditionType"
|
||||||
|
:options="conditionTypeOptions"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item
|
||||||
|
label="条件值"
|
||||||
|
v-if="editBadgeConditionType !== 'all_problems'"
|
||||||
|
>
|
||||||
|
<n-input-number
|
||||||
|
style="width: 120px"
|
||||||
|
v-model:value="editBadgeConditionValue"
|
||||||
|
placeholder="条件值"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-flex>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="handleCancel">取消</n-button>
|
||||||
|
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
100
src/admin/problemset/components/EditProblemModal.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ProblemSetProblem } from "utils/types"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
problem: ProblemSetProblem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "update:show", value: boolean): void
|
||||||
|
(
|
||||||
|
e: "confirm",
|
||||||
|
data: {
|
||||||
|
order: number
|
||||||
|
is_required: boolean
|
||||||
|
score: number
|
||||||
|
hint: string
|
||||||
|
},
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const editProblemOrder = ref(0)
|
||||||
|
const editProblemRequired = ref(true)
|
||||||
|
const editProblemScore = ref(0)
|
||||||
|
const editProblemHint = ref("")
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit("confirm", {
|
||||||
|
order: editProblemOrder.value,
|
||||||
|
is_required: editProblemRequired.value,
|
||||||
|
score: editProblemScore.value,
|
||||||
|
hint: editProblemHint.value || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("update:show", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当问题数据变化时,更新表单数据
|
||||||
|
watch(
|
||||||
|
() => props.problem,
|
||||||
|
(newProblem) => {
|
||||||
|
if (newProblem) {
|
||||||
|
editProblemOrder.value = newProblem.order
|
||||||
|
editProblemRequired.value = newProblem.is_required
|
||||||
|
editProblemScore.value = newProblem.score
|
||||||
|
editProblemHint.value = newProblem.hint || ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
:show="show"
|
||||||
|
preset="card"
|
||||||
|
title="编辑题目"
|
||||||
|
style="width: 500px"
|
||||||
|
@update:show="emit('update:show', $event)"
|
||||||
|
>
|
||||||
|
<n-form v-if="problem">
|
||||||
|
<n-form-item label="题目标题">
|
||||||
|
<n-input :value="problem.problem.title" disabled />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="顺序">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="editProblemOrder"
|
||||||
|
placeholder="题目在题单中的顺序"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="是否必做">
|
||||||
|
<n-switch v-model:value="editProblemRequired" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="分数">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="editProblemScore"
|
||||||
|
placeholder="题目分数"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="提示">
|
||||||
|
<n-input
|
||||||
|
v-model:value="editProblemHint"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="题目提示"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="handleCancel">取消</n-button>
|
||||||
|
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
73
src/admin/problemset/components/ProblemManagement.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||||
|
import { ProblemSetProblem } from "utils/types"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
problems: ProblemSetProblem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "add-problem"): void
|
||||||
|
(e: "edit-problem", problem: ProblemSetProblem): void
|
||||||
|
(e: "remove-problem", problemSetProblemId: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
defineEmits<Emits>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||||
|
<h3>题目列表</h3>
|
||||||
|
<n-button type="primary" @click="$emit('add-problem')">
|
||||||
|
添加题目
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table
|
||||||
|
:columns="[
|
||||||
|
{ title: '题目ID', key: 'problem._id', width: 80 },
|
||||||
|
{ title: '题目标题', key: 'problem.title', minWidth: 200 },
|
||||||
|
{ title: '顺序', key: 'order', width: 80 },
|
||||||
|
{
|
||||||
|
title: '必做',
|
||||||
|
key: 'is_required',
|
||||||
|
width: 80,
|
||||||
|
render: (row) => (row.is_required ? '是' : '否'),
|
||||||
|
},
|
||||||
|
{ title: '分数', key: 'score', width: 80 },
|
||||||
|
{ title: '提示', key: 'hint', minWidth: 200 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 160,
|
||||||
|
render: (row) =>
|
||||||
|
h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: 'small',
|
||||||
|
type: 'primary',
|
||||||
|
secondary: true,
|
||||||
|
onClick: () => $emit('edit-problem', row),
|
||||||
|
},
|
||||||
|
{ default: () => '编辑' },
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: 'small',
|
||||||
|
type: 'error',
|
||||||
|
secondary: true,
|
||||||
|
onClick: () => $emit('remove-problem', row.id),
|
||||||
|
},
|
||||||
|
{ default: () => '移除' },
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:data="problems"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
70
src/admin/problemset/components/ProblemSetInfo.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import { ProblemSet } from "utils/types"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
problemSet: ProblemSet
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card title="题单信息" style="margin-bottom: 16px">
|
||||||
|
<n-descriptions :column="4" bordered>
|
||||||
|
<n-descriptions-item label="描述">
|
||||||
|
{{ problemSet.description }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="创建者">
|
||||||
|
{{ problemSet.created_by.username }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="难度">
|
||||||
|
<n-tag
|
||||||
|
:type="
|
||||||
|
problemSet.difficulty === 'Easy'
|
||||||
|
? 'success'
|
||||||
|
: problemSet.difficulty === 'Medium'
|
||||||
|
? 'warning'
|
||||||
|
: 'error'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
problemSet.difficulty === "Easy"
|
||||||
|
? "简单"
|
||||||
|
: problemSet.difficulty === "Medium"
|
||||||
|
? "中等"
|
||||||
|
: "困难"
|
||||||
|
}}
|
||||||
|
</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="状态">
|
||||||
|
<n-tag
|
||||||
|
:type="
|
||||||
|
problemSet.status === 'active'
|
||||||
|
? 'success'
|
||||||
|
: problemSet.status === 'archived'
|
||||||
|
? 'default'
|
||||||
|
: 'info'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
problemSet.status === "active"
|
||||||
|
? "活跃"
|
||||||
|
: problemSet.status === "archived"
|
||||||
|
? "已归档"
|
||||||
|
: "草稿"
|
||||||
|
}}
|
||||||
|
</n-tag>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="可见">
|
||||||
|
{{ problemSet.visible ? "是" : "否" }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="题目数量">
|
||||||
|
{{ problemSet.problems_count }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="创建时间">
|
||||||
|
{{ parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss") }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
69
src/admin/problemset/components/ProgressManagement.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import { ProblemSetProgress } from "utils/types"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
progress: ProblemSetProgress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "remove-user", userId: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 定义表格列
|
||||||
|
const progressColumns = [
|
||||||
|
{ title: "用户", key: "user.username", width: 120 },
|
||||||
|
{
|
||||||
|
title: "加入时间",
|
||||||
|
key: "join_time",
|
||||||
|
width: 180,
|
||||||
|
render: (row: ProblemSetProgress) =>
|
||||||
|
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
|
||||||
|
},
|
||||||
|
{ title: "已完成", key: "completed_problems_count", width: 100 },
|
||||||
|
{ title: "总题目", key: "total_problems_count", width: 100 },
|
||||||
|
{
|
||||||
|
title: "进度",
|
||||||
|
key: "progress_percentage",
|
||||||
|
width: 100,
|
||||||
|
render: (row: ProblemSetProgress) =>
|
||||||
|
`${row.progress_percentage.toFixed(0)}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "是否完成",
|
||||||
|
key: "is_completed",
|
||||||
|
width: 100,
|
||||||
|
render: (row: ProblemSetProgress) => (row.is_completed ? "是" : "否"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 120,
|
||||||
|
render: (row: ProblemSetProgress) =>
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
type: "error",
|
||||||
|
secondary: true,
|
||||||
|
onClick: () => emit("remove-user", row.user.id),
|
||||||
|
},
|
||||||
|
{ default: () => "移除" },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||||
|
<h3>用户进度</h3>
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table :columns="progressColumns" :data="progress" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
270
src/admin/problemset/detail.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
|
||||||
|
import {
|
||||||
|
ProblemSet,
|
||||||
|
ProblemSetProblem,
|
||||||
|
ProblemSetBadge,
|
||||||
|
ProblemSetProgress,
|
||||||
|
} from "utils/types"
|
||||||
|
import {
|
||||||
|
getProblemSetDetail,
|
||||||
|
getProblemSetProblems,
|
||||||
|
getProblemSetBadges,
|
||||||
|
getProblemSetProgress,
|
||||||
|
addProblemToSet,
|
||||||
|
editProblemInSet,
|
||||||
|
removeProblemFromSet,
|
||||||
|
createProblemSetBadge,
|
||||||
|
editProblemSetBadge,
|
||||||
|
deleteProblemSetBadge,
|
||||||
|
removeUserFromProblemSet,
|
||||||
|
} from "../api"
|
||||||
|
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
|
||||||
|
import ProblemManagement from "./components/ProblemManagement.vue"
|
||||||
|
import BadgeManagement from "./components/BadgeManagement.vue"
|
||||||
|
import ProgressManagement from "./components/ProgressManagement.vue"
|
||||||
|
import AddProblemModal from "./components/AddProblemModal.vue"
|
||||||
|
import EditProblemModal from "./components/EditProblemModal.vue"
|
||||||
|
import AddBadgeModal from "./components/AddBadgeModal.vue"
|
||||||
|
import EditBadgeModal from "./components/EditBadgeModal.vue"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||||
|
|
||||||
|
const problemSet = ref<ProblemSet | null>(null)
|
||||||
|
const problems = ref<ProblemSetProblem[]>([])
|
||||||
|
const badges = ref<ProblemSetBadge[]>([])
|
||||||
|
const progress = ref<ProblemSetProgress[]>([])
|
||||||
|
|
||||||
|
// 模态框状态
|
||||||
|
const showAddProblemModal = ref(false)
|
||||||
|
const showEditProblemModal = ref(false)
|
||||||
|
const showAddBadgeModal = ref(false)
|
||||||
|
const showEditBadgeModal = ref(false)
|
||||||
|
|
||||||
|
// 编辑数据
|
||||||
|
const editingProblem = ref<ProblemSetProblem | null>(null)
|
||||||
|
const editingBadge = ref<ProblemSetBadge | null>(null)
|
||||||
|
|
||||||
|
async function loadProblemSetDetail() {
|
||||||
|
try {
|
||||||
|
const res = await getProblemSetDetail(problemSetId.value)
|
||||||
|
problemSet.value = res.data
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProblems() {
|
||||||
|
try {
|
||||||
|
const res = await getProblemSetProblems(problemSetId.value)
|
||||||
|
problems.value = res.data
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("加载题目列表失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBadges() {
|
||||||
|
try {
|
||||||
|
const res = await getProblemSetBadges(problemSetId.value)
|
||||||
|
badges.value = res.data
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProgress() {
|
||||||
|
try {
|
||||||
|
const res = await getProblemSetProgress(problemSetId.value)
|
||||||
|
progress.value = res.data
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("加载进度列表失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddProblem(data: any) {
|
||||||
|
try {
|
||||||
|
await addProblemToSet(problemSetId.value, data)
|
||||||
|
message.success("题目添加成功")
|
||||||
|
showAddProblemModal.value = false
|
||||||
|
loadProblems()
|
||||||
|
loadProblemSetDetail() // 刷新题目数量
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("添加题目失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveProblem(problemSetProblemId: number) {
|
||||||
|
try {
|
||||||
|
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
|
||||||
|
message.success("题目移除成功")
|
||||||
|
loadProblems()
|
||||||
|
loadProblemSetDetail() // 刷新题目数量
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("移除题目失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditProblem(data: any) {
|
||||||
|
if (!editingProblem.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
|
||||||
|
message.success("题目编辑成功")
|
||||||
|
showEditProblemModal.value = false
|
||||||
|
loadProblems()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("编辑题目失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddBadge(data: any) {
|
||||||
|
try {
|
||||||
|
await createProblemSetBadge(problemSetId.value, data)
|
||||||
|
message.success("奖章创建成功")
|
||||||
|
showAddBadgeModal.value = false
|
||||||
|
loadBadges()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("创建奖章失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteBadge(badgeId: number) {
|
||||||
|
try {
|
||||||
|
await deleteProblemSetBadge(problemSetId.value, badgeId)
|
||||||
|
message.success("奖章删除成功")
|
||||||
|
loadBadges()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("删除奖章失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditBadge(data: any) {
|
||||||
|
if (!editingBadge.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
|
||||||
|
message.success("奖章编辑成功")
|
||||||
|
showEditBadgeModal.value = false
|
||||||
|
loadBadges()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("编辑奖章失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveUser(userId: number) {
|
||||||
|
try {
|
||||||
|
await removeUserFromProblemSet(problemSetId.value, userId)
|
||||||
|
message.success("用户移除成功")
|
||||||
|
loadProgress()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("移除用户失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddProblemModal() {
|
||||||
|
showAddProblemModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddBadgeModal() {
|
||||||
|
showAddBadgeModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditProblemModal(problem: ProblemSetProblem) {
|
||||||
|
editingProblem.value = problem
|
||||||
|
showEditProblemModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditBadgeModal(badge: ProblemSetBadge) {
|
||||||
|
editingBadge.value = badge
|
||||||
|
showEditBadgeModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProblemSetDetail()
|
||||||
|
loadProblems()
|
||||||
|
loadBadges()
|
||||||
|
loadProgress()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="problemSet">
|
||||||
|
<n-flex class="titleWrapper" justify="space-between" align="center">
|
||||||
|
<h2 class="title">{{ problemSet.title }}</h2>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
@click="
|
||||||
|
router.push({
|
||||||
|
name: 'admin problemset edit',
|
||||||
|
params: { problemSetId },
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
编辑题单
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<ProblemSetInfo :problem-set="problemSet" />
|
||||||
|
|
||||||
|
<n-tabs type="line">
|
||||||
|
<n-tab-pane name="problems" tab="题目管理">
|
||||||
|
<ProblemManagement
|
||||||
|
:problems="problems"
|
||||||
|
@add-problem="openAddProblemModal"
|
||||||
|
@edit-problem="openEditProblemModal"
|
||||||
|
@remove-problem="handleRemoveProblem"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<n-tab-pane name="badges" tab="奖章管理">
|
||||||
|
<BadgeManagement
|
||||||
|
:badges="badges"
|
||||||
|
@add-badge="openAddBadgeModal"
|
||||||
|
@edit-badge="openEditBadgeModal"
|
||||||
|
@delete-badge="handleDeleteBadge"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<n-tab-pane name="progress" tab="进度管理">
|
||||||
|
<ProgressManagement
|
||||||
|
:progress="progress"
|
||||||
|
@remove-user="handleRemoveUser"
|
||||||
|
/>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
|
||||||
|
<!-- 模态框组件 -->
|
||||||
|
<AddProblemModal
|
||||||
|
v-model:show="showAddProblemModal"
|
||||||
|
@confirm="handleAddProblem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditProblemModal
|
||||||
|
v-model:show="showEditProblemModal"
|
||||||
|
:problem="editingProblem"
|
||||||
|
@confirm="handleEditProblem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
|
||||||
|
|
||||||
|
<EditBadgeModal
|
||||||
|
v-model:show="showEditBadgeModal"
|
||||||
|
:badge="editingBadge"
|
||||||
|
@confirm="handleEditBadge"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.titleWrapper {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
149
src/admin/problemset/edit.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CreateProblemSetData, EditProblemSetData } from "utils/types"
|
||||||
|
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||||
|
const isEdit = computed(() => !!problemSetId.value)
|
||||||
|
|
||||||
|
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
difficulty: "Easy",
|
||||||
|
status: "draft",
|
||||||
|
visible: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const difficultyOptions = [
|
||||||
|
{ label: "简单", value: "Easy" },
|
||||||
|
{ label: "中等", value: "Medium" },
|
||||||
|
{ label: "困难", value: "Hard" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: "活跃", value: "active" },
|
||||||
|
{ label: "已归档", value: "archived" },
|
||||||
|
{ label: "草稿", value: "draft" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function loadProblemSetDetail() {
|
||||||
|
if (!isEdit.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getProblemSetDetail(problemSetId.value)
|
||||||
|
const data = res.data
|
||||||
|
formData.value = {
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
difficulty: data.difficulty,
|
||||||
|
status: data.status,
|
||||||
|
visible: data.visible,
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formData.value.title?.trim()) {
|
||||||
|
message.error("请输入题单标题")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.value.description?.trim()) {
|
||||||
|
message.error("请输入题单描述")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await editProblemSet(formData.value as EditProblemSetData)
|
||||||
|
message.success("题单更新成功")
|
||||||
|
} else {
|
||||||
|
await createProblemSet(formData.value as CreateProblemSetData)
|
||||||
|
message.success("题单创建成功")
|
||||||
|
}
|
||||||
|
router.push({ name: "admin problemset list" })
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(
|
||||||
|
(isEdit.value ? "更新" : "创建") +
|
||||||
|
"题单失败:" +
|
||||||
|
(err.data || "未知错误"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isEdit.value) {
|
||||||
|
loadProblemSetDetail()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="title">{{ isEdit ? "编辑题单" : "创建题单" }}</h2>
|
||||||
|
|
||||||
|
<n-form :model="formData" label-placement="top">
|
||||||
|
<n-flex>
|
||||||
|
<n-form-item label="题单标题" required>
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.title"
|
||||||
|
placeholder="请输入题单标题"
|
||||||
|
maxlength="200"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="难度">
|
||||||
|
<n-select
|
||||||
|
style="width: 100px"
|
||||||
|
v-model:value="formData.difficulty"
|
||||||
|
:options="difficultyOptions"
|
||||||
|
placeholder="选择难度"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="状态">
|
||||||
|
<n-select
|
||||||
|
style="width: 100px"
|
||||||
|
v-model:value="formData.status"
|
||||||
|
:options="statusOptions"
|
||||||
|
placeholder="选择状态"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="isEdit" label="是否可见">
|
||||||
|
<n-switch v-model:value="formData.visible" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-form-item label="题单描述" required>
|
||||||
|
<n-input
|
||||||
|
v-model:value="formData.description"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入题单描述"
|
||||||
|
:rows="4"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item>
|
||||||
|
<n-button type="primary" :loading="loading" @click="handleSubmit">
|
||||||
|
{{ isEdit ? "更新" : "创建" }}
|
||||||
|
</n-button>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.title {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
209
src/admin/problemset/list.vue
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
|
import { usePagination } from "shared/composables/pagination"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import { ProblemSetList } from "utils/types"
|
||||||
|
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
||||||
|
import Actions from "./components/Actions.vue"
|
||||||
|
import { NTag, NSwitch } from "naive-ui"
|
||||||
|
|
||||||
|
const total = ref(0)
|
||||||
|
const problemSets = ref<ProblemSetList[]>([])
|
||||||
|
|
||||||
|
interface ProblemSetQuery {
|
||||||
|
keyword: string
|
||||||
|
difficulty: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用分页 composable
|
||||||
|
const { query, clearQuery } = usePagination<ProblemSetQuery>({
|
||||||
|
keyword: "",
|
||||||
|
difficulty: "",
|
||||||
|
status: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const difficultyOptions = [
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
{ label: "简单", value: "Easy" },
|
||||||
|
{ label: "中等", value: "Medium" },
|
||||||
|
{ label: "困难", value: "Hard" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
{ label: "活跃", value: "active" },
|
||||||
|
{ label: "已归档", value: "archived" },
|
||||||
|
{ label: "草稿", value: "draft" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns: DataTableColumn<ProblemSetList>[] = [
|
||||||
|
{ title: "ID", key: "id", width: 80 },
|
||||||
|
{ title: "标题", key: "title", minWidth: 200 },
|
||||||
|
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: "创建者",
|
||||||
|
key: "created_by",
|
||||||
|
width: 120,
|
||||||
|
render: (row) => row.created_by.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "难度",
|
||||||
|
key: "difficulty",
|
||||||
|
width: 100,
|
||||||
|
render: (row) => {
|
||||||
|
const difficultyMap = {
|
||||||
|
Easy: { type: "success" as const, text: "简单" },
|
||||||
|
Medium: { type: "warning" as const, text: "中等" },
|
||||||
|
Hard: { type: "error" as const, text: "困难" },
|
||||||
|
}
|
||||||
|
const config = difficultyMap[row.difficulty]
|
||||||
|
return h(NTag, { type: config.type }, { default: () => config.text })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
key: "status",
|
||||||
|
width: 100,
|
||||||
|
render: (row) => {
|
||||||
|
const statusMap = {
|
||||||
|
active: { type: "success" as const, text: "活跃" },
|
||||||
|
archived: { type: "default" as const, text: "已归档" },
|
||||||
|
draft: { type: "info" as const, text: "草稿" },
|
||||||
|
}
|
||||||
|
const config = statusMap[row.status]
|
||||||
|
return h(NTag, { type: config.type }, { default: () => config.text })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "题目数量",
|
||||||
|
key: "problems_count",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "创建时间",
|
||||||
|
key: "create_time",
|
||||||
|
width: 180,
|
||||||
|
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "可见",
|
||||||
|
key: "visible",
|
||||||
|
width: 80,
|
||||||
|
render: (row) =>
|
||||||
|
h(NSwitch, {
|
||||||
|
value: row.visible,
|
||||||
|
size: "small",
|
||||||
|
rubberBand: false,
|
||||||
|
onUpdateValue: () => toggleVisible(row.id),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "选项",
|
||||||
|
key: "actions",
|
||||||
|
width: 300,
|
||||||
|
render: (row) =>
|
||||||
|
h(Actions, {
|
||||||
|
problemSetId: row.id,
|
||||||
|
onUpdated: listProblemSets,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function listProblemSets() {
|
||||||
|
if (query.page < 1) query.page = 1
|
||||||
|
const offset = (query.page - 1) * query.limit
|
||||||
|
const res = await getProblemSetList(
|
||||||
|
offset,
|
||||||
|
query.limit,
|
||||||
|
query.keyword,
|
||||||
|
query.difficulty,
|
||||||
|
query.status,
|
||||||
|
)
|
||||||
|
total.value = res.data.total
|
||||||
|
problemSets.value = res.data.results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleVisible(problemSetId: number) {
|
||||||
|
await toggleProblemSetVisible(problemSetId)
|
||||||
|
problemSets.value = problemSets.value.map((it) => {
|
||||||
|
if (it.id === problemSetId) {
|
||||||
|
it.visible = !it.visible
|
||||||
|
}
|
||||||
|
return it
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(listProblemSets)
|
||||||
|
|
||||||
|
// 监听搜索关键词变化(防抖)
|
||||||
|
watchDebounced(() => query.keyword, listProblemSets, {
|
||||||
|
debounce: 500,
|
||||||
|
maxWait: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听其他查询条件变化
|
||||||
|
watch(
|
||||||
|
() => [query.page, query.limit, query.difficulty, query.status],
|
||||||
|
listProblemSets,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-flex class="titleWrapper" justify="space-between">
|
||||||
|
<n-flex align="center">
|
||||||
|
<h2 class="title">题单管理</h2>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
@click="$router.push({ name: 'admin problemset create' })"
|
||||||
|
>
|
||||||
|
新建题单
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center">
|
||||||
|
<n-flex align="center">
|
||||||
|
<span>难度:</span>
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.difficulty"
|
||||||
|
:options="difficultyOptions"
|
||||||
|
placeholder="选择难度"
|
||||||
|
style="width: 120px"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span>状态:</span>
|
||||||
|
<n-select
|
||||||
|
v-model:value="query.status"
|
||||||
|
:options="statusOptions"
|
||||||
|
placeholder="选择状态"
|
||||||
|
style="width: 120px"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
<n-input
|
||||||
|
v-model:value="query.keyword"
|
||||||
|
placeholder="输入标题关键字"
|
||||||
|
clearable
|
||||||
|
@clear="clearQuery"
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table striped :columns="columns" :data="problemSets" />
|
||||||
|
<Pagination
|
||||||
|
:total="total"
|
||||||
|
v-model:limit="query.limit"
|
||||||
|
v-model:page="query.page"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.titleWrapper {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
import { NButton, NTag } from "naive-ui"
|
import { NButton, NTag } from "naive-ui"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Server } from "utils/types"
|
import { Server } from "utils/types"
|
||||||
|
import { useConfigStore } from "shared/store/config"
|
||||||
|
import { useConfigWebSocket } from "shared/composables/websocket"
|
||||||
import {
|
import {
|
||||||
deleteJudgeServer,
|
deleteJudgeServer,
|
||||||
editWebsite,
|
editWebsite,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
listInvalidTestcases,
|
listInvalidTestcases,
|
||||||
pruneInvalidTestcases,
|
pruneInvalidTestcases,
|
||||||
} from "../api"
|
} from "../api"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
interface Testcase {
|
interface Testcase {
|
||||||
id: string
|
id: string
|
||||||
@@ -17,6 +20,21 @@ interface Testcase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
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>[] = [
|
const testcaseColumns: DataTableColumn<Testcase>[] = [
|
||||||
{ title: "测试用例 ID", key: "id" },
|
{ title: "测试用例 ID", key: "id" },
|
||||||
@@ -106,6 +124,7 @@ const websiteConfig = reactive({
|
|||||||
allow_register: true,
|
allow_register: true,
|
||||||
submission_list_show_all: true,
|
submission_list_show_all: true,
|
||||||
class_list: [],
|
class_list: [],
|
||||||
|
enable_maxkb: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function getWebsiteConfig() {
|
async function getWebsiteConfig() {
|
||||||
@@ -117,12 +136,21 @@ async function getWebsiteConfig() {
|
|||||||
websiteConfig.allow_register = res.data.allow_register
|
websiteConfig.allow_register = res.data.allow_register
|
||||||
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
|
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
|
||||||
websiteConfig.class_list = res.data.class_list
|
websiteConfig.class_list = res.data.class_list
|
||||||
|
websiteConfig.enable_maxkb = res.data.enable_maxkb
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveWebsiteConfig() {
|
async function saveWebsiteConfig() {
|
||||||
await editWebsite(websiteConfig)
|
await editWebsite(websiteConfig)
|
||||||
message.success("网站配置保存成功")
|
message.success("网站配置保存成功")
|
||||||
getWebsiteConfig()
|
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) {
|
async function deleteTestcase(id?: string) {
|
||||||
@@ -198,6 +226,10 @@ onMounted(() => {
|
|||||||
<span>显示所有提交</span>
|
<span>显示所有提交</span>
|
||||||
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
|
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
<n-flex align="center">
|
||||||
|
<span>启用AI小助手</span>
|
||||||
|
<n-switch v-model:value="websiteConfig.enable_maxkb" />
|
||||||
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-card>
|
</n-card>
|
||||||
<n-card class="box">
|
<n-card class="box">
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ const adminOptions = [
|
|||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ label: "默认排序", value: "" },
|
{ label: "默认排序", value: "" },
|
||||||
{ label: "最近登录", value: "-last_login" },
|
{ label: "最近登录", value: "-last_login" },
|
||||||
{ label: "最早登录", value: "last_login" },
|
|
||||||
]
|
]
|
||||||
const [create, toggleCreate] = useToggle(false)
|
const [create, toggleCreate] = useToggle(false)
|
||||||
const password = ref("")
|
const password = ref("")
|
||||||
|
|||||||
1
src/env.d.ts
vendored
@@ -8,6 +8,7 @@ interface ImportMetaEnv {
|
|||||||
readonly PUBLIC_JUDGE0_URL: string
|
readonly PUBLIC_JUDGE0_URL: string
|
||||||
readonly PUBLIC_ICONIFY_URL: string
|
readonly PUBLIC_ICONIFY_URL: string
|
||||||
readonly PUBLIC_SIGNALING_URL: string
|
readonly PUBLIC_SIGNALING_URL: string
|
||||||
|
readonly PUBLIC_WS_URL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
24
src/main.ts
@@ -8,19 +8,29 @@ import storage from "utils/storage"
|
|||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
import { admins, ojs } from "./routes"
|
import { admins, ojs } from "./routes"
|
||||||
|
|
||||||
import { toggleLogin } from "./shared/composables/modal"
|
|
||||||
import { useUserStore } from "./shared/store/user"
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [ojs, admins],
|
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) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
// 检查是否需要认证
|
// 检查是否需要认证
|
||||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||||
toggleLogin(true)
|
authStore.openLoginModal()
|
||||||
next("/")
|
next("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -34,7 +44,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||||
toggleLogin(true)
|
authStore.openLoginModal()
|
||||||
next("/")
|
next("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -66,10 +76,6 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
const pinia = createPinia()
|
|
||||||
const app = createApp(App)
|
|
||||||
app.use(router)
|
|
||||||
app.use(pinia)
|
|
||||||
app.mount("#app")
|
app.mount("#app")
|
||||||
|
|
||||||
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-spin :show="aiStore.loading.fetching">
|
<n-spin :show="aiStore.loading.fetching" :delay="50">
|
||||||
<n-grid :cols="isDesktop ? 5 : 1" :x-gap="20" :y-gap="20">
|
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
||||||
<n-gi :span="2">
|
<n-gi :span="1">
|
||||||
<n-flex vertical size="large">
|
<n-flex vertical size="large">
|
||||||
<n-flex align="center" justify="space-between">
|
<n-flex align="center" justify="space-between">
|
||||||
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
||||||
@@ -29,20 +29,20 @@
|
|||||||
<SolvedTable />
|
<SolvedTable />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi :span="3">
|
<n-gi :span="1">
|
||||||
<n-flex vertical size="large">
|
<n-flex vertical size="large">
|
||||||
<Heatmap />
|
<Heatmap />
|
||||||
<ProgressChart />
|
<ProgressChart />
|
||||||
<EfficiencyChart />
|
<EfficiencyChart />
|
||||||
<DurationChart />
|
<DurationChart />
|
||||||
<AI v-if="aiStore.detailsData.solved.length >= 10" />
|
<AI v-if="aiStore.detailsData.solved.length > 10" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi :span="5">
|
<n-gi :span="2">
|
||||||
<AI
|
<AI
|
||||||
v-if="
|
v-if="
|
||||||
aiStore.detailsData.solved.length > 0 &&
|
aiStore.detailsData.solved.length > 0 &&
|
||||||
aiStore.detailsData.solved.length < 10
|
aiStore.detailsData.solved.length <= 10
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</n-spin>
|
</n-spin>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<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 { formatISO, sub, type Duration } from "date-fns"
|
||||||
import TagsRadarChart from "./components/TagsRadarChart.vue"
|
import TagsRadarChart from "./components/TagsRadarChart.vue"
|
||||||
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
|
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
|
||||||
@@ -68,6 +68,8 @@ import { DURATION_OPTIONS } from "utils/constants"
|
|||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const options = [...DURATION_OPTIONS]
|
const options = [...DURATION_OPTIONS]
|
||||||
|
|
||||||
const subOptions = computed<Duration>(() => {
|
const subOptions = computed<Duration>(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<span class="title-text">AI 帮你分析</span>
|
<span class="title-text">AI 帮你分析</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n-spin :show="aiStore.loading.ai">
|
<n-spin :show="aiStore.loading.ai" :delay="50">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<MdPreview :model-value="aiStore.mdContent" />
|
<MdPreview :model-value="aiStore.mdContent" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx: TooltipItem<"bar">) => {
|
label: (ctx: TooltipItem<"bar" | "line">) => {
|
||||||
const dsLabel = ctx.dataset.label || ""
|
const dsLabel = ctx.dataset.label || ""
|
||||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||||
const idx = Number(ctx.parsed.y)
|
const idx = Number(ctx.parsed.y)
|
||||||
@@ -170,7 +170,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
|
|||||||
}
|
}
|
||||||
return `${dsLabel}: ${ctx.formattedValue}`
|
return `${dsLabel}: ${ctx.formattedValue}`
|
||||||
},
|
},
|
||||||
footer: (items: TooltipItem<"bar">[]) => {
|
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||||||
const barItems = items.filter(
|
const barItems = items.filter(
|
||||||
(item) => (item.dataset as any).yAxisID === "y",
|
(item) => (item.dataset as any).yAxisID === "y",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -125,12 +125,12 @@ const data = computed<ChartData<"line">>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 图表配置
|
// 图表配置
|
||||||
const options = computed<ChartOptions<"line">>(() => {
|
const options = computed(() => {
|
||||||
return {
|
return {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: {
|
interaction: {
|
||||||
mode: "index",
|
mode: "index" as const,
|
||||||
intersect: false,
|
intersect: false,
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
@@ -142,8 +142,8 @@ const options = computed<ChartOptions<"line">>(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
type: "linear",
|
type: "linear" as const,
|
||||||
position: "left",
|
position: "left" as const,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: "平均提交次数(次/题)",
|
text: "平均提交次数(次/题)",
|
||||||
@@ -159,8 +159,8 @@ const options = computed<ChartOptions<"line">>(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
type: "linear",
|
type: "linear" as const,
|
||||||
position: "right",
|
position: "right" as const,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
title: {
|
title: {
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
|
<div align="center" style="display: inline-flex; margin: 0 10px">
|
||||||
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
|
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
|
||||||
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
|
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
|
||||||
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
|
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
|
||||||
|
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-icon size="16" style="cursor: help">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
<div style="max-width: 300px; line-height: 1.4">
|
||||||
|
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
|
||||||
|
<div>使用加权平均方法计算综合等级:</div>
|
||||||
|
<div>• S级 = 4分,A级 = 3分,B级 = 2分,C级 = 1分</div>
|
||||||
|
<div>• 根据平均分数确定最终等级:</div>
|
||||||
|
<div>- S级:≥3.5分</div>
|
||||||
|
<div>- A级:2.5-3.5分</div>
|
||||||
|
<div>- B级:1.5-2.5分</div>
|
||||||
|
<div>- C级:<1.5分</div>
|
||||||
|
</div>
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -14,7 +37,6 @@ img {
|
|||||||
animation: shake 0.5s infinite;
|
animation: shake 0.5s infinite;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
margin: 0 10px -10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
||||||
</template>
|
</template>
|
||||||
<n-spin :show="aiStore.loading.heatmap">
|
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
||||||
<div class="heatmap-container" ref="containerRef">
|
<div class="heatmap-container" ref="containerRef">
|
||||||
<svg
|
<svg
|
||||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
<template>
|
<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
|
<n-data-table
|
||||||
v-if="solvedProblems.length"
|
v-if="submissions.length && !flowcharts.length"
|
||||||
striped
|
striped
|
||||||
:data="solvedProblems"
|
:data="submissions"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:max-height="isDesktop ? 1500 : 500"
|
:max-height="isDesktop ? 1500 : 500"
|
||||||
/>
|
/>
|
||||||
@@ -11,15 +31,18 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { NButton } from "naive-ui"
|
import { NButton } from "naive-ui"
|
||||||
import TagTitle from "./TagTitle.vue"
|
import TagTitle from "./TagTitle.vue"
|
||||||
import { SolvedProblem } from "utils/types"
|
import { FlowchartSummary, SolvedProblem } from "utils/types"
|
||||||
import { useAIStore } from "oj/store/ai"
|
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 router = useRouter()
|
||||||
const aiStore = useAIStore()
|
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>[] = [
|
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||||
{
|
{
|
||||||
title: "完成的题目",
|
title: "完成的题目",
|
||||||
@@ -65,4 +88,39 @@ const columns: DataTableColumn<SolvedProblem>[] = [
|
|||||||
align: "center",
|
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>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
||||||
</template>
|
</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-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||||
<n-gi>
|
<n-gi>
|
||||||
<n-statistic label="当前连续" :value="currentStreak">
|
<n-statistic label="当前连续" :value="currentStreak">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { NTag } from "naive-ui"
|
import { NTag } from "naive-ui"
|
||||||
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { Announcement } from "utils/types"
|
import { Announcement } from "utils/types"
|
||||||
@@ -12,6 +12,9 @@ const total = ref(0)
|
|||||||
const content = ref("")
|
const content = ref("")
|
||||||
const title = ref("")
|
const title = ref("")
|
||||||
const [show, toggleShow] = useToggle(false)
|
const [show, toggleShow] = useToggle(false)
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
limit: 10,
|
limit: 10,
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|||||||
108
src/oj/api.ts
@@ -19,6 +19,7 @@ function filterResult(result: Problem) {
|
|||||||
rate: getACRate(result.accepted_number, result.submission_number),
|
rate: getACRate(result.accepted_number, result.submission_number),
|
||||||
status: "",
|
status: "",
|
||||||
author: result.created_by.username,
|
author: result.created_by.username,
|
||||||
|
allow_flowchart: result.allow_flowchart,
|
||||||
}
|
}
|
||||||
if (result.my_status === null || result.my_status === undefined) {
|
if (result.my_status === null || result.my_status === undefined) {
|
||||||
newResult.status = "not_test"
|
newResult.status = "not_test"
|
||||||
@@ -264,3 +265,110 @@ export function getAIDurationData(end: string, duration: string) {
|
|||||||
export function getAIHeatmapData() {
|
export function getAIHeatmapData() {
|
||||||
return http.get("ai/heatmap")
|
return http.get("ai/heatmap")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 流程图相关API ====================
|
||||||
|
|
||||||
|
export function submitFlowchart(data: {
|
||||||
|
problem_id: number
|
||||||
|
mermaid_code: string
|
||||||
|
flowchart_data: any // 这个是压缩之后的,元数据太长了
|
||||||
|
}) {
|
||||||
|
return http.post("flowchart/submission", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFlowchartSubmission(id: string) {
|
||||||
|
return http.get("flowchart/submission", {
|
||||||
|
params: { id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFlowchartSubmissions(params: {
|
||||||
|
username?: string
|
||||||
|
problem_id?: string
|
||||||
|
myself?: string
|
||||||
|
offset?: number
|
||||||
|
limit?: number
|
||||||
|
}) {
|
||||||
|
return http.get("flowchart/submissions", { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryFlowchartSubmission(submissionId: string) {
|
||||||
|
return http.post("flowchart/submission/retry", {
|
||||||
|
submission_id: submissionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||||||
|
return http.get("flowchart/submission/current", {
|
||||||
|
params: { problem_id: problemId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 题单相关API ====================
|
||||||
|
|
||||||
|
export function getProblemSetList(
|
||||||
|
offset = 0,
|
||||||
|
limit = 10,
|
||||||
|
keyword = "",
|
||||||
|
difficulty = "",
|
||||||
|
status = "",
|
||||||
|
) {
|
||||||
|
return http.get("problemset", {
|
||||||
|
params: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
keyword,
|
||||||
|
difficulty,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProblemSetDetail(id: number) {
|
||||||
|
return http.get(`problemset/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProblemSetProblems(problemSetId: number) {
|
||||||
|
return http.get(`problemset/${problemSetId}/problems`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinProblemSet(problemSetId: number) {
|
||||||
|
return http.post("problemset/progress", {
|
||||||
|
problemset_id: problemSetId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProblemSetProgress(
|
||||||
|
problemSetId: number,
|
||||||
|
problemId: number,
|
||||||
|
submissionId: string,
|
||||||
|
) {
|
||||||
|
return http.put("problemset/progress", {
|
||||||
|
problemset_id: problemSetId,
|
||||||
|
problem_id: problemId,
|
||||||
|
submission_id: submissionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户徽章列表
|
||||||
|
export function getUserBadges(username?: string) {
|
||||||
|
return http.get("user/badges", { params: username ? { username } : {} })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取题单徽章列表
|
||||||
|
export function getProblemSetBadges(problemSetId: number) {
|
||||||
|
return http.get(`problemset/${problemSetId}/badges`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取题单用户进度列表
|
||||||
|
export function getProblemSetUserProgress(
|
||||||
|
problemSetId: number,
|
||||||
|
params?: {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
class_name?: string
|
||||||
|
completion_status?: "" | "completed" | "in_progress" | "not_started"
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return http.get(`problemset/${problemSetId}/users_progress`, { params })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { STORAGE_KEY } from "utils/constants"
|
|
||||||
import storage from "utils/storage"
|
|
||||||
import { Code } from "utils/types"
|
|
||||||
|
|
||||||
export const code = reactive<Code>({
|
|
||||||
value: "",
|
|
||||||
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3",
|
|
||||||
})
|
|
||||||
|
|
||||||
export const input = ref("")
|
|
||||||
export const output = ref("")
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { Problem } from "utils/types"
|
|
||||||
|
|
||||||
export const problem = ref<Problem | null>(null)
|
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
// 同步状态管理 composable
|
import { ref, provide, inject } from "vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态管理 composable
|
||||||
|
* 使用 provide/inject 模式在组件树中共享状态
|
||||||
|
*/
|
||||||
|
|
||||||
export interface SyncStatusState {
|
export interface SyncStatusState {
|
||||||
otherUser?: { name: string; isSuperAdmin: boolean }
|
|
||||||
hadConnection: boolean
|
hadConnection: boolean
|
||||||
|
otherUser?: { name: string; isSuperAdmin: boolean }
|
||||||
|
lastLeftUser?: { name: string; isSuperAdmin: boolean } // 保存离开之人的信息
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供/注入的 key
|
// 提供/注入的 key
|
||||||
export const SYNC_STATUS_KEY = Symbol("syncStatus")
|
export const SYNC_STATUS_KEY = Symbol("syncStatus")
|
||||||
|
|
||||||
// 创建同步状态
|
/**
|
||||||
|
* 创建同步状态实例
|
||||||
|
* 每次调用创建新的状态实例
|
||||||
|
*/
|
||||||
export function createSyncStatus() {
|
export function createSyncStatus() {
|
||||||
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
|
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
|
||||||
const hadConnection = ref(false)
|
const hadConnection = ref(false)
|
||||||
|
const lastLeftUser = ref<{ name: string; isSuperAdmin: boolean }>()
|
||||||
|
|
||||||
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
|
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
|
||||||
|
// 如果之前有其他用户,现在没有了,说明用户离开了
|
||||||
|
if (otherUser.value && !user) {
|
||||||
|
lastLeftUser.value = otherUser.value
|
||||||
|
}
|
||||||
otherUser.value = user
|
otherUser.value = user
|
||||||
if (user) {
|
if (user) {
|
||||||
hadConnection.value = true
|
hadConnection.value = true
|
||||||
@@ -23,24 +37,32 @@ export function createSyncStatus() {
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
otherUser.value = undefined
|
otherUser.value = undefined
|
||||||
hadConnection.value = false
|
hadConnection.value = false
|
||||||
|
lastLeftUser.value = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
otherUser,
|
otherUser,
|
||||||
hadConnection,
|
hadConnection,
|
||||||
|
lastLeftUser,
|
||||||
setOtherUser,
|
setOtherUser,
|
||||||
reset,
|
reset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供同步状态
|
/**
|
||||||
|
* 提供同步状态到子组件
|
||||||
|
* 在父组件中调用
|
||||||
|
*/
|
||||||
export function provideSyncStatus() {
|
export function provideSyncStatus() {
|
||||||
const syncStatus = createSyncStatus()
|
const syncStatus = createSyncStatus()
|
||||||
provide(SYNC_STATUS_KEY, syncStatus)
|
provide(SYNC_STATUS_KEY, syncStatus)
|
||||||
return syncStatus
|
return syncStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注入同步状态
|
/**
|
||||||
|
* 注入同步状态
|
||||||
|
* 在子组件中调用,获取父组件提供的状态
|
||||||
|
*/
|
||||||
export function injectSyncStatus() {
|
export function injectSyncStatus() {
|
||||||
const syncStatus =
|
const syncStatus =
|
||||||
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)
|
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { ContestStatus } from "utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const contestMenuVisible = computed(() => {
|
const contestMenuVisible = computed(() => {
|
||||||
if (contestStore.isContestAdmin) return true
|
if (contestStore.isContestAdmin) return true
|
||||||
if (!contestStore.isPrivate) {
|
if (!contestStore.isPrivate) {
|
||||||
|
|||||||
231
src/oj/contest/components/LineChart.vue
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chart" v-if="showChart">
|
||||||
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Line } from "vue-chartjs"
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "chart.js"
|
||||||
|
import { ContestRank } from "utils/types"
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ranks: ContestRank[]
|
||||||
|
problems: Array<{ id: number; title: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const showChart = computed(() => {
|
||||||
|
const hasRanks = props.ranks.length > 0
|
||||||
|
const hasProblems = props.problems.length >= 3
|
||||||
|
return hasProblems && hasRanks
|
||||||
|
})
|
||||||
|
|
||||||
|
// 预定义的颜色方案 - 更现代和可访问的颜色
|
||||||
|
const colorPalette = [
|
||||||
|
"#3B82F6", // 蓝色
|
||||||
|
"#EF4444", // 红色
|
||||||
|
"#10B981", // 绿色
|
||||||
|
"#F59E0B", // 黄色
|
||||||
|
"#8B5CF6", // 紫色
|
||||||
|
"#EC4899", // 粉色
|
||||||
|
"#06B6D4", // 青色
|
||||||
|
"#84CC16", // 青绿色
|
||||||
|
"#F97316", // 橙色
|
||||||
|
"#6366F1", // 靛蓝色
|
||||||
|
]
|
||||||
|
|
||||||
|
// 数据处理函数
|
||||||
|
const processChartData = () => {
|
||||||
|
if (!props.ranks || props.ranks.length === 0) {
|
||||||
|
return {
|
||||||
|
labels: [],
|
||||||
|
datasets: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取前10名用户的数据
|
||||||
|
const topUsers = props.ranks.slice(0, 10)
|
||||||
|
|
||||||
|
// 获取所有题目ID(从所有用户的submission_info中收集)
|
||||||
|
const allProblemIds = new Set<string>()
|
||||||
|
topUsers.forEach((rank) => {
|
||||||
|
Object.keys(rank.submission_info).forEach((problemId) => {
|
||||||
|
allProblemIds.add(problemId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按题目ID排序
|
||||||
|
const problemIds = Array.from(allProblemIds).sort()
|
||||||
|
|
||||||
|
// 创建题目标签
|
||||||
|
const labels = problemIds.map((id) => {
|
||||||
|
if (props.problems) {
|
||||||
|
const problem = props.problems.find((p) => p.id.toString() === id)
|
||||||
|
return problem ? problem.title : `题目${id}`
|
||||||
|
}
|
||||||
|
return `题目${id}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到所有用户中最早的提交时间
|
||||||
|
let earliestTime = Infinity
|
||||||
|
topUsers.forEach((rank) => {
|
||||||
|
Object.values(rank.submission_info).forEach((submissionInfo) => {
|
||||||
|
if (submissionInfo.is_ac && submissionInfo.ac_time < earliestTime) {
|
||||||
|
earliestTime = submissionInfo.ac_time
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果没有找到任何通过记录,使用0作为基准
|
||||||
|
if (earliestTime === Infinity) {
|
||||||
|
earliestTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个用户创建数据集
|
||||||
|
const datasets = topUsers.map((rank, userIndex) => {
|
||||||
|
const userData = problemIds.map((problemId) => {
|
||||||
|
const submissionInfo = rank.submission_info[problemId]
|
||||||
|
if (!submissionInfo || !submissionInfo.is_ac) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return submissionInfo.ac_time - earliestTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const actualRank = userIndex + 1
|
||||||
|
const colorIndex = userIndex % colorPalette.length
|
||||||
|
const color = colorPalette[colorIndex]
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `第${actualRank}名: ${rank.user.username}`,
|
||||||
|
data: userData,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color + "20",
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 6,
|
||||||
|
pointHoverRadius: 8,
|
||||||
|
pointBackgroundColor: color,
|
||||||
|
pointBorderColor: "#fff",
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
spanGaps: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听数据变化,重新处理
|
||||||
|
watch(
|
||||||
|
() => [props.ranks, props.problems],
|
||||||
|
() => {
|
||||||
|
if (props.ranks && props.ranks.length > 0) {
|
||||||
|
// 数据变化时重新处理
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
return processChartData()
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: "top" as const,
|
||||||
|
maxHeight: 80,
|
||||||
|
labels: {
|
||||||
|
boxWidth: 12,
|
||||||
|
boxHeight: 12,
|
||||||
|
padding: 8,
|
||||||
|
usePointStyle: true,
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: "index" as const,
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: function (context: any) {
|
||||||
|
return `题目: ${context[0].label}`
|
||||||
|
},
|
||||||
|
label: function (context: any) {
|
||||||
|
const value = context.parsed.y
|
||||||
|
const label = context.dataset.label
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return `${label}: 未通过`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(value / 3600)
|
||||||
|
const minutes = Math.floor((value % 3600) / 60)
|
||||||
|
const seconds = Math.floor(value % 60)
|
||||||
|
|
||||||
|
let timeStr = ""
|
||||||
|
if (hours > 0) timeStr += `${hours}小时`
|
||||||
|
if (minutes > 0) timeStr += `${minutes}分钟`
|
||||||
|
if (seconds > 0 || timeStr === "") timeStr += `${seconds}秒`
|
||||||
|
|
||||||
|
return `${label}: +${timeStr}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "相对通过时间",
|
||||||
|
},
|
||||||
|
min: 0,
|
||||||
|
ticks: {
|
||||||
|
callback: function (value: any) {
|
||||||
|
const hours = Math.floor(value / 3600)
|
||||||
|
const minutes = Math.floor((value % 3600) / 60)
|
||||||
|
const seconds = Math.floor(value % 60)
|
||||||
|
|
||||||
|
if (hours > 0) return `+${hours}h${minutes}m`
|
||||||
|
if (minutes > 0) return `+${minutes}m${seconds}s`
|
||||||
|
return `+${seconds}s`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart {
|
||||||
|
height: 500px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useContestStore } from "../store/contest"
|
import { useContestStore } from "../store/contest"
|
||||||
import ContestInfo from "./components/ContestInfo.vue"
|
import ContestInfo from "./components/ContestInfo.vue"
|
||||||
import ContestMenu from "./components/ContestMenu.vue"
|
import ContestMenu from "./components/ContestMenu.vue"
|
||||||
@@ -12,6 +12,8 @@ const props = defineProps<{
|
|||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const password = ref("")
|
const password = ref("")
|
||||||
|
|
||||||
async function check() {
|
async function check() {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { duration, parseTime } from "utils/functions"
|
|||||||
import { Contest } from "utils/types"
|
import { Contest } from "utils/types"
|
||||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { toggleLogin } from "shared/composables/modal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { CONTEST_STATUS, ContestType } from "utils/constants"
|
import { CONTEST_STATUS, ContestType } from "utils/constants"
|
||||||
@@ -14,6 +14,7 @@ import { renderTableTitle } from "utils/renders"
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const authStore = useAuthModalStore()
|
||||||
|
|
||||||
interface ContestQuery {
|
interface ContestQuery {
|
||||||
keyword: string
|
keyword: string
|
||||||
@@ -120,7 +121,7 @@ function rowProps(row: Contest) {
|
|||||||
style: "cursor: pointer",
|
style: "cursor: pointer",
|
||||||
onClick() {
|
onClick() {
|
||||||
if (!userStore.isAuthed && row.contest_type === ContestType.private) {
|
if (!userStore.isAuthed && row.contest_type === ContestType.private) {
|
||||||
toggleLogin(true)
|
authStore.openLoginModal()
|
||||||
} else {
|
} else {
|
||||||
router.push("/contest/" + row.id)
|
router.push("/contest/" + row.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ContestStatus } from "utils/constants"
|
|||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { ContestRank, ProblemFiltered } from "utils/types"
|
import { ContestRank, ProblemFiltered } from "utils/types"
|
||||||
import AcAndSubmission from "../components/AcAndSubmission.vue"
|
import AcAndSubmission from "../components/AcAndSubmission.vue"
|
||||||
|
import LineChart from "../components/LineChart.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contestID: string
|
contestID: string
|
||||||
@@ -26,7 +27,7 @@ const total = ref(0)
|
|||||||
const data = ref<ContestRank[]>([])
|
const data = ref<ContestRank[]>([])
|
||||||
const chart = ref<ContestRank[]>([])
|
const chart = ref<ContestRank[]>([])
|
||||||
const problems = ref<ProblemFiltered[]>([])
|
const problems = ref<ProblemFiltered[]>([])
|
||||||
const [autoRefresh] = useToggle()
|
const [autoRefresh] = useToggle(true)
|
||||||
const { resume, pause } = useIntervalFn(
|
const { resume, pause } = useIntervalFn(
|
||||||
() => {
|
() => {
|
||||||
query.page = 1
|
query.page = 1
|
||||||
@@ -82,13 +83,13 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
|
|||||||
align: "center",
|
align: "center",
|
||||||
render: (row) => h(AcAndSubmission, { rank: row }),
|
render: (row) => h(AcAndSubmission, { rank: row }),
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// title: "总时间",
|
title: "总时间",
|
||||||
// key: "total_time",
|
key: "total_time",
|
||||||
// width: 120,
|
width: 120,
|
||||||
// align: "center",
|
align: "center",
|
||||||
// render: (row) => secondsToDuration(row.total_time),
|
render: (row) => secondsToDuration(row.total_time),
|
||||||
// },
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
async function listRanks() {
|
async function listRanks() {
|
||||||
@@ -207,6 +208,10 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 排名变化图表 -->
|
||||||
|
<LineChart :ranks="chart" :problems="problems" v-if="chart.length > 0" />
|
||||||
|
|
||||||
|
<!-- 排名表格 -->
|
||||||
<n-data-table
|
<n-data-table
|
||||||
striped
|
striped
|
||||||
:single-line="false"
|
:single-line="false"
|
||||||
|
|||||||
7
src/oj/flowchart/index.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import FlowchartEditor from "shared/components/FlowchartEditor/index.vue"
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<FlowchartEditor />
|
||||||
|
</template>
|
||||||
|
<style scoped></style>
|
||||||
@@ -105,16 +105,18 @@ import { MdPreview } from "md-editor-v3"
|
|||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
import { Tutorial } from "utils/types"
|
import { Tutorial } from "utils/types"
|
||||||
import { getTutorial, getTutorials } from "../api"
|
import { getTutorial, getTutorials } from "../api"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
const isDark = useDark()
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(
|
const CodeEditor = defineAsyncComponent(
|
||||||
() => import("shared/components/CodeEditor.vue"),
|
() => import("shared/components/CodeEditor.vue"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDark = useDark()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const step = computed(() => {
|
const step = computed(() => {
|
||||||
if (!route.params.step || !route.params.step.length) return 1
|
if (!route.params.step || !route.params.step.length) return 1
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { code } from "oj/composables/code"
|
import { storeToRefs } from "pinia"
|
||||||
import { problem } from "oj/composables/problem"
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { SOURCES } from "utils/constants"
|
import { SOURCES } from "utils/constants"
|
||||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import { provideSyncStatus } from "oj/composables/syncStatus"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import { LANGUAGE } from "utils/types"
|
import { LANGUAGE } from "utils/types"
|
||||||
import Form from "./Form.vue"
|
import Form from "./Form.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
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 contestID = route.params.contestID || null
|
||||||
const storageKey = computed(
|
const storageKey = computed(
|
||||||
() =>
|
() =>
|
||||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
|
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const editorHeight = computed(() =>
|
const editorHeight = computed(() =>
|
||||||
@@ -22,10 +34,11 @@ const editorHeight = computed(() =>
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const savedCode = storage.get(storageKey.value)
|
const savedCode = storage.get(storageKey.value)
|
||||||
code.value =
|
codeStore.setCode(
|
||||||
savedCode ||
|
savedCode ||
|
||||||
problem.value!.template[code.language] ||
|
problem.value!.template[codeStore.code.language] ||
|
||||||
SOURCES[code.language]
|
SOURCES[codeStore.code.language],
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const changeCode = (v: string) => {
|
const changeCode = (v: string) => {
|
||||||
@@ -34,10 +47,12 @@ const changeCode = (v: string) => {
|
|||||||
|
|
||||||
const changeLanguage = (v: LANGUAGE) => {
|
const changeLanguage = (v: LANGUAGE) => {
|
||||||
const savedCode = storage.get(storageKey.value)
|
const savedCode = storage.get(storageKey.value)
|
||||||
code.value =
|
codeStore.setCode(
|
||||||
savedCode && storageKey.value.split("_").pop() === v
|
savedCode && storageKey.value.split("_").pop() === v
|
||||||
? savedCode
|
? savedCode
|
||||||
: problem.value!.template[code.language] || SOURCES[code.language]
|
: problem.value!.template[codeStore.code.language] ||
|
||||||
|
SOURCES[codeStore.code.language],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -45,8 +60,8 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
v-model:value="code.value"
|
v-model:value="codeStore.code.value"
|
||||||
:language="code.language"
|
:language="codeStore.code.language"
|
||||||
:height="editorHeight"
|
:height="editorHeight"
|
||||||
@update:model-value="changeCode"
|
@update:model-value="changeCode"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { code, input, output } from "oj/composables/code"
|
import { storeToRefs } from "pinia"
|
||||||
import { problem } from "oj/composables/problem"
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { SOURCES } from "utils/constants"
|
import { SOURCES } from "utils/constants"
|
||||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
@@ -13,17 +14,24 @@ const message = useMessage()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const contestID = !!route.params.contestID ? route.params.contestID : null
|
const contestID = !!route.params.contestID ? route.params.contestID : null
|
||||||
|
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
const { input, output } = storeToRefs(codeStore)
|
||||||
|
const { problem } = storeToRefs(problemStore)
|
||||||
|
|
||||||
const storageKey = computed(
|
const storageKey = computed(
|
||||||
() =>
|
() =>
|
||||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
|
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (storage.get(storageKey.value)) {
|
if (storage.get(storageKey.value)) {
|
||||||
code.value = storage.get(storageKey.value)
|
codeStore.setCode(storage.get(storageKey.value))
|
||||||
} else {
|
} else {
|
||||||
code.value =
|
codeStore.setCode(
|
||||||
problem.value!.template[code.language] || SOURCES[code.language]
|
problem.value!.template[codeStore.code.language] ||
|
||||||
|
SOURCES[codeStore.code.language],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,26 +44,31 @@ function changeLanguage(v: string) {
|
|||||||
storage.get(storageKey.value) &&
|
storage.get(storageKey.value) &&
|
||||||
storageKey.value.split("_").pop() === v
|
storageKey.value.split("_").pop() === v
|
||||||
) {
|
) {
|
||||||
code.value = storage.get(storageKey.value)
|
codeStore.setCode(storage.get(storageKey.value))
|
||||||
} else {
|
} else {
|
||||||
code.value =
|
codeStore.setCode(
|
||||||
problem.value!.template[code.language] || SOURCES[code.language]
|
problem.value!.template[codeStore.code.language] ||
|
||||||
|
SOURCES[codeStore.code.language],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
const success = await copyToClipboard(code.value)
|
const success = await copyToClipboard(codeStore.code.value)
|
||||||
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = () => {
|
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)
|
storage.remove(storageKey.value)
|
||||||
message.success("代码重置成功")
|
message.success("代码重置成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
const runCode = async () => {
|
const runCode = async () => {
|
||||||
const res = await createTestSubmission(code, input.value)
|
const res = await createTestSubmission(codeStore.code, input.value)
|
||||||
output.value = res.output
|
output.value = res.output
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +94,7 @@ const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
|||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="code.language"
|
v-model:value="codeStore.code.language"
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
:options="languageOptions"
|
:options="languageOptions"
|
||||||
@update:value="changeLanguage"
|
@update:value="changeLanguage"
|
||||||
@@ -93,9 +106,9 @@ const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
v-model:value="code.value"
|
v-model:value="codeStore.code.value"
|
||||||
@update:model-value="changeCode"
|
@update:model-value="changeCode"
|
||||||
:language="code.language"
|
:language="codeStore.code.language"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { copyToClipboard } from "utils/functions"
|
import { storeToRefs } from "pinia"
|
||||||
import { code } from "oj/composables/code"
|
import { copyToClipboard, utoa } from "utils/functions"
|
||||||
import { problem } from "oj/composables/problem"
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { injectSyncStatus } from "oj/composables/syncStatus"
|
import { injectSyncStatus } from "oj/composables/syncStatus"
|
||||||
import { SYNC_MESSAGES } from "shared/composables/sync"
|
import { SYNC_MESSAGES } from "shared/composables/sync"
|
||||||
import { LANGUAGE_SHOW_VALUE, SOURCES, STORAGE_KEY } from "utils/constants"
|
import {
|
||||||
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
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 { useUserStore } from "shared/store/user"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import { LANGUAGE } from "utils/types"
|
import { LANGUAGE } from "utils/types"
|
||||||
import Submit from "./Submit.vue"
|
|
||||||
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||||
import IconButton from "shared/components/IconButton.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 {
|
interface Props {
|
||||||
storageKey: string
|
storageKey: string
|
||||||
@@ -34,6 +47,11 @@ const message = useMessage()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
const { problem, languages } = storeToRefs(problemStore)
|
||||||
|
|
||||||
|
const { isMobile, isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
||||||
const statisticPanel = ref(false)
|
const statisticPanel = ref(false)
|
||||||
@@ -42,7 +60,11 @@ const statisticPanel = ref(false)
|
|||||||
const isContestMode = computed(() => route.name === "contest problem")
|
const isContestMode = computed(() => route.name === "contest problem")
|
||||||
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
||||||
const showSyncFeature = computed(
|
const showSyncFeature = computed(
|
||||||
() => isDesktop.value && userStore.isAuthed && !isContestMode.value,
|
() =>
|
||||||
|
isDesktop.value &&
|
||||||
|
userStore.isAuthed &&
|
||||||
|
codeStore.code.language !== "Flowchart" &&
|
||||||
|
!isContestMode.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const menu = computed<DropdownOption[]>(() => [
|
const menu = computed<DropdownOption[]>(() => [
|
||||||
@@ -51,27 +73,35 @@ const menu = computed<DropdownOption[]>(() => [
|
|||||||
{ label: "重置代码", key: "reset" },
|
{ label: "重置代码", key: "reset" },
|
||||||
])
|
])
|
||||||
|
|
||||||
const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
const showGoSubmissionButton = computed(() => {
|
||||||
(it) => ({
|
if (isContestMode.value) return true
|
||||||
label: () =>
|
else if (userStore.isAdminRole) return true
|
||||||
h("div", { style: "display: flex; align-items: center;" }, [
|
else if (userStore.showSubmissions) return true
|
||||||
h("img", {
|
else return false
|
||||||
src: `/${it}.svg`,
|
})
|
||||||
style: { width: "16px", height: "16px", marginRight: "8px" },
|
|
||||||
}),
|
const languageOptions: DropdownOption[] = languages.value.map((it) => ({
|
||||||
LANGUAGE_SHOW_VALUE[it],
|
label: () =>
|
||||||
]),
|
h(NFlex, { align: "center" }, () => [
|
||||||
value: it,
|
h(Icon, {
|
||||||
}),
|
icon: ICON_SET[it],
|
||||||
)
|
width: 16,
|
||||||
|
}),
|
||||||
|
LANGUAGE_SHOW_VALUE[it],
|
||||||
|
]),
|
||||||
|
value: it,
|
||||||
|
}))
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
const success = await copyToClipboard(code.value)
|
const success = await copyToClipboard(codeStore.code.value)
|
||||||
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = () => {
|
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)
|
storage.remove(props.storageKey)
|
||||||
message.success("代码重置成功")
|
message.success("代码重置成功")
|
||||||
}
|
}
|
||||||
@@ -82,7 +112,15 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goTestCat = () => {
|
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 = () => {
|
const goSubmissions = () => {
|
||||||
@@ -116,22 +154,30 @@ defineExpose({
|
|||||||
syncEnabled.value = false
|
syncEnabled.value = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!languages.value.includes(codeStore.code.language)) {
|
||||||
|
codeStore.code.language = "Python3"
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="code.language"
|
v-model:value="codeStore.code.language"
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
:options="languageOptions"
|
:options="languageOptions"
|
||||||
@update:value="changeLanguage"
|
@update:value="changeLanguage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Submit />
|
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
|
||||||
|
|
||||||
|
<SubmitCode v-else />
|
||||||
|
|
||||||
<n-button
|
<n-button
|
||||||
v-if="!userStore.isSuperAdmin && userStore.showSubmissions"
|
v-if="showGoSubmissionButton"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
@click="goSubmissions"
|
@click="goSubmissions"
|
||||||
>
|
>
|
||||||
@@ -146,9 +192,19 @@ defineExpose({
|
|||||||
{{ isDesktop ? "统计信息" : "统计" }}
|
{{ isDesktop ? "统计信息" : "统计" }}
|
||||||
</n-button>
|
</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-button :size="buttonSize">操作</n-button>
|
||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
|
|
||||||
@@ -184,7 +240,7 @@ defineExpose({
|
|||||||
"
|
"
|
||||||
type="warning"
|
type="warning"
|
||||||
>
|
>
|
||||||
{{ SYNC_MESSAGES.STUDENT_LEFT }}
|
{{ SYNC_MESSAGES.STUDENT_LEFT(syncStatus.lastLeftUser.value?.name) }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -107,7 +107,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
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 { DIFFICULTY } from "utils/constants"
|
||||||
import { createComment, getComment, getCommentStatistics } from "oj/api"
|
import { createComment, getComment, getCommentStatistics } from "oj/api"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
@@ -121,6 +122,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
const { problem } = storeToRefs(problemStore)
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { code } from "oj/composables/code"
|
import { storeToRefs } from "pinia"
|
||||||
import { problem } from "oj/composables/problem"
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { createTestSubmission } from "utils/judge"
|
import { createTestSubmission } from "utils/judge"
|
||||||
import { Problem, ProblemStatus } from "utils/types"
|
import { Problem, ProblemStatus } from "utils/types"
|
||||||
import Copy from "shared/components/Copy.vue"
|
import Copy from "shared/components/Copy.vue"
|
||||||
@@ -17,6 +18,13 @@ type Sample = Problem["samples"][number] & {
|
|||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const style = computed(() => "color: " + theme.value.primaryColor)
|
const style = computed(() => "color: " + theme.value.primaryColor)
|
||||||
|
|
||||||
|
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 !== 0 && my_status !== null: 尝试过但未通过
|
// my_status !== 0 && my_status !== null: 尝试过但未通过
|
||||||
@@ -46,7 +54,7 @@ async function test(sample: Sample, index: number) {
|
|||||||
}
|
}
|
||||||
return sample
|
return sample
|
||||||
})
|
})
|
||||||
const res = await createTestSubmission(code, sample.input)
|
const res = await createTestSubmission(codeStore.code, sample.input)
|
||||||
samples.value = samples.value.map((sample) => {
|
samples.value = samples.value.map((sample) => {
|
||||||
if (sample.id === index) {
|
if (sample.id === index) {
|
||||||
const status =
|
const status =
|
||||||
@@ -101,24 +109,27 @@ function type(status: ProblemStatus) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="problem" class="problemContent">
|
<div v-if="problem" class="problemContent">
|
||||||
<!-- 已通过 -->
|
<template v-if="!problemSetId">
|
||||||
<n-alert
|
<!-- 已通过 -->
|
||||||
class="status-alert"
|
<n-alert
|
||||||
v-if="problem.my_status === 0"
|
class="status-alert"
|
||||||
type="success"
|
v-if="problem.my_status === 0"
|
||||||
title="🎉 本 题 已 经 被 你 解 决 啦"
|
type="success"
|
||||||
>
|
title="🎉 本 题 已 经 被 你 解 决 啦"
|
||||||
</n-alert>
|
>
|
||||||
|
</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-flex align="center">
|
||||||
<n-tag>{{ problem._id }}</n-tag>
|
<n-tag>{{ problem._id }}</n-tag>
|
||||||
<h2 class="problemTitle">{{ problem.title }}</h2>
|
<h2 class="problemTitle">{{ problem.title }}</h2>
|
||||||
@@ -326,7 +337,7 @@ function type(status: ProblemStatus) {
|
|||||||
|
|
||||||
.content :deep(img) {
|
.content :deep(img) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :deep(a) {
|
.content :deep(a) {
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { code } from "oj/composables/code"
|
import { storeToRefs } from "pinia"
|
||||||
import { problem } from "oj/composables/problem"
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { provideSyncStatus } from "oj/composables/syncStatus"
|
import { provideSyncStatus } from "oj/composables/syncStatus"
|
||||||
import { SOURCES } from "utils/constants"
|
import { SOURCES } from "utils/constants"
|
||||||
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
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 storage from "utils/storage"
|
||||||
import { LANGUAGE } from "utils/types"
|
import { LANGUAGE } from "utils/types"
|
||||||
import Form from "./Form.vue"
|
import Form from "./Form.vue"
|
||||||
|
|
||||||
|
const FlowchartEditor = defineAsyncComponent(
|
||||||
|
() => import("shared/components/FlowchartEditor/index.vue"),
|
||||||
|
)
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
|
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)
|
const sync = ref(false)
|
||||||
// 提供同步状态给子组件使用
|
// 提供同步状态给子组件使用
|
||||||
@@ -19,7 +31,7 @@ const syncStatus = provideSyncStatus()
|
|||||||
const contestID = route.params.contestID || null
|
const contestID = route.params.contestID || null
|
||||||
const storageKey = computed(
|
const storageKey = computed(
|
||||||
() =>
|
() =>
|
||||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`,
|
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const editorHeight = computed(() =>
|
const editorHeight = computed(() =>
|
||||||
@@ -28,10 +40,11 @@ const editorHeight = computed(() =>
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const savedCode = storage.get(storageKey.value)
|
const savedCode = storage.get(storageKey.value)
|
||||||
code.value =
|
codeStore.setCode(
|
||||||
savedCode ||
|
savedCode ||
|
||||||
problem.value!.template[code.language] ||
|
problem.value!.template[codeStore.code.language] ||
|
||||||
SOURCES[code.language]
|
SOURCES[codeStore.code.language],
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const changeCode = (v: string) => {
|
const changeCode = (v: string) => {
|
||||||
@@ -40,10 +53,12 @@ const changeCode = (v: string) => {
|
|||||||
|
|
||||||
const changeLanguage = (v: LANGUAGE) => {
|
const changeLanguage = (v: LANGUAGE) => {
|
||||||
const savedCode = storage.get(storageKey.value)
|
const savedCode = storage.get(storageKey.value)
|
||||||
code.value =
|
codeStore.setCode(
|
||||||
savedCode && storageKey.value.split("_").pop() === v
|
savedCode && storageKey.value.split("_").pop() === v
|
||||||
? savedCode
|
? savedCode
|
||||||
: problem.value!.template[code.language] || SOURCES[code.language]
|
: problem.value!.template[codeStore.code.language] ||
|
||||||
|
SOURCES[codeStore.code.language],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSync = (value: boolean) => {
|
const toggleSync = (value: boolean) => {
|
||||||
@@ -64,6 +79,9 @@ const handleSyncStatusChange = (status: {
|
|||||||
}) => {
|
}) => {
|
||||||
syncStatus.setOtherUser(status.otherUser)
|
syncStatus.setOtherUser(status.otherUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提供FlowchartEditor的ref给子组件
|
||||||
|
provide("flowchartEditorRef", flowchartEditorRef)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -75,11 +93,16 @@ const handleSyncStatusChange = (status: {
|
|||||||
@change-language="changeLanguage"
|
@change-language="changeLanguage"
|
||||||
@toggle-sync="toggleSync"
|
@toggle-sync="toggleSync"
|
||||||
/>
|
/>
|
||||||
|
<FlowchartEditor
|
||||||
|
v-if="codeStore.code.language === 'Flowchart'"
|
||||||
|
ref="flowchartEditorRef"
|
||||||
|
/>
|
||||||
<SyncCodeEditor
|
<SyncCodeEditor
|
||||||
v-model:value="code.value"
|
v-else
|
||||||
|
v-model:value="codeStore.code.value"
|
||||||
:sync="sync"
|
:sync="sync"
|
||||||
:problem="problem!._id"
|
:problem="problem!._id"
|
||||||
:language="code.language"
|
:language="codeStore.code.language"
|
||||||
:height="editorHeight"
|
:height="editorHeight"
|
||||||
@update:model-value="changeCode"
|
@update:model-value="changeCode"
|
||||||
@sync-closed="handleSyncClosed"
|
@sync-closed="handleSyncClosed"
|
||||||
|
|||||||
45
src/oj/problem/components/ProblemFlowchart.vue
Normal 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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
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 { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
||||||
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
|
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
|
||||||
import { Pie } from "vue-chartjs"
|
import { Pie } from "vue-chartjs"
|
||||||
@@ -13,11 +14,16 @@ import {
|
|||||||
Colors,
|
Colors,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { getProblemBeatRate } from "oj/api"
|
import { getProblemBeatRate } from "oj/api"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
// 仅注册饼图所需的 Chart.js 组件
|
// 仅注册饼图所需的 Chart.js 组件
|
||||||
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
|
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
|
||||||
|
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
const { problem } = storeToRefs(problemStore)
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const beatRate = ref("0")
|
const beatRate = ref("0")
|
||||||
|
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
@@ -119,18 +125,16 @@ onMounted(getBeatRate)
|
|||||||
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="10" :y-gap="10" class="cards">
|
<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-gi v-for="item in numbers" :key="item.content">
|
||||||
<n-card hoverable>
|
<n-card hoverable>
|
||||||
<n-flex align="center">
|
<n-flex vertical align="center">
|
||||||
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
|
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
|
||||||
<div>
|
<n-h2 class="number">
|
||||||
<n-h2 class="number">
|
<n-number-animation
|
||||||
<n-number-animation
|
:to="item.title"
|
||||||
:to="item.title"
|
:precision="item.int ? 0 : 2"
|
||||||
:precision="item.int ? 0 : 2"
|
/>
|
||||||
/>
|
<span v-if="item.suffix">{{ item.suffix }}</span>
|
||||||
<span v-if="item.suffix">{{ item.suffix }}</span>
|
</n-h2>
|
||||||
</n-h2>
|
<n-h4 class="number-label">{{ item.content }}</n-h4>
|
||||||
<n-h4 class="number-label">{{ item.content }}</n-h4>
|
|
||||||
</div>
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -145,7 +149,7 @@ onMounted(getBeatRate)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.number {
|
.number {
|
||||||
margin-bottom: 0;
|
margin: 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
src/oj/problem/components/ProblemListTitle.vue
Normal 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>
|
||||||
@@ -8,10 +8,25 @@ import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { Submission } from "utils/types"
|
import { Submission } from "utils/types"
|
||||||
|
import SubmissionDetail from "oj/submission/detail.vue"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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>[] = [
|
const columns: DataTableColumn<Submission>[] = [
|
||||||
{
|
{
|
||||||
@@ -25,22 +40,17 @@ const columns: DataTableColumn<Submission>[] = [
|
|||||||
key: "id",
|
key: "id",
|
||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
if (row.show_link) {
|
return h(
|
||||||
return h(
|
NButton,
|
||||||
NButton,
|
{
|
||||||
{
|
text: true,
|
||||||
text: true,
|
type: "info",
|
||||||
type: "info",
|
onClick: () => {
|
||||||
onClick: () => {
|
showCodePanel(row.id, <string>route.params.problemID ?? "")
|
||||||
const data = router.resolve("/submission/" + row.id)
|
|
||||||
window.open(data.href, "_blank")
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
() => row.id.slice(0, 12),
|
},
|
||||||
)
|
() => row.id.slice(0, 12),
|
||||||
} else {
|
)
|
||||||
return row.id.slice(0, 12)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -258,6 +268,21 @@ watch(query, listSubmissions)
|
|||||||
v-model:page="query.page"
|
v-model:page="query.page"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
99
src/oj/problem/components/SubmissionResult.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
200
src/oj/problem/components/SubmitCode.vue
Normal 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>
|
||||||
360
src/oj/problem/components/SubmitFlowchart.vue
Normal 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>
|
||||||
228
src/oj/problem/composables/useFireworks.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/oj/problem/composables/useMermaidConverter.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/oj/problem/composables/useSubmissionMonitor.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getProblem } from "oj/api"
|
import { getProblem } from "oj/api"
|
||||||
import { ScreenMode } from "utils/constants"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
import { storeToRefs } from "pinia"
|
||||||
import {
|
import { useProblemStore } from "oj/store/problem"
|
||||||
bothAndProblem,
|
import { useScreenModeStore } from "shared/store/screenMode"
|
||||||
resetScreenMode,
|
|
||||||
screenMode,
|
|
||||||
} from "shared/composables/switchScreen"
|
|
||||||
import { problem } from "../composables/problem"
|
|
||||||
|
|
||||||
const ProblemEditor = defineAsyncComponent(
|
const ProblemEditor = defineAsyncComponent(
|
||||||
() => import("./components/ProblemEditor.vue"),
|
() => import("./components/ProblemEditor.vue"),
|
||||||
@@ -30,22 +26,37 @@ const ProblemSubmission = defineAsyncComponent(
|
|||||||
const ProblemComment = defineAsyncComponent(
|
const ProblemComment = defineAsyncComponent(
|
||||||
() => import("./components/ProblemComment.vue"),
|
() => import("./components/ProblemComment.vue"),
|
||||||
)
|
)
|
||||||
|
const ProblemFlowchart = defineAsyncComponent(
|
||||||
|
() => import("./components/ProblemFlowchart.vue"),
|
||||||
|
)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
problemID: string
|
problemID: string
|
||||||
contestID?: string
|
contestID?: string
|
||||||
|
problemSetId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
contestID: "",
|
contestID: "",
|
||||||
|
problemSetId: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
const errMsg = ref("无数据")
|
const errMsg = ref("无数据")
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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 tabOptions = computed(() => {
|
||||||
const options: string[] = ["content"]
|
const options: string[] = ["content"]
|
||||||
|
if (problem.value?.show_flowchart) {
|
||||||
|
options.push("flowchart")
|
||||||
|
}
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
options.push("editor")
|
options.push("editor")
|
||||||
}
|
}
|
||||||
@@ -59,7 +70,7 @@ const tabOptions = computed(() => {
|
|||||||
|
|
||||||
const currentTab = ref("content")
|
const currentTab = ref("content")
|
||||||
|
|
||||||
const shouldUseProblemEditor = computed(() => route.name === "problem")
|
const inProblem = computed(() => route.name === "problem")
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => route.query.tab, () => tabOptions.value],
|
[() => route.query.tab, () => tabOptions.value],
|
||||||
@@ -81,7 +92,7 @@ watch(currentTab, (tab) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
resetScreenMode()
|
screenModeStore.resetScreenMode()
|
||||||
try {
|
try {
|
||||||
const res = await getProblem(props.problemID, props.contestID)
|
const res = await getProblem(props.problemID, props.contestID)
|
||||||
problem.value = res.data
|
problem.value = res.data
|
||||||
@@ -96,33 +107,49 @@ onMounted(init)
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
problem.value = null
|
problem.value = null
|
||||||
errMsg.value = "无数据"
|
errMsg.value = "无数据"
|
||||||
resetScreenMode()
|
screenModeStore.resetScreenMode()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(isMobile, (value) => {
|
watch(isMobile, (value) => {
|
||||||
if (value) resetScreenMode()
|
if (value) screenModeStore.resetScreenMode()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-grid
|
<n-grid v-if="problem" x-gap="16" :cols="screenModeStore.isBothMode ? 7 : 1">
|
||||||
v-if="problem"
|
<n-gi :span="isDesktop ? 3 : 7" v-if="shouldShowProblem">
|
||||||
x-gap="16"
|
|
||||||
:cols="screenMode === ScreenMode.both ? 2 : 1"
|
|
||||||
>
|
|
||||||
<n-gi :span="isDesktop ? 1 : 2" v-if="bothAndProblem">
|
|
||||||
<n-scrollbar v-if="isDesktop" style="max-height: calc(100vh - 92px)">
|
<n-scrollbar v-if="isDesktop" style="max-height: calc(100vh - 92px)">
|
||||||
<n-tabs v-model:value="currentTab" type="segment">
|
<n-tabs v-model:value="currentTab" type="segment">
|
||||||
<n-tab-pane name="content" tab="题目描述">
|
<n-tab-pane name="content" tab="题目描述">
|
||||||
<ProblemContent />
|
<ProblemContent />
|
||||||
</n-tab-pane>
|
</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 />
|
<ProblemInfo />
|
||||||
</n-tab-pane>
|
</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 />
|
<ProblemComment />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="submission" tab="我的提交">
|
<n-tab-pane
|
||||||
|
name="submission"
|
||||||
|
tab="我的提交"
|
||||||
|
:disabled="!!props.problemSetId"
|
||||||
|
>
|
||||||
<ProblemSubmission />
|
<ProblemSubmission />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
@@ -131,26 +158,36 @@ watch(isMobile, (value) => {
|
|||||||
<n-tab-pane name="content" tab="描述">
|
<n-tab-pane name="content" tab="描述">
|
||||||
<ProblemContent />
|
<ProblemContent />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="editor" tab="编辑">
|
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程">
|
||||||
<ProblemEditor v-if="shouldUseProblemEditor" />
|
<ProblemFlowchart />
|
||||||
<ContestEditor v-else />
|
|
||||||
</n-tab-pane>
|
</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 />
|
<ProblemInfo />
|
||||||
</n-tab-pane>
|
</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 />
|
<ProblemComment />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="submission" tab="提交">
|
<n-tab-pane
|
||||||
|
name="submission"
|
||||||
|
tab="提交"
|
||||||
|
:disabled="!!props.problemSetId"
|
||||||
|
>
|
||||||
<ProblemSubmission />
|
<ProblemSubmission />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi v-if="isDesktop && screenMode === ScreenMode.both">
|
<n-gi :span="4" v-if="isDesktop && screenModeStore.isBothMode">
|
||||||
<ProblemEditor v-if="shouldUseProblemEditor" />
|
<component :is="inProblem ? ProblemEditor : ContestEditor" />
|
||||||
<ContestEditor v-else />
|
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<n-gi v-if="isDesktop && screenMode === ScreenMode.code">
|
<n-gi v-if="isDesktop && screenModeStore.isCodeOnlyMode">
|
||||||
<EditorForTest />
|
<EditorForTest />
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { NSpace, NTag } from "naive-ui"
|
import { NFlex, NTag } from "naive-ui"
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import { getProblemList, getRandomProblemID } from "oj/api"
|
import { getProblemList, getRandomProblemID } from "oj/api"
|
||||||
import { getTagColor } from "utils/functions"
|
import { getTagColor } from "utils/functions"
|
||||||
@@ -8,12 +8,13 @@ import { ProblemFiltered } from "utils/types"
|
|||||||
import { getProblemTagList } from "shared/api"
|
import { getProblemTagList } from "shared/api"
|
||||||
import Hitokoto from "shared/components/Hitokoto.vue"
|
import Hitokoto from "shared/components/Hitokoto.vue"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import ProblemStatus from "./components/ProblemStatus.vue"
|
import ProblemStatus from "./components/ProblemStatus.vue"
|
||||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||||
|
import ProblemListTitle from "./components/ProblemListTitle.vue"
|
||||||
|
|
||||||
interface Tag {
|
interface Tag {
|
||||||
id: number
|
id: number
|
||||||
@@ -26,6 +27,7 @@ interface ProblemQuery {
|
|||||||
difficulty: string
|
difficulty: string
|
||||||
tag: string
|
tag: string
|
||||||
author: string
|
author: string
|
||||||
|
sort: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const difficultyOptions = [
|
const difficultyOptions = [
|
||||||
@@ -35,9 +37,21 @@ const difficultyOptions = [
|
|||||||
{ label: "困难", value: "High" },
|
{ 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 router = useRouter()
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const problems = ref<ProblemFiltered[]>([])
|
const problems = ref<ProblemFiltered[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const tags = ref<Tag[]>([])
|
const tags = ref<Tag[]>([])
|
||||||
@@ -49,6 +63,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
|
|||||||
difficulty: useRouteQuery("difficulty", "").value,
|
difficulty: useRouteQuery("difficulty", "").value,
|
||||||
tag: useRouteQuery("tag", "").value,
|
tag: useRouteQuery("tag", "").value,
|
||||||
author: useRouteQuery("author", "").value,
|
author: useRouteQuery("author", "").value,
|
||||||
|
sort: useRouteQuery("sort", "").value,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function listProblems() {
|
async function listProblems() {
|
||||||
@@ -59,6 +74,7 @@ async function listProblems() {
|
|||||||
tag: query.tag,
|
tag: query.tag,
|
||||||
difficulty: query.difficulty,
|
difficulty: query.difficulty,
|
||||||
author: query.author,
|
author: query.author,
|
||||||
|
sort: query.sort,
|
||||||
})
|
})
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
problems.value = res.results
|
problems.value = res.results
|
||||||
@@ -97,7 +113,14 @@ watchDebounced(() => query.keyword, listProblems, {
|
|||||||
|
|
||||||
// 监听其他查询条件变化
|
// 监听其他查询条件变化
|
||||||
watch(
|
watch(
|
||||||
() => [query.tag, query.difficulty, query.limit, query.page, query.author],
|
() => [
|
||||||
|
query.tag,
|
||||||
|
query.difficulty,
|
||||||
|
query.limit,
|
||||||
|
query.page,
|
||||||
|
query.author,
|
||||||
|
query.sort,
|
||||||
|
],
|
||||||
listProblems,
|
listProblems,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,6 +166,7 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
|
|||||||
title: renderTableTitle("题目", "streamline-emojis:watermelon-2"),
|
title: renderTableTitle("题目", "streamline-emojis:watermelon-2"),
|
||||||
key: "title",
|
key: "title",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
|
render: (row) => h(ProblemListTitle, { problem: row }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("难度", "streamline-emojis:lady-beetle"),
|
title: renderTableTitle("难度", "streamline-emojis:lady-beetle"),
|
||||||
@@ -156,7 +180,7 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
|
|||||||
key: "tags",
|
key: "tags",
|
||||||
width: 260,
|
width: 260,
|
||||||
render: (row) =>
|
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"),
|
title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"),
|
||||||
@@ -210,6 +234,13 @@ function rowProps(row: ProblemFiltered) {
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-form :show-feedback="false" inline label-placement="left">
|
<n-form :show-feedback="false" inline label-placement="left">
|
||||||
|
<n-form-item label="排序">
|
||||||
|
<n-select
|
||||||
|
style="width: 120px"
|
||||||
|
v-model:value="query.sort"
|
||||||
|
:options="sortOptions"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
<n-form-item>
|
<n-form-item>
|
||||||
<n-input
|
<n-input
|
||||||
clearable
|
clearable
|
||||||
@@ -218,6 +249,8 @@ function rowProps(row: ProblemFiltered) {
|
|||||||
placeholder="编号或者标题"
|
placeholder="编号或者标题"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-form :show-feedback="false" inline label-placement="left">
|
||||||
<n-form-item>
|
<n-form-item>
|
||||||
<n-button @click="clearQuery" quaternary>重置</n-button>
|
<n-button @click="clearQuery" quaternary>重置</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|||||||
109
src/oj/problemset/components/ProblemSetHeader.vue
Normal 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>
|
||||||
81
src/oj/problemset/components/ProblemSetProblemsList.vue
Normal 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>
|
||||||
270
src/oj/problemset/components/UserProgressView.vue
Normal 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>
|
||||||
139
src/oj/problemset/detail.vue
Normal 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
@@ -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>
|
||||||
@@ -15,6 +15,7 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
difficulty: {},
|
difficulty: {},
|
||||||
contest_count: 0,
|
contest_count: 0,
|
||||||
solved: [],
|
solved: [],
|
||||||
|
flowcharts: [],
|
||||||
})
|
})
|
||||||
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
detailsData.tags = res.data.tags
|
detailsData.tags = res.data.tags
|
||||||
detailsData.difficulty = res.data.difficulty
|
detailsData.difficulty = res.data.difficulty
|
||||||
detailsData.contest_count = res.data.contest_count
|
detailsData.contest_count = res.data.contest_count
|
||||||
|
detailsData.flowcharts = res.data.flowcharts
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDurationData(end: string, duration: string) {
|
async function fetchDurationData(end: string, duration: string) {
|
||||||
|
|||||||
94
src/oj/store/code.ts
Normal 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
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -23,7 +23,9 @@ function filterClass() {
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-button text type="info" @click="$emit('click')"><slot></slot></n-button>
|
<n-button text type="info" @click="$emit('click')">
|
||||||
|
<slot></slot>
|
||||||
|
</n-button>
|
||||||
<n-tooltip>
|
<n-tooltip>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button text @click="$emit('search')">
|
<n-button text @click="$emit('search')">
|
||||||
|
|||||||
25
src/oj/submission/components/FlowchartLink.vue
Normal 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>
|
||||||
25
src/oj/submission/components/Grade.vue
Normal 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>
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "utils/functions"
|
} from "utils/functions"
|
||||||
import { Submission } from "utils/types"
|
import { Submission } from "utils/types"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { isDesktop, isMobile } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
submissionID: string
|
submissionID: string
|
||||||
@@ -23,9 +23,12 @@ const props = defineProps<{
|
|||||||
hideList?: boolean
|
hideList?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
const { isMobile, isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const submission = ref<Submission>()
|
const submission = ref<Submission>()
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -69,13 +72,13 @@ function copyToCat() {
|
|||||||
async function copyToProblem() {
|
async function copyToProblem() {
|
||||||
const success = await copyToClipboard(submission.value!.code)
|
const success = await copyToClipboard(submission.value!.code)
|
||||||
if (success) {
|
if (success) {
|
||||||
message.success("代码复制成功")
|
message.success("代码复制成功,需要手动粘贴到题目")
|
||||||
} else {
|
} else {
|
||||||
message.error("代码复制失败")
|
message.error("代码复制失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否是竞赛题目
|
|
||||||
const contestID = submission.value!.contest
|
const contestID = submission.value!.contest
|
||||||
|
const problemSetId = <string>route.params.problemSetId ?? ""
|
||||||
if (contestID) {
|
if (contestID) {
|
||||||
// 竞赛题目
|
// 竞赛题目
|
||||||
router.push({
|
router.push({
|
||||||
@@ -85,6 +88,15 @@ async function copyToProblem() {
|
|||||||
problemID: props.problemID,
|
problemID: props.problemID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else if (problemSetId) {
|
||||||
|
// 题单题目
|
||||||
|
router.push({
|
||||||
|
name: "problemset problem",
|
||||||
|
params: {
|
||||||
|
problemSetId: problemSetId,
|
||||||
|
problemID: props.problemID,
|
||||||
|
},
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// 普通题目
|
// 普通题目
|
||||||
router.push({
|
router.push({
|
||||||
@@ -115,7 +127,7 @@ onMounted(init)
|
|||||||
</n-alert>
|
</n-alert>
|
||||||
<n-flex :vertical="isDesktop" justify="center">
|
<n-flex :vertical="isDesktop" justify="center">
|
||||||
<n-button secondary @click="copyToCat">复制到自测猫</n-button>
|
<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-flex>
|
</n-flex>
|
||||||
<n-card embedded>
|
<n-card embedded>
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NH2, NText } from "naive-ui"
|
import { NButton, NH2, NText } from "naive-ui"
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { 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 { 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 Pagination from "shared/components/Pagination.vue"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.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 { usePagination } from "shared/composables/pagination"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||||
@@ -15,7 +24,8 @@ import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
|||||||
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||||
import SubmissionLink from "./components/SubmissionLink.vue"
|
import SubmissionLink from "./components/SubmissionLink.vue"
|
||||||
import SubmissionDetail from "./detail.vue"
|
import SubmissionDetail from "./detail.vue"
|
||||||
import IconButton from "shared/components/IconButton.vue"
|
import Grade from "./components/Grade.vue"
|
||||||
|
import FlowchartLink from "./components/FlowchartLink.vue"
|
||||||
|
|
||||||
interface SubmissionQuery {
|
interface SubmissionQuery {
|
||||||
username: string
|
username: string
|
||||||
@@ -30,7 +40,10 @@ const router = useRouter()
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
const { isMobile, isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const submissions = ref<SubmissionListItem[]>([])
|
const submissions = ref<SubmissionListItem[]>([])
|
||||||
|
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const todayCount = ref(0)
|
const todayCount = ref(0)
|
||||||
|
|
||||||
@@ -45,6 +58,8 @@ const { query, clearQuery } = usePagination<SubmissionQuery>({
|
|||||||
const submissionID = ref("")
|
const submissionID = ref("")
|
||||||
const problemDisplayID = ref("")
|
const problemDisplayID = ref("")
|
||||||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
||||||
|
const [flowchartStatisticPanel, toggleFlowchartStatisticPanel] =
|
||||||
|
useToggle(false)
|
||||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||||
|
|
||||||
const resultOptions: SelectOption[] = [
|
const resultOptions: SelectOption[] = [
|
||||||
@@ -56,7 +71,8 @@ const resultOptions: SelectOption[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const languageOptions: SelectOption[] = [
|
const languageOptions: SelectOption[] = [
|
||||||
{ label: "全部", value: "" },
|
{ label: "流程图", value: "Flowchart" },
|
||||||
|
{ label: "全部语言", value: "" },
|
||||||
{ label: "Python", value: "Python3" },
|
{ label: "Python", value: "Python3" },
|
||||||
{ label: "C语言", value: "C" },
|
{ label: "C语言", value: "C" },
|
||||||
{ label: "C++", value: "C++" },
|
{ label: "C++", value: "C++" },
|
||||||
@@ -64,7 +80,17 @@ const languageOptions: SelectOption[] = [
|
|||||||
async function listSubmissions() {
|
async function listSubmissions() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = query.limit * (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({
|
const res = await getSubmissions({
|
||||||
...query,
|
...query,
|
||||||
offset,
|
offset,
|
||||||
@@ -74,10 +100,6 @@ async function listSubmissions() {
|
|||||||
})
|
})
|
||||||
submissions.value = res.data.results
|
submissions.value = res.data.results
|
||||||
total.value = res.data.total
|
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()
|
listSubmissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
function problemClicked(row: SubmissionListItem) {
|
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
|
||||||
if (route.name === "contest submissions") {
|
if (route.name === "contest submissions") {
|
||||||
const path = router.resolve({
|
const path = router.resolve({
|
||||||
name: "contest problem",
|
name: "contest problem",
|
||||||
@@ -140,27 +162,18 @@ watch(
|
|||||||
listSubmissions,
|
listSubmissions,
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() =>
|
|
||||||
(route.name === "submissions" || route.name === "contest submissions") &&
|
|
||||||
route.query,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) listSubmissions()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns = computed(() => {
|
const columns = computed(() => {
|
||||||
const res: DataTableColumn<SubmissionListItem>[] = [
|
const res: DataTableColumn<SubmissionListItem>[] = [
|
||||||
{
|
{
|
||||||
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
||||||
key: "create_time",
|
key: "create_time",
|
||||||
width: 200,
|
minWidth: 200,
|
||||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
||||||
key: "id",
|
key: "id",
|
||||||
minWidth: 180,
|
minWidth: 200,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(SubmissionLink, {
|
h(SubmissionLink, {
|
||||||
submission: row,
|
submission: row,
|
||||||
@@ -170,13 +183,13 @@ const columns = computed(() => {
|
|||||||
{
|
{
|
||||||
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
|
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
|
||||||
key: "status",
|
key: "status",
|
||||||
width: 140,
|
minWidth: 140,
|
||||||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("题目", "streamline-emojis:blossom"),
|
title: renderTableTitle("题目", "streamline-emojis:blossom"),
|
||||||
key: "problem",
|
key: "problem",
|
||||||
width: 160,
|
minWidth: 300,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(
|
h(
|
||||||
ButtonWithSearch,
|
ButtonWithSearch,
|
||||||
@@ -185,7 +198,7 @@ const columns = computed(() => {
|
|||||||
onClick: () => problemClicked(row),
|
onClick: () => problemClicked(row),
|
||||||
onSearch: () => (query.problem = row.problem),
|
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",
|
"streamline-emojis:globe-showing-europe-africa",
|
||||||
),
|
),
|
||||||
key: "language",
|
key: "language",
|
||||||
width: 120,
|
minWidth: 120,
|
||||||
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -237,6 +250,58 @@ const columns = computed(() => {
|
|||||||
}
|
}
|
||||||
return res
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical size="large">
|
<n-flex vertical size="large">
|
||||||
@@ -249,13 +314,6 @@ const columns = computed(() => {
|
|||||||
unchecked-value="0"
|
unchecked-value="0"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</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-form-item label="语言" v-if="route.name !== 'contest submissions'">
|
||||||
<n-select
|
<n-select
|
||||||
class="select"
|
class="select"
|
||||||
@@ -263,10 +321,19 @@ const columns = computed(() => {
|
|||||||
:options="languageOptions"
|
:options="languageOptions"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</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>
|
||||||
<n-form :show-feedback="false" inline label-placement="left">
|
<n-form :show-feedback="false" inline label-placement="left">
|
||||||
<n-form-item>
|
<n-form-item>
|
||||||
<n-input
|
<n-input
|
||||||
|
:disabled="query.myself === '1'"
|
||||||
class="input"
|
class="input"
|
||||||
clearable
|
clearable
|
||||||
v-model:value="query.username"
|
v-model:value="query.username"
|
||||||
@@ -301,11 +368,7 @@ const columns = computed(() => {
|
|||||||
<n-form-item
|
<n-form-item
|
||||||
v-if="userStore.isSuperAdmin && route.name === 'submissions'"
|
v-if="userStore.isSuperAdmin && route.name === 'submissions'"
|
||||||
>
|
>
|
||||||
<IconButton
|
<n-button @click="toggleStatisticPanel(true)">数据统计</n-button>
|
||||||
icon="streamline-emojis:bar-chart"
|
|
||||||
tip="数据统计"
|
|
||||||
@click="toggleStatisticPanel(true)"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-form
|
<n-form
|
||||||
@@ -321,7 +384,18 @@ const columns = computed(() => {
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-space>
|
</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>
|
</n-flex>
|
||||||
<Pagination
|
<Pagination
|
||||||
:total="total"
|
:total="total"
|
||||||
@@ -369,4 +443,11 @@ const columns = computed(() => {
|
|||||||
.todayCount {
|
.todayCount {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flowchart-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { NH2, NH3 } from "naive-ui"
|
import { NH2, NH3 } from "naive-ui"
|
||||||
import { getProfile } from "shared/api"
|
import { getProfile } from "shared/api"
|
||||||
import { isDesktop } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { durationToDays, parseTime } from "utils/functions"
|
import { durationToDays, parseTime } from "utils/functions"
|
||||||
import { Profile } from "utils/types"
|
import { Profile, UserBadge as UserBadgeType } from "utils/types"
|
||||||
import { getMetrics } from "../api"
|
import { getMetrics, getUserBadges } from "../api"
|
||||||
|
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -15,9 +16,46 @@ const firstSubmissionAt = ref("")
|
|||||||
const latestSubmissionAt = ref("")
|
const latestSubmissionAt = ref("")
|
||||||
const toLatestAt = ref("")
|
const toLatestAt = ref("")
|
||||||
const learnDuration = ref("")
|
const learnDuration = ref("")
|
||||||
|
const userBadges = ref<GroupedBadge[]>([])
|
||||||
const [loading, toggle] = useToggle()
|
const [loading, toggle] = useToggle()
|
||||||
const [show, toggleShow] = useToggle(false)
|
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() {
|
async function init() {
|
||||||
toggle(true)
|
toggle(true)
|
||||||
try {
|
try {
|
||||||
@@ -35,8 +73,23 @@ async function init() {
|
|||||||
}
|
}
|
||||||
ac.sort()
|
ac.sort()
|
||||||
problems.value = ac
|
problems.value = ac
|
||||||
|
const promises: Promise<{ data: any }>[] = []
|
||||||
|
|
||||||
if (profile.value.submission_number > 0) {
|
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)
|
firstSubmissionAt.value = parseTime(metricsRes.data.first)
|
||||||
latestSubmissionAt.value = parseTime(metricsRes.data.latest)
|
latestSubmissionAt.value = parseTime(metricsRes.data.latest)
|
||||||
toLatestAt.value = durationToDays(
|
toLatestAt.value = durationToDays(
|
||||||
@@ -48,6 +101,9 @@ async function init() {
|
|||||||
metricsRes.data.latest,
|
metricsRes.data.latest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 badges 结果
|
||||||
|
userBadges.value = groupBadgesByIcon(results[1].data)
|
||||||
} finally {
|
} finally {
|
||||||
toggle(false)
|
toggle(false)
|
||||||
}
|
}
|
||||||
@@ -131,6 +187,24 @@ onMounted(init)
|
|||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</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 v-if="!loading && profile" class="wrapper" bordered>
|
||||||
<n-descriptions-item v-if="!!problems.length">
|
<n-descriptions-item v-if="!!problems.length">
|
||||||
<template #label>
|
<template #label>
|
||||||
@@ -197,6 +271,8 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desc {
|
.desc {
|
||||||
margin: 0;
|
margin: 0 auto;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||