feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
1
apps/web/.browserslistrc
Normal file
@@ -0,0 +1 @@
|
||||
chrome >= 90
|
||||
8
apps/web/.env.production
Normal file
@@ -0,0 +1,8 @@
|
||||
PUBLIC_ENV=xuyue.cc
|
||||
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=dd37457027c40b39
|
||||
PUBLIC_OJ_URL=https://oj.xuyue.cc
|
||||
PUBLIC_CODE_URL=https://code.xuyue.cc
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
|
||||
PUBLIC_WS_URL=wss://oj.xuyue.cc/ws
|
||||
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc
|
||||
8
apps/web/.env.staging
Normal file
@@ -0,0 +1,8 @@
|
||||
PUBLIC_ENV=school
|
||||
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_CODE_URL=http://10.13.114.114:82
|
||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||
PUBLIC_WS_URL=ws://10.13.114.114:81/ws
|
||||
8
apps/web/.env.test
Normal file
@@ -0,0 +1,8 @@
|
||||
PUBLIC_ENV=test
|
||||
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=dd37457027c40b39
|
||||
PUBLIC_OJ_URL=http://10.13.114.114:93
|
||||
PUBLIC_CODE_URL=http://10.13.114.114:82
|
||||
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||
PUBLIC_WS_URL=ws://10.13.114.114:93/ws
|
||||
46
apps/web/.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: debian
|
||||
build_command: build
|
||||
remote_port: 22
|
||||
target: /root/OJDeploy/data/clientnext
|
||||
- name: school
|
||||
build_command: build:staging
|
||||
remote_port: 8822
|
||||
target: /root/OJ/data/dist
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm install
|
||||
- run: npm run ${{ matrix.build_command }}
|
||||
env:
|
||||
CI: false
|
||||
|
||||
- uses: easingthemes/ssh-deploy@main
|
||||
with:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.KEY }}
|
||||
REMOTE_HOST: ${{ secrets.HOST }}
|
||||
REMOTE_PORT: ${{ matrix.remote_port }}
|
||||
ARGS: "-avzr --delete"
|
||||
SOURCE: dist/
|
||||
REMOTE_USER: root
|
||||
TARGET: ${{ matrix.target }}
|
||||
30
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
src/components.d.ts
|
||||
src/auto-imports.d.ts
|
||||
|
||||
.claude
|
||||
.worktrees
|
||||
1
apps/web/.prettierrc.toml
Normal file
@@ -0,0 +1 @@
|
||||
semi=false
|
||||
114
apps/web/CLAUDE.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**ojnext** is the frontend for an Online Judge platform. Built with Vue 3 + TypeScript using Vite (Rolldown-based bundler), Naive UI component library, Pinia for state management, and Vue Router.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm start # Start dev server on port 5173
|
||||
npm run build # Production build
|
||||
npm run build:staging # Staging build
|
||||
npm run build:test # Test build
|
||||
npm fmt # Format with Prettier
|
||||
```
|
||||
|
||||
No test suite is configured. Linting is via Prettier only.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── shared/ # Cross-cutting concerns: layout, stores, composables, API
|
||||
├── oj/ # User-facing features (problems, submissions, contests, etc.)
|
||||
├── admin/ # Admin panel features
|
||||
├── utils/ # Constants, types, HTTP client, helpers
|
||||
├── routes.ts # Route definitions (two top-level: ojs, admins)
|
||||
├── main.ts # App entry point
|
||||
└── App.vue # Root component with Naive UI theme setup
|
||||
```
|
||||
|
||||
### Module Pattern
|
||||
|
||||
Each feature module (under `oj/` or `admin/`) typically has:
|
||||
- `views/` — page-level Vue components
|
||||
- `components/` — feature-specific components
|
||||
- `api.ts` — API calls specific to the feature
|
||||
|
||||
Shared logic lives in `shared/`:
|
||||
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary)
|
||||
- `composables/` — `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `sync` (Yjs/y-webrtc for collaborative editing), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `breakpoints`, `maxkb`
|
||||
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
||||
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
||||
|
||||
### Auto-Imports
|
||||
|
||||
Configured via `unplugin-auto-import` and `unplugin-vue-components`. You do **not** need to manually import:
|
||||
- Vue APIs (`ref`, `computed`, `watch`, etc.)
|
||||
- Vue Router (`useRouter`, `useRoute`)
|
||||
- Pinia (`defineStore`, `storeToRefs`)
|
||||
- VueUse composables
|
||||
- Naive UI composables (`useDialog`, `useMessage`, `useNotification`, `useLoadingBar`)
|
||||
- Naive UI components (all `N*` components)
|
||||
- Naive UI types (`DataTableColumn`, `FormRules`, `FormItemRule`, `SelectOption`, `UploadCustomRequestOptions`, `UploadFileInfo`, `MenuOption`, `DropdownOption`)
|
||||
|
||||
Generated type declaration files: `src/auto-imports.d.ts`, `src/components.d.ts`.
|
||||
|
||||
### Path Aliases
|
||||
|
||||
```
|
||||
utils → ./src/utils
|
||||
oj → ./src/oj
|
||||
admin → ./src/admin
|
||||
shared → ./src/shared
|
||||
```
|
||||
|
||||
### HTTP Client
|
||||
|
||||
`utils/http.ts` — Axios instance with interceptors. All API calls proxy through the dev server:
|
||||
- `/api` and `/public` → `PUBLIC_OJ_URL` (backend)
|
||||
- `/ws` → `PUBLIC_WS_URL` (WebSocket backend)
|
||||
|
||||
### Key Utilities
|
||||
|
||||
- `utils/constants.ts` — Judge status codes, language IDs, difficulty levels, contest types
|
||||
- `utils/types.ts` — TypeScript interfaces for all domain models
|
||||
- `utils/permissions.ts` — Permission check helpers
|
||||
- `utils/judge.ts` — Judge-related utilities
|
||||
- `utils/renders.ts` — Table column render helpers for Naive UI DataTable
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Variables prefixed with `PUBLIC_` are injected at build time. Env files: `.env`, `.env.staging`, `.env.test`.
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `PUBLIC_OJ_URL` | Backend REST API base URL |
|
||||
| `PUBLIC_WS_URL` | WebSocket server URL |
|
||||
| `PUBLIC_ENV` | Environment name (dev/staging/production) |
|
||||
| `PUBLIC_CODE_URL` | Code execution service |
|
||||
| `PUBLIC_JUDGE0_URL` | Judge0 API |
|
||||
| `PUBLIC_MAXKB_URL` | Knowledge base service |
|
||||
| `PUBLIC_SIGNALING_URL` | WebRTC signaling server |
|
||||
| `PUBLIC_ICONIFY_URL` | Iconify icon CDN |
|
||||
|
||||
### Routing
|
||||
|
||||
Routes are defined in `src/routes.ts` with two root routes: `ojs` (user-facing) and `admins` (admin panel). Route meta fields used:
|
||||
- `requiresAuth` — redirect to login if not authenticated
|
||||
- `requiresSuperAdmin` — super admin only
|
||||
- `requiresProblemPermission` — problem management access
|
||||
|
||||
### Real-time Features
|
||||
|
||||
- WebSocket via composable in `shared/composables/` for submission status updates
|
||||
- Yjs + y-webrtc for collaborative editing in the flowchart editor
|
||||
|
||||
## Related Repository
|
||||
|
||||
The backend is at `../OnlineJudge` — a Django 5 + DRF project. See its CLAUDE.md for backend details.
|
||||
1
apps/web/README.md
Normal file
@@ -0,0 +1 @@
|
||||
oj.xuyue.cc
|
||||
548
apps/web/docs/API接口对比分析.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# API接口对比分析
|
||||
|
||||
## 更新日志
|
||||
|
||||
### 最近更新(2025-01-15)
|
||||
- ✨ **FlowchartEditor 流程图编辑器**:新增完整的流程图编辑功能
|
||||
- 基于 Vue Flow 构建的流程图编辑器组件
|
||||
- 支持7种节点类型:开始、输入、处理、判断、循环、输出、结束
|
||||
- 完整的拖拽创建、节点连接、编辑功能
|
||||
- 撤销重做、自动保存、键盘快捷键支持
|
||||
- 模块化设计,包含9个独立的功能模块
|
||||
- 🎨 **前端组件优化**:完善了组件文档和项目结构说明
|
||||
- 更新了 README.md 中的技术栈和项目结构
|
||||
- 创建了详细的 FlowchartEditor 使用文档
|
||||
- 优化了开发指南和构建流程说明
|
||||
- 🔧 **构建工具升级**:从 Vite 迁移到 Rsbuild
|
||||
- 使用 Rsbuild 作为新的构建工具
|
||||
- 支持多环境构建(test、staging、production)
|
||||
- 优化了构建性能和开发体验
|
||||
|
||||
### 历史更新(2025-10)
|
||||
- ✨ **AI分析功能增强**:完善了AI智能分析模块的文档说明
|
||||
- 详细说明了4个AI相关接口的功能和参数
|
||||
- 新增等级系统说明(S/A/B/C),包含特殊规则
|
||||
- 补充了时间范围选择功能
|
||||
- 说明了流式响应的实现方式
|
||||
- 前端组件从 `WeeklyChart.vue` 升级为 `DurationChart.vue`(混合图表)
|
||||
- 🔧 **数据缓存优化**:后端AI接口增加了缓存机制,提升性能
|
||||
- 🐛 **修正等级系统说明**:更正了等级阈值(A级:前35%,B级:前75%),并补充了小规模参与惩罚规则
|
||||
|
||||
---
|
||||
|
||||
## 一、前端已使用的API接口
|
||||
|
||||
### 1. 用户认证相关(shared/api.ts)
|
||||
- `POST /api/login` - 用户登录
|
||||
- `POST /api/register` - 用户注册
|
||||
- `GET /api/logout` - 用户登出
|
||||
- `GET /api/profile` - 获取用户资料
|
||||
- `GET /api/captcha` - 获取验证码
|
||||
|
||||
### 2. OJ普通用户API(oj/api.ts)
|
||||
#### 2.1 网站配置
|
||||
- `GET /api/website` - 获取网站配置
|
||||
- `GET /api/hitokoto` - 获取一言
|
||||
|
||||
#### 2.2 题目相关
|
||||
- `GET /api/problem` - 获取题目列表
|
||||
- `GET /api/problem` - 获取单个题目
|
||||
- `GET /api/problem/tags` - 获取题目标签列表
|
||||
- `GET /api/problem/author` - 获取题目作者列表
|
||||
- `GET /api/problem/beat_count` - 获取题目击败率
|
||||
- `GET /api/pickone` - 随机获取题目
|
||||
- `GET /api/contest/problem` - 获取竞赛题目
|
||||
|
||||
#### 2.3 提交相关
|
||||
- `GET /api/submission` - 获取单个提交
|
||||
- `POST /api/submission` - 提交代码
|
||||
- `GET /api/submissions` - 获取提交列表
|
||||
- `GET /api/submissions/today_count` - 获取今日提交数
|
||||
- `GET /api/contest_submissions` - 获取竞赛提交列表
|
||||
|
||||
#### 2.4 排名相关
|
||||
- `GET /api/user_rank` - 获取用户排名
|
||||
- `GET /api/user_activity_rank` - 获取活跃度排名
|
||||
- `GET /api/user_problem_rank` - 获取题目排名
|
||||
- `GET /api/contest_rank` - 获取竞赛排名
|
||||
|
||||
#### 2.5 竞赛相关
|
||||
- `GET /api/contests` - 获取竞赛列表
|
||||
- `GET /api/contest` - 获取单个竞赛
|
||||
- `GET /api/contest/access` - 获取竞赛访问权限
|
||||
- `POST /api/contest/password` - 验证竞赛密码
|
||||
|
||||
#### 2.6 公告相关
|
||||
- `GET /api/announcement` - 获取公告列表/单个公告
|
||||
|
||||
#### 2.7 消息相关
|
||||
- `POST /api/message` - 创建消息
|
||||
- `GET /api/message` - 获取消息列表
|
||||
|
||||
#### 2.8 评论相关
|
||||
- `POST /api/comment` - 创建评论
|
||||
- `GET /api/comment` - 获取评论
|
||||
- `GET /api/comment/statistics` - 获取评论统计
|
||||
|
||||
#### 2.9 用户相关
|
||||
- `POST /api/upload_avatar` - 上传头像
|
||||
- `PUT /api/profile` - 更新用户资料
|
||||
- `GET /api/profile/fresh_display_id` - 刷新用户题目显示ID
|
||||
- `GET /api/metrics` - 获取用户统计数据
|
||||
|
||||
#### 2.10 教程相关
|
||||
- `GET /api/tutorial` - 获取单个教程
|
||||
- `GET /api/tutorials` - 获取教程列表
|
||||
|
||||
#### 2.11 AI分析相关
|
||||
- `GET /api/ai/detail` - 获取用户详细数据
|
||||
- **参数**: start, end(时间范围)
|
||||
- **返回**: 用户等级(S/A/B/C)、已解决题目列表、标签统计、难度统计、参赛次数等
|
||||
- **特点**: 包含班级排名对比,计算每道题的解题排名和等级
|
||||
- `GET /api/ai/duration` - 获取时段数据
|
||||
- **参数**: end(结束时间), duration(时间单位,如 "months:6", "weeks:1")
|
||||
- **返回**: 每周/每月的综合情况(题目数、提交数、等级)
|
||||
- **用途**: 用于绘制时间趋势图,展示学习进度变化
|
||||
- `GET /api/ai/heatmap` - 获取热力图数据
|
||||
- **返回**: 用户的提交热力图数据(按日期统计提交数)
|
||||
- **用途**: 可视化用户活跃度分布
|
||||
- `POST /api/ai/analysis` - AI智能分析生成
|
||||
- **请求体**: details(详细数据), duration(时段数据)
|
||||
- **响应方式**: 流式响应(Server-Sent Events)
|
||||
- **AI提供商**: DeepSeek
|
||||
- **功能**: 根据用户学习数据生成个性化学习建议和鼓励
|
||||
- **实现**: 使用fetch直接调用,在 `oj/store/ai.ts` 中处理流式输出
|
||||
|
||||
### 3. 管理员API(admin/api.ts)
|
||||
#### 3.1 仪表板
|
||||
- `GET /api/admin/dashboard_info` - 获取仪表板信息
|
||||
- `GET /api/admin/random_user` - 随机获取用户
|
||||
|
||||
#### 3.2 题目管理
|
||||
- `GET /api/admin/problem` - 获取题目列表/单个题目
|
||||
- `POST /api/admin/problem` - 创建题目
|
||||
- `PUT /api/admin/problem` - 编辑题目
|
||||
- `PUT /api/admin/problem/visible` - 切换题目可见性
|
||||
- `DELETE /api/admin/problem` - 删除题目
|
||||
- `GET /api/admin/contest/problem` - 获取竞赛题目
|
||||
- `POST /api/admin/contest/problem` - 创建竞赛题目
|
||||
- `PUT /api/admin/contest/problem` - 编辑竞赛题目
|
||||
- `DELETE /api/admin/contest/problem` - 删除竞赛题目
|
||||
- `POST /api/admin/contest/add_problem_from_public` - 从公开题库添加题目到竞赛
|
||||
|
||||
#### 3.3 用户管理
|
||||
- `GET /api/admin/user` - 获取用户列表
|
||||
- `POST /api/admin/user` - 导入用户
|
||||
- `PUT /api/admin/user` - 编辑用户
|
||||
- `DELETE /api/admin/user` - 删除用户
|
||||
- `POST /api/admin/reset_password` - 重置用户密码
|
||||
|
||||
#### 3.4 竞赛管理
|
||||
- `GET /api/admin/contest` - 获取竞赛列表/单个竞赛
|
||||
- `POST /api/admin/contest` - 创建竞赛
|
||||
- `PUT /api/admin/contest` - 编辑竞赛
|
||||
- `GET /api/admin/contest/acm_helper` - 获取ACM比赛辅助检查列表
|
||||
- `PUT /api/admin/contest/acm_helper` - 更新ACM比赛辅助检查状态
|
||||
|
||||
#### 3.5 测试用例管理
|
||||
- `POST /api/admin/test_case` - 上传测试用例
|
||||
- `GET /api/admin/prune_test_case` - 列出无效测试用例
|
||||
- `DELETE /api/admin/prune_test_case` - 清理无效测试用例
|
||||
|
||||
#### 3.6 题目管理扩展
|
||||
- `POST /api/admin/contest_problem/make_public` - 将竞赛题目转为公开题目
|
||||
|
||||
#### 3.7 判题服务器管理
|
||||
- `GET /api/admin/judge_server` - 获取判题服务器列表
|
||||
- `DELETE /api/admin/judge_server` - 删除判题服务器
|
||||
|
||||
#### 3.8 公告管理
|
||||
- `GET /api/admin/announcement` - 获取公告列表/单个公告
|
||||
- `POST /api/admin/announcement` - 创建公告
|
||||
- `PUT /api/admin/announcement` - 编辑公告
|
||||
- `DELETE /api/admin/announcement` - 删除公告
|
||||
|
||||
#### 3.9 评论管理
|
||||
- `GET /api/admin/comment` - 获取评论列表
|
||||
- `DELETE /api/admin/comment` - 删除评论
|
||||
|
||||
#### 3.10 网站配置
|
||||
- `GET /api/admin/website` - 获取网站配置
|
||||
- `POST /api/admin/website` - 更新网站配置
|
||||
|
||||
#### 3.11 文件上传
|
||||
- `POST /api/admin/upload_image` - 上传图片(富文本编辑器、Markdown编辑器使用)
|
||||
|
||||
#### 3.12 提交管理
|
||||
- `GET /api/admin/submission/rejudge` - 重新判题
|
||||
- `GET /api/admin/submission/statistics` - 获取提交统计
|
||||
|
||||
#### 3.13 教程管理
|
||||
- `GET /api/admin/tutorial` - 获取教程列表/单个教程
|
||||
- `POST /api/admin/tutorial` - 创建教程
|
||||
- `PUT /api/admin/tutorial` - 更新教程
|
||||
- `DELETE /api/admin/tutorial` - 删除教程
|
||||
- `PUT /api/admin/tutorial/visibility` - 设置教程可见性
|
||||
|
||||
---
|
||||
|
||||
## 二、后端提供但前端未使用的API接口
|
||||
|
||||
### 1. 用户认证相关(account)
|
||||
- `POST /api/change_password` - 修改密码
|
||||
- `POST /api/change_email` - 修改邮箱
|
||||
- `POST /api/apply_reset_password` - 申请重置密码
|
||||
- `POST /api/reset_password` - 重置密码
|
||||
- `GET /api/check_username_or_email` - 检查用户名或邮箱是否存在
|
||||
- `GET /api/tfa_required` - 检查是否需要双因素认证
|
||||
- `POST /api/two_factor_auth` - 双因素认证
|
||||
- `GET /api/sessions` - 会话管理
|
||||
- `GET /api/open_api_appkey` - OpenAPI密钥管理
|
||||
- `GET /api/sso` - 单点登录
|
||||
|
||||
### 2. 用户管理(admin)
|
||||
- `GET /api/admin/generate_user` - 生成用户
|
||||
|
||||
### 3. 网站配置相关
|
||||
- `GET /api/languages` - 获取支持的编程语言列表
|
||||
|
||||
### 4. 判题服务器内部接口(不需要前端实现)
|
||||
- ❌ `POST /api/judge_server_heartbeat/` - 判题服务器心跳
|
||||
- **标记为不需要**
|
||||
- **原因**: 此接口由 JudgeServer Docker 容器调用,用于向后端报告服务器状态
|
||||
- **使用方**: 判题服务器(非前端)
|
||||
- **数据内容**: hostname, judger_version, CPU/内存使用率等
|
||||
- **认证方式**: 使用特殊的 judge_server_token,非用户认证
|
||||
|
||||
### 5. 管理员配置相关
|
||||
- `GET /api/admin/smtp` - SMTP配置
|
||||
- `POST /api/admin/smtp_test` - SMTP测试
|
||||
- `GET /api/admin/versions` - 版本信息
|
||||
|
||||
### 6. 题目管理相关(admin)
|
||||
- `POST /api/admin/export_problem` - 导出题目
|
||||
- `POST /api/admin/import_problem` - 导入题目
|
||||
- `POST /api/admin/import_fps` - 导入FPS格式题目
|
||||
|
||||
### 7. 竞赛相关(admin)
|
||||
- `GET /api/contest/announcement` - 获取竞赛公告列表(OJ端)
|
||||
- `GET /api/admin/contest/announcement` - 获取竞赛公告(管理端)
|
||||
- `GET /api/admin/download_submissions` - 下载竞赛提交
|
||||
|
||||
### 8. 提交相关(不必要的接口)
|
||||
- ❌ `GET /api/submission_exists` - 检查提交是否存在
|
||||
- **标记为不需要**
|
||||
- **原因**: 题目接口返回的 `my_status` 字段已完整包含此信息
|
||||
- **替代方案**: 直接判断 `my_status` 的值(0=已通过,非零=已尝试未通过,null=未尝试)
|
||||
- **优势**: 零额外请求,性能更优
|
||||
|
||||
### 9. 文件上传相关(admin)
|
||||
- `POST /api/admin/upload_file` - 上传文件(任意格式)
|
||||
- **说明**: 前端已使用 `upload_image`(仅图片),`upload_file` 可上传任意文件
|
||||
- **当前状态**: 未使用(前端暂无上传非图片文件的需求)
|
||||
- **潜在场景**: 题目附件、教程资料、作业提交等
|
||||
|
||||
---
|
||||
|
||||
## 三、统计总结
|
||||
|
||||
### 前端已使用接口统计
|
||||
- **OJ普通用户接口**: 38个(包括1个直接用fetch调用的流式接口)
|
||||
- **管理员接口**: 35个
|
||||
- **共享接口**: 5个
|
||||
- **总计**: 78个API调用
|
||||
|
||||
### 后端未被使用接口统计
|
||||
- **用户认证相关**: 10个
|
||||
- **网站配置相关**: 2个(languages)
|
||||
- **题目管理相关**: 3个
|
||||
- **竞赛管理相关**: 3个
|
||||
- **其他**: 1个
|
||||
- **标记为不需要**: 2个
|
||||
- submission_exists(数据冗余)
|
||||
- judge_server_heartbeat(内部接口)
|
||||
- **总计**: 21个API端点(其中2个不需要前端实现)
|
||||
|
||||
### 未使用接口占比
|
||||
约 **21%** 的后端API接口前端尚未使用
|
||||
- **需要考虑实现**: 19个接口
|
||||
- **不必要实现**: 2个接口(submission_exists, judge_server_heartbeat)
|
||||
|
||||
**备注**: 本次更新新增了 ACM 比赛辅助检查功能(2个接口),用于赛后人工审核代码。
|
||||
|
||||
---
|
||||
|
||||
## 四、建议
|
||||
|
||||
### 1. 高优先级需要实现的功能
|
||||
- **密码管理**: change_password, apply_reset_password, reset_password
|
||||
- **邮箱管理**: change_email
|
||||
- **用户名/邮箱检查**: check_username_or_email(注册时实时验证)
|
||||
- **编程语言列表**: languages(显示支持的编程语言)
|
||||
- **题目导入导出**: export_problem, import_problem(方便题库管理)
|
||||
|
||||
### 2. 中等优先级功能
|
||||
- **双因素认证**: tfa_required, two_factor_auth(增强安全性)
|
||||
- **会话管理**: sessions(多设备登录管理)
|
||||
- **竞赛公告**: contest/announcement(OJ端,增强竞赛体验)
|
||||
|
||||
### 3. 低优先级功能
|
||||
- **SSO单点登录**: sso(如需要集成其他系统)
|
||||
- **OpenAPI**: open_api_appkey(如需要开放API)
|
||||
- **SMTP配置**: smtp, smtp_test(管理员配置)
|
||||
- **版本信息**: versions(显示系统版本)
|
||||
- **FPS导入**: import_fps(特定格式题目导入)
|
||||
- **文件上传**: upload_file(目前只有图片上传)
|
||||
|
||||
### 4. 可选功能
|
||||
- **生成用户**: generate_user(批量生成测试用户)
|
||||
- **下载提交**: download_submissions(下载竞赛所有提交)
|
||||
|
||||
---
|
||||
|
||||
## 五、接口使用率分析
|
||||
|
||||
| 模块 | 后端提供 | 前端使用 | 使用率 |
|
||||
|------|---------|---------|--------|
|
||||
| 用户认证 | 17 | 7 | 41% |
|
||||
| 题目管理 | 14 | 11 | 79% |
|
||||
| 提交管理 | 7 | 6 | 86% |
|
||||
| 竞赛管理 | 12 | 9 | 75% |
|
||||
| 公告管理 | 4 | 4 | 100% |
|
||||
| 评论管理 | 4 | 4 | 100% |
|
||||
| 消息管理 | 1 | 1 | 100% |
|
||||
| 教程管理 | 4 | 4 | 100% |
|
||||
| AI分析 | 4 | 4 | 100% ✅ |
|
||||
| 配置管理 | 9 | 3 | 33% ⚠️(1个为内部接口) |
|
||||
|
||||
**总体使用率约为 79%**(已实现ACM比赛辅助检查功能,竞赛管理使用率提升至75%)
|
||||
|
||||
### 特殊说明
|
||||
|
||||
#### 1. AI智能分析功能 ✨
|
||||
|
||||
**功能概述**: 基于用户的学习数据,使用DeepSeek AI生成个性化的学习分析报告和建议。
|
||||
|
||||
**涉及接口**:
|
||||
- `GET /api/ai/detail` - 获取详细学习数据
|
||||
- `GET /api/ai/duration` - 获取时段趋势数据
|
||||
- `GET /api/ai/heatmap` - 获取活跃度热力图
|
||||
- `POST /api/ai/analysis` - 生成AI分析(流式响应)
|
||||
|
||||
**前端实现**:
|
||||
- **页面**: `src/oj/ai/analysis.vue`
|
||||
- **Store**: `src/oj/store/ai.ts`
|
||||
- **组件**:
|
||||
- `DurationChart.vue` - 混合图表(柱状图+折线图),展示题目数、提交数、等级变化
|
||||
- `Heatmap.vue` - 提交热力图
|
||||
- `Details.vue` - 详细数据展示
|
||||
- `AI.vue` - AI分析结果展示(Markdown格式)
|
||||
|
||||
**时间范围选择**:
|
||||
支持多种时间范围:一节课(1小时)、两节课(2小时)、一天、一周、一个月、两个月、半年、一年
|
||||
|
||||
**等级系统**:
|
||||
- **S级**: 排名前10%(卓越水平,约10%的人)
|
||||
- **A级**: 排名前35%(优秀水平,约25%的人)
|
||||
- **B级**: 排名前75%(良好水平,约40%的人)
|
||||
- **C级**: 75%之后(及格水平,约25%的人)
|
||||
- **特殊规则**: 参与人数少于10人时,S级降为A级,A级降为B级(避免因人少而评级虚高)
|
||||
|
||||
**流式接口实现**:
|
||||
`POST /api/ai/analysis` 使用了**流式响应(Server-Sent Events)**,在 `oj/store/ai.ts` 中直接使用 `fetch` API调用,配合 `consumeJSONEventStream` 工具函数处理流式数据,实现AI内容的实时流式输出。
|
||||
|
||||
**数据缓存**:
|
||||
为提升性能,后端对 `ai/detail` 和 `ai/duration` 接口的返回数据进行了缓存,相同参数的请求会直接返回缓存结果。
|
||||
|
||||
#### 2. ACM 比赛辅助检查功能 ✨
|
||||
|
||||
**功能说明**: 用于赛后人工审核 ACM 模式比赛的代码,检查是否存在抄袭、作弊等行为。
|
||||
|
||||
**涉及接口**:
|
||||
- `GET /api/admin/contest/acm_helper` - 获取比赛中所有 AC 的提交记录
|
||||
- **返回数据**: 用户名、题目ID、AC时间、错误次数、检查状态等
|
||||
- `PUT /api/admin/contest/acm_helper` - 更新提交的检查状态
|
||||
- **参数**: contest_id, rank_id, problem_id, checked
|
||||
|
||||
**使用场景**:
|
||||
1. 管理员进入比赛详情页,点击"审核"按钮进入辅助检查页面
|
||||
2. 系统展示所有 AC 提交的列表(按用户和题目分组)
|
||||
3. 管理员可以:
|
||||
- 查看每个提交的代码详情
|
||||
- 标记已检查的提交
|
||||
- 批量标记所有提交为已检查
|
||||
- 按用户名、题目、检查状态筛选
|
||||
4. 实时显示检查进度统计(总计/已检查/未检查)
|
||||
|
||||
**实现位置**:
|
||||
- 页面: `src/admin/contest/helper.vue`
|
||||
- 路由: `/admin/contest/:contestID/helper`
|
||||
- API: `src/admin/api.ts` (getACMHelperList, updateACMHelperChecked)
|
||||
|
||||
#### 3. 前端不需要的接口 ❌
|
||||
|
||||
##### 3.1 数据冗余接口
|
||||
**`GET /api/submission_exists`** - 此接口**无需实现**
|
||||
|
||||
**分析结论**:
|
||||
- 后端题目接口(`GET /api/problem`)已返回 `my_status` 字段
|
||||
- `my_status` 完整记录了用户的做题状态:
|
||||
- `0` (JudgeStatus.ACCEPTED) = 已通过 ✅
|
||||
- `-1` (JudgeStatus.WRONG_ANSWER) = 答案错误 ❌
|
||||
- `-2` (JudgeStatus.COMPILE_ERROR) = 编译错误 ❌
|
||||
- `1` (JudgeStatus.TIME_LIMIT_EXCEEDED) = 超时 ❌
|
||||
- 其他非零值 = 其他失败原因 ❌
|
||||
- `null/undefined` = 从未提交 ⭕
|
||||
|
||||
**前端实现**:
|
||||
```typescript
|
||||
// 仅需一个计算属性即可判断
|
||||
const hasTriedButNotPassed = computed(() => {
|
||||
return problem.value?.my_status !== undefined &&
|
||||
problem.value?.my_status !== null &&
|
||||
problem.value?.my_status !== 0
|
||||
})
|
||||
```
|
||||
|
||||
**优势对比**:
|
||||
| 方案 | API请求 | 代码复杂度 | 性能 |
|
||||
|------|---------|-----------|------|
|
||||
| ❌ 使用 submission_exists | +1 | 高(需异步+状态管理) | 慢(额外网络请求) |
|
||||
| ✅ 使用 my_status | 0 | 低(3行计算属性) | 快(本地计算) |
|
||||
|
||||
**经验教训**:
|
||||
- 实现新功能前应充分了解后端现有数据结构
|
||||
- 避免创建冗余接口,优先利用现有数据
|
||||
- 简单的解决方案往往是最好的
|
||||
|
||||
---
|
||||
|
||||
##### 3.2 内部系统接口
|
||||
**`POST /api/judge_server_heartbeat/`** - 此接口**无需前端实现**
|
||||
|
||||
**接口说明**:
|
||||
- **用途**: 判题服务器(JudgeServer)向后端报告健康状态
|
||||
- **调用方**: JudgeServer Docker 容器(非前端)
|
||||
- **调用频率**: 每隔几秒自动调用一次
|
||||
- **认证方式**: 使用 `judge_server_token`(特殊令牌,非用户认证)
|
||||
|
||||
**报告的数据**:
|
||||
```python
|
||||
{
|
||||
"hostname": "judge-server-1",
|
||||
"judger_version": "2.0.4",
|
||||
"cpu_core": 4,
|
||||
"cpu": 45.2, # CPU使用率
|
||||
"memory": 60.5, # 内存使用率
|
||||
"service_url": "http://judger:8080"
|
||||
}
|
||||
```
|
||||
|
||||
**后端使用场景**:
|
||||
- 监控判题服务器在线状态
|
||||
- 显示服务器资源使用情况(仅在管理后台显示)
|
||||
- 负载均衡分配判题任务
|
||||
|
||||
**前端已有接口**:
|
||||
前端查看判题服务器状态使用的是:
|
||||
- `GET /api/admin/judge_server` - 获取所有判题服务器列表及状态
|
||||
|
||||
**结论**:
|
||||
此接口是系统内部通信接口,前端完全不需要调用。类似的内部接口还可能存在于分布式系统的其他服务间通信中。
|
||||
|
||||
---
|
||||
|
||||
## 六、新增功能模块
|
||||
|
||||
### 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
apps/web/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
|
||||
@@ -0,0 +1,259 @@
|
||||
# Submit Formatting Button State Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show `格式化中` on the submit button during automatic formatting, then show `正在提交` continuously while the submission request is pending.
|
||||
|
||||
**Architecture:** Extract the button presentation rules into a small pure TypeScript function so the state priority can be tested without adding a frontend test framework. Keep formatter and submission-request flags local to `SubmitCode.vue`, with `finally` blocks ensuring both flags clear on every outcome.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TypeScript, Node.js built-in test runner, Rsbuild
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Define and test submit button presentation rules
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/submitButtonState.test.ts`
|
||||
- Create: `src/oj/problem/components/submitButtonState.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/submitButtonState.test.ts`:
|
||||
|
||||
```ts
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { getSubmitButtonState } from "../src/oj/problem/components/submitButtonState.ts"
|
||||
|
||||
const idleInput = {
|
||||
isAuthed: true,
|
||||
hasCode: true,
|
||||
isFormatting: false,
|
||||
isSubmitting: false,
|
||||
isJudging: false,
|
||||
isCooldown: false,
|
||||
}
|
||||
|
||||
test("shows a disabled loading state while formatting", () => {
|
||||
assert.deepEqual(
|
||||
getSubmitButtonState({ ...idleInput, isFormatting: true }),
|
||||
{
|
||||
disabled: true,
|
||||
label: "格式化中",
|
||||
icon: "eos-icons:loading",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("shows submitting immediately after formatting", () => {
|
||||
assert.deepEqual(
|
||||
getSubmitButtonState({ ...idleInput, isSubmitting: true }),
|
||||
{
|
||||
disabled: true,
|
||||
label: "正在提交",
|
||||
icon: "eos-icons:loading",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves existing login, judging, cooldown, and idle states", () => {
|
||||
assert.deepEqual(
|
||||
getSubmitButtonState({ ...idleInput, isAuthed: false }),
|
||||
{
|
||||
disabled: true,
|
||||
label: "请先登录",
|
||||
icon: "ph:play-fill",
|
||||
},
|
||||
)
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isJudging: true }), {
|
||||
disabled: true,
|
||||
label: "正在评分",
|
||||
icon: "eos-icons:loading",
|
||||
})
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isCooldown: true }), {
|
||||
disabled: true,
|
||||
label: "正在冷却",
|
||||
icon: "ph:lightbulb-fill",
|
||||
})
|
||||
assert.deepEqual(getSubmitButtonState(idleInput), {
|
||||
disabled: false,
|
||||
label: "提交代码",
|
||||
icon: "ph:play-fill",
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/submitButtonState.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `submitButtonState.ts`.
|
||||
|
||||
- [ ] **Step 3: Implement the pure state function**
|
||||
|
||||
Create `src/oj/problem/components/submitButtonState.ts`:
|
||||
|
||||
```ts
|
||||
export interface SubmitButtonStateInput {
|
||||
isAuthed: boolean
|
||||
hasCode: boolean
|
||||
isFormatting: boolean
|
||||
isSubmitting: boolean
|
||||
isJudging: boolean
|
||||
isCooldown: boolean
|
||||
}
|
||||
|
||||
export interface SubmitButtonState {
|
||||
disabled: boolean
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export function getSubmitButtonState({
|
||||
isAuthed,
|
||||
hasCode,
|
||||
isFormatting,
|
||||
isSubmitting,
|
||||
isJudging,
|
||||
isCooldown,
|
||||
}: SubmitButtonStateInput): SubmitButtonState {
|
||||
const disabled =
|
||||
!isAuthed ||
|
||||
!hasCode ||
|
||||
isFormatting ||
|
||||
isSubmitting ||
|
||||
isJudging ||
|
||||
isCooldown
|
||||
|
||||
let label = "提交代码"
|
||||
if (!isAuthed) {
|
||||
label = "请先登录"
|
||||
} else if (isFormatting) {
|
||||
label = "格式化中"
|
||||
} else if (isSubmitting) {
|
||||
label = "正在提交"
|
||||
} else if (isJudging) {
|
||||
label = "正在评分"
|
||||
} else if (isCooldown) {
|
||||
label = "正在冷却"
|
||||
}
|
||||
|
||||
const icon =
|
||||
isFormatting || isSubmitting || isJudging
|
||||
? "eos-icons:loading"
|
||||
: isCooldown
|
||||
? "ph:lightbulb-fill"
|
||||
: "ph:play-fill"
|
||||
|
||||
return { disabled, label, icon }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/submitButtonState.test.ts
|
||||
```
|
||||
|
||||
Expected: 3 tests pass.
|
||||
|
||||
### Task 2: Connect formatting and submission request lifecycle to the button
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/oj/problem/components/SubmitCode.vue`
|
||||
|
||||
- [ ] **Step 1: Add local request states and computed presentation**
|
||||
|
||||
Import `getSubmitButtonState`, add `isFormatting` and `isSubmittingRequest` refs, and replace the three existing button computed properties with:
|
||||
|
||||
```ts
|
||||
const buttonState = computed(() =>
|
||||
getSubmitButtonState({
|
||||
isAuthed: userStore.isAuthed,
|
||||
hasCode: codeStore.code.value.trim() !== "",
|
||||
isFormatting: isFormatting.value,
|
||||
isSubmitting: isSubmittingRequest.value || submitting.value,
|
||||
isJudging: judging.value || pending.value,
|
||||
isCooldown: isCooldown.value,
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `buttonState.disabled`, `buttonState.icon`, and `buttonState.label` in the template.
|
||||
|
||||
- [ ] **Step 2: Guard and track the formatting request**
|
||||
|
||||
At the start of `submit`, return when `buttonState.value.disabled` is true. Around `formatCode`, set `isFormatting.value = true` before the request and clear it in `finally`:
|
||||
|
||||
```ts
|
||||
isFormatting.value = true
|
||||
try {
|
||||
const res = await formatCode({
|
||||
code: codeStore.code.value,
|
||||
language: formatLang,
|
||||
})
|
||||
codeStore.setCode(res.data.code)
|
||||
} catch (e: any) {
|
||||
if (e?.error === "format-error") {
|
||||
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
isFormatting.value = false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Track the submission API request**
|
||||
|
||||
Set `isSubmittingRequest.value = true` immediately before `submitCode`, keep the existing success flow inside the `try`, and clear the request state in `finally`:
|
||||
|
||||
```ts
|
||||
isSubmittingRequest.value = true
|
||||
try {
|
||||
const res = await submitCode(data)
|
||||
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
|
||||
|
||||
startCooldown()
|
||||
startMonitoring(res.data.submission_id)
|
||||
showResult.value = true
|
||||
} finally {
|
||||
isSubmittingRequest.value = false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/submitButtonState.test.ts
|
||||
```
|
||||
|
||||
Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Step 5: Run the production build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: Rsbuild exits with status 0.
|
||||
|
||||
- [ ] **Step 6: Check the final diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git diff -- src/oj/problem/components/SubmitCode.vue src/oj/problem/components/submitButtonState.ts tests/submitButtonState.test.ts
|
||||
```
|
||||
|
||||
Expected: no whitespace errors; diff is limited to the button state feature and its test.
|
||||
296
apps/web/docs/superpowers/plans/2026-07-26-demo-student-view.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 学生视角(演示模式)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 超级管理员在右上角下拉菜单点一下,整站界面变成普通学生看到的样子,再点一下恢复。
|
||||
|
||||
**Architecture:** 纯前端伪装。全站 40 多处权限判断读的都是 `shared/store/user.ts` 里的 6 个角色 getter,没有一处直接读 `user.admin_type`。在 store 里加一个 `demoMode` 开关,给每个 getter 加 `!demoMode.value &&` 前缀,全站自动跟随。路由守卫(`src/main.ts`)、权限工具(`src/utils/permissions.ts`)、各页面 `v-if` 零改动。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>` + TypeScript,Pinia setup store,Naive UI(`n-dropdown` / `DropdownOption`),Vite。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-26-demo-student-view-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **不写测试。** 项目根 CLAUDE.md 明确规定 "Do not write new tests",且 ojnext 无测试框架。本计划的验证步骤全部是 `npm run build` 冒烟 + 浏览器手工核对。
|
||||
- **不改后端。** 演示模式是界面伪装,登录态仍是超管,接口权限不变。
|
||||
- 仅超级管理员可见此开关。教师管理员、学生管理员不提供。
|
||||
- 自动导入已配置:`ref` / `computed` / `useRouter` / `useRoute` / Naive UI 组件与类型(`DropdownOption`)均**不需要手写 import**。
|
||||
- 存储 key 常量统一放 `src/utils/constants.ts` 的 `STORAGE_KEY`,读写走 `src/utils/storage.ts` 默认导出(内部做 JSON 序列化)。
|
||||
- 提交前跑 `npm fmt`(Prettier)。
|
||||
- 中文注释、中文 UI 文案,与现有代码一致。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 职责 | 本次改动 |
|
||||
|---|---|---|
|
||||
| `src/utils/constants.ts` | 全局常量 | `STORAGE_KEY` 增加一个键 |
|
||||
| `src/shared/store/user.ts` | 用户身份与角色判断的唯一来源 | 新增 `demoMode` 状态与伪装逻辑(改动主体) |
|
||||
| `src/shared/components/Header.vue` | 顶栏与用户下拉菜单 | 新增菜单项与切换处理函数 |
|
||||
|
||||
不新建文件。`demoMode` 放进已有的 `user` store 而不是单开一个 store —— 它伪装的就是这个 store 的输出,分开会让两个 store 循环依赖。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: store 层伪装开关
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/constants.ts:147-153`
|
||||
- Modify: `src/shared/store/user.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 无(第一个任务)
|
||||
- Produces: `useUserStore()` 新增三个成员,供 Task 2 使用:
|
||||
- `demoMode: boolean` — 当前是否处于演示模式(store 解包后为布尔值)
|
||||
- `canToggleDemoMode: boolean` — 是否显示切换入口(真实超管身份,不受伪装影响)
|
||||
- `toggleDemoMode(): void` — 翻转开关并写入 localStorage
|
||||
|
||||
- [ ] **Step 1: 在 `STORAGE_KEY` 增加常量**
|
||||
|
||||
打开 `src/utils/constants.ts`,把 `STORAGE_KEY` 改成:
|
||||
|
||||
```ts
|
||||
export const STORAGE_KEY = {
|
||||
AUTHED: "authed",
|
||||
LANGUAGE: "problemLanguage",
|
||||
LEARN_CURRENT_STEP: "learnStep",
|
||||
ADMIN_PROBLEM: "adminProblem",
|
||||
ADMIN_PROBLEM_TAGS: "adminProblemTags",
|
||||
DEMO_MODE: "demoMode",
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 user store 加入 `demoMode` 与真实身份 getter**
|
||||
|
||||
打开 `src/shared/store/user.ts`。在 `const isAuthed = ...` 那一行之后、`const isAdminRole = ...` 之前,插入:
|
||||
|
||||
```ts
|
||||
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏
|
||||
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
||||
|
||||
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
|
||||
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
|
||||
const realIsSuperAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
```
|
||||
|
||||
`storage` 和 `STORAGE_KEY` 文件顶部已经 import 过,不需要新增 import。
|
||||
|
||||
- [ ] **Step 3: 给 6 个角色 getter 加上伪装前缀**
|
||||
|
||||
把 `src/shared/store/user.ts` 中原有的 6 个 getter(`isAdminRole`、`isStudentAdmin`、`isTeacherAdmin`、`isTeacherOrAbove`、`isSuperAdmin`、`hasProblemPermission`)整段替换为:
|
||||
|
||||
```ts
|
||||
const isAdminRole = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
(user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
|
||||
)
|
||||
const isStudentAdmin = computed(
|
||||
() => !demoMode.value && user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
|
||||
)
|
||||
const isTeacherAdmin = computed(
|
||||
() => !demoMode.value && user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
|
||||
)
|
||||
const isTeacherOrAbove = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
(user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
|
||||
)
|
||||
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
|
||||
const hasProblemPermission = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
user.value?.problem_permission !== PROBLEM_PERMISSION.NONE,
|
||||
)
|
||||
```
|
||||
|
||||
注意:`isAdminRole` 和 `isTeacherOrAbove` 原本是多个 `||` 连成的表达式,加前缀时**必须给原表达式套一层括号**,否则 `&&` 的优先级会让第一个 `||` 分支逃过伪装。
|
||||
|
||||
- [ ] **Step 4: 加入切换能力与切换函数**
|
||||
|
||||
紧接在 `hasProblemPermission` 之后插入:
|
||||
|
||||
```ts
|
||||
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
||||
|
||||
function toggleDemoMode() {
|
||||
demoMode.value = !demoMode.value
|
||||
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 退出登录时重置内存中的开关**
|
||||
|
||||
`storage.clear()` 会清掉 localStorage 里的标记,但内存中的 ref 还留着,同一次会话里换账号登录会带过去。把 `clearProfile` 改成:
|
||||
|
||||
```ts
|
||||
function clearProfile() {
|
||||
profile.value = null
|
||||
demoMode.value = false
|
||||
storage.clear()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 导出新成员**
|
||||
|
||||
在 store 末尾的 `return { ... }` 里加入三项(放在 `hasProblemPermission` 之后):
|
||||
|
||||
```ts
|
||||
demoMode,
|
||||
canToggleDemoMode,
|
||||
toggleDemoMode,
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 格式化并冒烟构建**
|
||||
|
||||
```bash
|
||||
cd ojnext
|
||||
npm fmt
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: 构建成功,无报错。
|
||||
|
||||
- [ ] **Step 8: 手工验证伪装生效(此时还没有 UI 入口,用 localStorage 模拟)**
|
||||
|
||||
启动 `npm start`,用超管账号登录,然后在浏览器 DevTools Console 执行:
|
||||
|
||||
```js
|
||||
localStorage.setItem("demoMode", "true")
|
||||
location.reload()
|
||||
```
|
||||
|
||||
逐项核对:
|
||||
- 顶栏「后台」菜单项消失
|
||||
- 地址栏直接输入 `/admin` → 被弹回首页
|
||||
- 题目详情页不再出现管理员专属按钮
|
||||
|
||||
再执行 `localStorage.setItem("demoMode", "false"); location.reload()`,确认上述内容全部恢复。
|
||||
|
||||
- [ ] **Step 9: 提交**
|
||||
|
||||
```bash
|
||||
cd ojnext
|
||||
git add src/utils/constants.ts src/shared/store/user.ts
|
||||
git commit -m "feat(user): store 层加入演示模式伪装开关
|
||||
|
||||
超管开启后所有角色 getter 降级为普通学生,全站权限判断自动跟随。
|
||||
realIsSuperAdmin 保留真实身份,用于判断能否切换。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 下拉菜单切换入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/shared/components/Header.vue:109-113`(新增函数)、`:178-227`(`options` 改为 computed 并增加菜单项)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 提供的 `userStore.demoMode`、`userStore.canToggleDemoMode`、`userStore.toggleDemoMode()`
|
||||
- Produces: 无后续任务依赖
|
||||
|
||||
- [ ] **Step 1: 把 `options` 从普通数组改为 computed**
|
||||
|
||||
`src/shared/components/Header.vue` 第 178 行现在是:
|
||||
|
||||
```ts
|
||||
const options: Array<DropdownOption | DropdownDividerOption> = [
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```ts
|
||||
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
|
||||
```
|
||||
|
||||
并把第 227 行的结尾 `]` 改为 `])`。
|
||||
|
||||
**这一步是必需的,不是风格偏好**:原来的 `options` 是普通数组,只在 setup 时求值一次。新菜单项的 `label`(「进入演示」/「退出演示」)和 `show` 都要跟随状态变化,留在普通数组里永远不会更新。同文件的 `menus`(第 119 行)本来就是 computed,改完两者一致。
|
||||
|
||||
模板里 `:options="options"`(第 280 行)不用动,computed 在模板中自动解包。
|
||||
|
||||
- [ ] **Step 2: 加入切换处理函数**
|
||||
|
||||
在 `handleLogout`(第 109-113 行)之后插入:
|
||||
|
||||
```ts
|
||||
function handleToggleDemoMode() {
|
||||
const entering = !userStore.demoMode
|
||||
userStore.toggleDemoMode()
|
||||
// 进入演示模式时若正停在后台页面,当前界面已经失去权限,必须主动退出去
|
||||
if (entering && route.path.startsWith("/admin")) {
|
||||
router.push("/")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`route` 和 `router` 在第 16-17 行已经拿到,`userStore` 在第 12 行已经拿到,无需新增。
|
||||
|
||||
- [ ] **Step 3: 在下拉菜单中加入菜单项**
|
||||
|
||||
在 `options` 数组里、`{ type: "divider" }`(第 220 行)**之前**插入:
|
||||
|
||||
```ts
|
||||
{
|
||||
label: userStore.demoMode ? "退出演示" : "进入演示",
|
||||
key: "demo-mode",
|
||||
show: userStore.canToggleDemoMode,
|
||||
icon: renderIcon("fluent-emoji:graduation-cap"),
|
||||
props: { onClick: handleToggleDemoMode },
|
||||
},
|
||||
```
|
||||
|
||||
文案本身就是状态指示器:看到「退出演示」说明当前正处于演示模式。按设计不额外加横幅。
|
||||
|
||||
- [ ] **Step 4: 格式化并冒烟构建**
|
||||
|
||||
```bash
|
||||
cd ojnext
|
||||
npm fmt
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: 构建成功,无报错。
|
||||
|
||||
- [ ] **Step 5: 手工验证完整流程**
|
||||
|
||||
先清掉 Task 1 遗留的手工标记:DevTools Console 执行 `localStorage.removeItem("demoMode")`,刷新。
|
||||
|
||||
用超管账号登录,逐项核对:
|
||||
|
||||
1. 右上角用户名下拉菜单出现「进入演示」,图标正常显示(不是空白方块)
|
||||
2. 点击 →「后台」菜单项消失,下拉菜单文案变为「退出演示」
|
||||
3. 刷新页面 → 仍是学生界面,菜单仍显示「退出演示」
|
||||
4. 地址栏直接输入 `/admin` → 弹回首页
|
||||
5. 点「退出演示」→「后台」入口恢复,文案变回「进入演示」
|
||||
6. 进入 `/admin/problem/list`,打开下拉菜单点「进入演示」→ 自动跳回首页
|
||||
7. 退出登录后重新用超管登录 → 演示模式已重置为关闭状态
|
||||
8. 用教师管理员账号登录 → 下拉菜单中**没有**这一项
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
cd ojnext
|
||||
git add src/shared/components/Header.vue
|
||||
git commit -m "feat(header): 用户下拉菜单加入学生视角开关
|
||||
|
||||
仅超管可见。进入演示模式时若停在后台页面则跳回首页。
|
||||
options 改为 computed,否则菜单文案与显示条件不会随状态更新。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成后
|
||||
|
||||
两个任务都提交后,整个特性即完成。回读一遍 spec 的「连带影响」章节,确认这些变化在实机上都是预期的:
|
||||
|
||||
- 提交列表的教师筛选与额外列消失,`showSubmissions` 改为跟随站点配置
|
||||
- 比赛失去超管免密码特权
|
||||
- 协同代码编辑(`shared/composables/sync.ts`)的超管特殊颜色与提示一并变为学生行为 —— **已确认不做豁免**
|
||||
@@ -0,0 +1,34 @@
|
||||
# Submit Formatting Button State
|
||||
|
||||
## Goal
|
||||
|
||||
Make the code submission button reflect the automatic formatting request that runs before submission.
|
||||
|
||||
## Behavior
|
||||
|
||||
- For Python3, C, and C++, the button displays `格式化中` while the formatting API request is pending.
|
||||
- During formatting, the button uses the existing loading icon and is disabled to prevent duplicate submissions.
|
||||
- After formatting succeeds, the existing submission flow continues and the button can display `正在提交`.
|
||||
- A formatting error stops submission and clears the formatting state before showing the existing warning.
|
||||
- A formatter server or network failure keeps the existing fallback behavior: clear the formatting state and submit the original code.
|
||||
- Languages without automatic formatting skip this state and submit directly.
|
||||
- Existing button labels and judging/cooldown behavior remain unchanged.
|
||||
|
||||
## Implementation
|
||||
|
||||
Add a component-local `isFormatting` ref in `SubmitCode.vue`.
|
||||
|
||||
- Include it in `submitDisabled`.
|
||||
- Give it priority in `submitLabel`, using `格式化中`.
|
||||
- Include it in the loading-icon condition.
|
||||
- Set it immediately before `formatCode`.
|
||||
- Clear it in a `finally` block so every formatter outcome restores the button state.
|
||||
|
||||
The state remains local because it is transient UI state owned only by the submission button.
|
||||
|
||||
## Verification
|
||||
|
||||
The frontend currently has no automated test suite. Verify with:
|
||||
|
||||
- TypeScript production build.
|
||||
- Manual inspection of the state transitions for successful formatting, formatting errors, formatter infrastructure failures, and languages that do not format.
|
||||
@@ -0,0 +1,28 @@
|
||||
# SQL 题强制至少 2 个测试点 — 设计
|
||||
|
||||
日期:2026-07-05
|
||||
|
||||
## 背景
|
||||
|
||||
题目页的 `sql_display` 用**测试点 1** 的数据生成期望结果展示(截断到 20 行)。如果 SQL 题只有 1 个测试点且结果 ≤20 行,页面展示的期望结果就是完整答案输出,学生可用 `SELECT ... UNION ALL ...`(query 模式)或硬编码 INSERT/UPDATE(modify 模式)对照抄写直接 AC。多测试点时数据不同,硬编码只能过测试点 1。
|
||||
|
||||
## 决定
|
||||
|
||||
SQL 题**强制至少 2 个数据不同的初始化脚本**,前后端双重拦截:
|
||||
|
||||
- **前端** `ojnext/src/admin/problem/components/SQLTestcaseEditor.vue`:
|
||||
- `canUpload` 要求非空脚本数 ≥ 2,不满足时上传按钮禁用;
|
||||
- 上传按钮 tooltip 在脚本不足时显示原因("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")。
|
||||
- **后端** `OnlineJudge/problem/views/admin.py` 的 `TestCaseZipProcessor.process_zip`:
|
||||
- `sql=True` 且测试点数 < 2 时 `raise APIError(...)`,兜底直接调 API 的情况。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 只在**重新上传/保存测试点**时拦截,已有的单测试点老题目不受影响、不回溯校验。
|
||||
- 非 SQL 题(.in/.out 沙箱判题)不受影响。
|
||||
- "数据不同"不做内容级校验(两个脚本内容相同也能过),只保证数量下限,YAGNI。
|
||||
|
||||
## 验证
|
||||
|
||||
前端 `vue-tsc --noEmit`、Prettier 通过;后端 `ruff check` / `ruff format --check` 通过。
|
||||
人工验证:出题页只填 1 个脚本 → 上传按钮禁用且 tooltip 说明原因;填 2 个并预览通过 → 可上传。
|
||||
@@ -0,0 +1,50 @@
|
||||
# SQL 题目表名/字段名自动补全 — 设计
|
||||
|
||||
日期:2026-07-05
|
||||
|
||||
## 目标
|
||||
|
||||
学生在 SQL 题目的代码编辑器里输入时,自动补全列表中除现有的 SQL 关键字/函数外,还出现**当前题目的表名和字段名**(带类型提示),减少抄写表名字段名的负担和拼写错误。
|
||||
|
||||
## 背景
|
||||
|
||||
- SQL 题目详情页已下发 `problem.sql_display`(`SQLDisplay` 类型),其中 `tables: SQLDisplayTable[]` 包含每张表的 `name`、`columns[{name, type}]`。数据在前端齐全,**无需后端改动**。
|
||||
- 编辑器补全入口是 `shared/extensions/autocompletion.ts` 的 `enhanceCompletion(language)`,`CodeEditor.vue` 和 `SyncCodeEditor.vue` 都用它,且都叠加了 `completeAnyWord`。
|
||||
- SQL 静态关键字补全表在 `shared/extensions/sql.ts`。
|
||||
- `shared` 直接 import `oj/store/problem` 已有先例(`FlowchartEditor/index.vue`)。
|
||||
|
||||
## 方案(已选:方案 A)
|
||||
|
||||
在 `enhanceCompletion` 中,当 `language === "SQL"` 时,从 `useProblemStore().problem?.sql_display?.tables` 动态生成补全项,追加到静态关键字列表后:
|
||||
|
||||
- **表名**:`type: "class"`,`detail: "数据表"`,`info` 列出该表全部字段(如 `字段:id INTEGER, name TEXT, score REAL`),`boost` 高于所有关键字(如 110)。
|
||||
- **字段名**:`type: "property"`,`detail` 标注来源表和类型(如 `students 的字段 · TEXT`),`boost` 略低于表名、高于关键字(如 105)。
|
||||
- **同名字段每表一条**,靠 detail 区分来源表。
|
||||
- store 在补全回调内惰性读取(每次按键执行),题目切换后自动反映最新表结构。
|
||||
- 非 SQL 语言、无题目上下文(如 admin/tutorial/learn 页面)或 `sql_display` 为空时,不追加任何项,行为与现状一致。
|
||||
|
||||
### 不做的事(YAGNI)
|
||||
|
||||
- 不改后端;不改题目描述展示(`SQLDataTable` 已展示表结构)。
|
||||
- 管理端出题的 SQL 编辑器(`SQLTestcaseEditor`)不接入。
|
||||
- 不做基于 SQL 语法位置的智能上下文补全(如 FROM 后只补表名)。
|
||||
|
||||
## 改动文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/shared/extensions/autocompletion.ts` | SQL 分支追加由 `sql_display.tables` 生成的动态补全项 |
|
||||
|
||||
(如生成逻辑较长,可拆一个小函数放同文件或 `sql.ts`,保持单一职责。)
|
||||
|
||||
## 错误处理
|
||||
|
||||
- `problem`、`sql_display`、`tables` 任一为空 → 返回纯静态列表(可选链兜底)。
|
||||
- Pinia store 在组件上下文外调用的风险:补全回调在编辑器运行期触发,此时 Pinia 已安装;与 FlowchartEditor 的既有用法一致。
|
||||
|
||||
## 测试
|
||||
|
||||
项目无测试套件(政策:不写新测试)。人工验证:
|
||||
1. 打开一道 SQL 题,编辑器中输入表名/字段名前缀,确认补全项出现且 detail/info 正确。
|
||||
2. 打开非 SQL 题,确认补全行为无变化。
|
||||
3. 协作编辑(SyncCodeEditor)场景同样生效。
|
||||
@@ -0,0 +1,102 @@
|
||||
# 学生视角(演示模式)设计
|
||||
|
||||
日期:2026-07-26
|
||||
范围:仅前端(ojnext)
|
||||
|
||||
## 背景
|
||||
|
||||
超级管理员给学生上课演示时,界面上到处是管理员才可见的入口和按钮(后台菜单、题目编辑、提交列表的额外操作列等)。这些东西对学生是噪音,也容易误点。需要一个一键开关,把界面临时切换成普通学生看到的样子。
|
||||
|
||||
## 目标
|
||||
|
||||
- 超管点一下,全站界面变成普通学生的样子
|
||||
- 再点一下恢复
|
||||
- 刷新页面不丢状态
|
||||
- 改动集中,不散落到几十个页面
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改后端。演示模式是纯界面伪装,登录态仍然是超管,接口权限不变。目的是演示,不是权限隔离。
|
||||
- 不做审计日志、不做时长限制。
|
||||
- 教师管理员、学生管理员不提供此功能。
|
||||
|
||||
## 机制
|
||||
|
||||
全站所有权限判断都读 `shared/store/user.ts` 里的几个 getter,没有任何一处直接读 `user.admin_type`。因此在 store 层加一个开关,就能一次性覆盖全部调用点。
|
||||
|
||||
```ts
|
||||
// shared/store/user.ts
|
||||
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
||||
|
||||
// 不受伪装影响的真实身份,只用于决定是否显示切换入口
|
||||
const realIsSuperAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
|
||||
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
|
||||
const isAdminRole = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
const isStudentAdmin = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
const isTeacherAdmin = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
const isTeacherOrAbove = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
const hasProblemPermission = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
|
||||
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
||||
|
||||
function toggleDemoMode() {
|
||||
demoMode.value = !demoMode.value
|
||||
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
|
||||
}
|
||||
```
|
||||
|
||||
`realIsSuperAdmin` 是关键:如果切换入口的显示条件用被伪装后的 `isSuperAdmin`,一进入演示模式入口自己就消失了,退不出来。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/utils/constants.ts` | `STORAGE_KEY` 增加 `DEMO_MODE: "demoMode"` |
|
||||
| `src/shared/store/user.ts` | 新增 `demoMode`、`realIsSuperAdmin`、`canToggleDemoMode`、`toggleDemoMode`;6 个角色 getter 加 `!demoMode.value &&` 前缀;导出新成员 |
|
||||
| `src/shared/components/Header.vue` | 用户下拉菜单 `options` 增加一项「进入演示 / 退出演示」 |
|
||||
|
||||
**不改**:`src/main.ts` 路由守卫、`src/utils/permissions.ts`、以及所有页面级的 `v-if` 判断。它们读的都是上述 getter,自动跟随。
|
||||
|
||||
## 交互
|
||||
|
||||
**入口**:右上角用户头像下拉菜单,与「我的主页」「我的提交」并列。
|
||||
|
||||
- 显示条件:`userStore.canToggleDemoMode`
|
||||
- 文案随状态翻转:未开启显示「进入演示」,已开启显示「退出演示」
|
||||
- 该文案本身就是状态指示器,不额外加横幅或角标
|
||||
|
||||
**点击行为**:
|
||||
|
||||
1. 调用 `toggleDemoMode()`
|
||||
2. 如果是**进入**演示模式,且当前路由属于 `admins` 分支,执行 `router.push("/")`。否则页面会停在一个已失去权限的后台界面上。
|
||||
3. 退出演示模式不需要跳转,留在当前页即可。
|
||||
|
||||
## 连带影响(均为预期行为)
|
||||
|
||||
- **后台入口消失**(`Header.vue:165-175`)。手动输入 `/admin/*` 地址也会被 `main.ts` 守卫弹回首页,因为守卫读的是被伪装后的 getter。
|
||||
- **提交列表**(`submission/list.vue`)的教师专属筛选、额外列隐藏;`showSubmissions` 改为跟随站点配置 `submission_list_show_all`,而不是无条件为 true。
|
||||
- **题目编辑表单**(`problem/components/Form.vue`)的管理员字段隐藏。
|
||||
- **比赛访问**(`oj/store/contest.ts:49`)失去超管免密码特权,需按学生流程输密码。演示时更真实。
|
||||
- **协同代码编辑**(`shared/composables/sync.ts`)的超管特殊颜色与提示一并变为学生行为。**确认不做豁免**——演示场景不涉及协同编辑功能。
|
||||
|
||||
## 持久化与清理
|
||||
|
||||
- 存 `localStorage`,key 为 `demoMode`
|
||||
- store 初始化时从 storage 读取,刷新后保持
|
||||
- 退出登录时 `clearProfile()` 调用 `storage.clear()`,会一并清除,不会残留到下一个登录用户
|
||||
|
||||
## 验证方式
|
||||
|
||||
手工验证(本项目不写测试):
|
||||
|
||||
1. 超管登录 → 下拉菜单出现「进入演示」
|
||||
2. 点击 → 顶栏「后台」消失,菜单文案变为「退出演示」
|
||||
3. 停在 `/admin/problem/list` 时点击 → 跳回首页
|
||||
4. 地址栏直接输 `/admin` → 弹回首页
|
||||
5. 刷新页面 → 仍是学生界面
|
||||
6. 点「退出演示」→ 后台入口恢复
|
||||
7. 退出登录再登录 → 演示模式已重置为关闭
|
||||
8. 用教师管理员账号登录 → 菜单中无此项
|
||||
216
apps/web/docs/图表.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 图表组件说明
|
||||
|
||||
基于 Chart.js 和 Vue-ChartJS 构建的数据可视化组件库,为 OJ Next 项目提供丰富的图表展示功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Chart.js** - 强大的图表库,支持多种图表类型
|
||||
- **Vue-ChartJS** - Chart.js 的 Vue 3 封装
|
||||
- **Vue 3 Composition API** - 现代化的组件开发方式
|
||||
- **TypeScript** - 完整的类型支持
|
||||
|
||||
## 现有图表组件
|
||||
|
||||
### 1. DurationChart 混合图表
|
||||
**文件位置**: `src/oj/ai/components/DurationChart.vue`
|
||||
|
||||
**功能描述**: 展示用户学习进度的时间趋势,结合柱状图和折线图。
|
||||
|
||||
**数据来源**: `durationData` API 接口
|
||||
- 每周/每月的综合情况
|
||||
- 题目数、提交数、等级变化
|
||||
|
||||
**图表类型**:
|
||||
- 柱状图:完成题目数
|
||||
- 折线图:提交次数、等级变化
|
||||
|
||||
**使用场景**: AI分析页面的主要图表
|
||||
|
||||
### 2. Heatmap 热力图
|
||||
**文件位置**: `src/oj/ai/components/Heatmap.vue`
|
||||
|
||||
**功能描述**: 展示用户的提交活跃度分布。
|
||||
|
||||
**数据来源**: `heatmapData` API 接口
|
||||
- 按日期统计提交数
|
||||
- 可视化用户活跃度
|
||||
|
||||
**图表类型**: 热力图矩阵
|
||||
|
||||
**使用场景**: 展示学习习惯和时间规律
|
||||
|
||||
### 3. Details 详细数据
|
||||
**文件位置**: `src/oj/ai/components/Details.vue`
|
||||
|
||||
**功能描述**: 展示用户详细的学习数据。
|
||||
|
||||
**数据来源**: `detailsData` API 接口
|
||||
- 用户等级、已解决题目列表
|
||||
- 标签统计、难度统计
|
||||
- 参赛次数等
|
||||
|
||||
**展示方式**: 数据表格和统计卡片
|
||||
|
||||
## 可扩展的图表类型
|
||||
|
||||
### 1. 提交效率趋势图 (折线图)
|
||||
**数据来源**: `durationData` 中的 `submission_count / problem_count`
|
||||
**展示内容**: 每个时间段的提交效率(提交次数/完成题目数)
|
||||
**价值**: 反映刷题质量的提升,值越接近1说明一次AC率越高
|
||||
|
||||
### 2. 排名分布图 (直方图/箱线图)
|
||||
**数据来源**: `solved` 数组中每道题的 `rank` 和 `ac_count`
|
||||
**展示内容**: 用户解题排名的分布情况(如:前10%、10-30%、30-50%等区间的题目数量)
|
||||
**价值**: 了解解题速度和竞争力
|
||||
|
||||
### 3. 等级分布饼图/环形图
|
||||
**数据来源**: `solved` 数组中每道题的 `grade`
|
||||
**展示内容**: S/A/B/C 各等级题目的数量和占比
|
||||
**价值**: 直观看出题目质量分布
|
||||
|
||||
### 4. 标签雷达图
|
||||
**数据来源**: `tags` 对象
|
||||
**展示内容**: 多维度展示各类标签的掌握程度(可以归一化处理)
|
||||
**价值**: 可视化知识点覆盖面
|
||||
|
||||
### 5. 时间活跃度分析 (热力矩阵)
|
||||
**数据来源**: `solved` 数组中的 `ac_time`
|
||||
**展示内容**: 按星期几和时间段统计做题分布(如:工作日vs周末,早中晚时段)
|
||||
**价值**: 了解学习习惯和时间规律
|
||||
|
||||
### 6. 难度-等级关联散点图
|
||||
**数据来源**: `solved` 数组中的难度信息和 `grade`
|
||||
**展示内容**: X轴为难度,Y轴为等级,每个点代表一道题
|
||||
**价值**: 分析在不同难度下的表现
|
||||
|
||||
### 7. 做题加速度图
|
||||
**数据来源**: `durationData`
|
||||
**展示内容**: 每个时间段完成题目数的变化率
|
||||
**价值**: 看出学习动力的变化趋势
|
||||
|
||||
### 8. 竞赛题目占比
|
||||
**数据来源**: `solved` 数组中的 `contest_id` 和 `contest_count`
|
||||
**展示内容**: 竞赛题 vs 常规题的数量对比
|
||||
**价值**: 了解竞赛参与情况
|
||||
|
||||
### 9. 连续做题天数统计
|
||||
**数据来源**: `heatmapData`
|
||||
**展示内容**: 最长连续做题天数、当前连续天数等
|
||||
**价值**: 激励持续学习
|
||||
|
||||
### 10. 月度对比雷达图
|
||||
**数据来源**: `durationData`
|
||||
**展示内容**: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
|
||||
**价值**: 全面评估进步情况
|
||||
|
||||
## 组件开发指南
|
||||
|
||||
### 创建新图表组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="chart-container">
|
||||
<Line
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
|
||||
// 计算图表数据
|
||||
const chartData = computed(() => ({
|
||||
labels: props.data.map(item => item.label),
|
||||
datasets: [{
|
||||
label: '数据',
|
||||
data: props.data.map(item => item.value),
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
}))
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 图表样式定制
|
||||
|
||||
```typescript
|
||||
// 主题配置
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: '#6366f1',
|
||||
secondary: '#8b5cf6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444'
|
||||
},
|
||||
gradients: {
|
||||
primary: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
success: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 1. 数据懒加载
|
||||
- 按需加载图表数据
|
||||
- 使用虚拟滚动处理大量数据
|
||||
|
||||
### 2. 图表缓存
|
||||
- 缓存计算结果
|
||||
- 避免重复渲染
|
||||
|
||||
### 3. 响应式设计
|
||||
- 自适应容器大小
|
||||
- 移动端优化
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 数据预处理
|
||||
- 在组件外部处理数据
|
||||
- 使用 computed 属性缓存计算结果
|
||||
|
||||
### 2. 错误处理
|
||||
- 添加数据验证
|
||||
- 提供降级方案
|
||||
|
||||
### 3. 用户体验
|
||||
- 添加加载状态
|
||||
- 提供交互反馈
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-01-15)
|
||||
- ✨ 初始版本发布
|
||||
- 📊 基础图表组件
|
||||
- 🎨 主题样式支持
|
||||
- 📱 响应式设计
|
||||
|
||||
### v1.1.0 (2024-01-20)
|
||||
- 🔧 性能优化
|
||||
- 📈 新增混合图表
|
||||
- 🎯 交互体验改进
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
17
apps/web/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="/noto--dog-face.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>判题狗</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<script>
|
||||
window.localStorage.setItem("maxkbMaskTip", true)
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
67
apps/web/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@oj2/web",
|
||||
"version": "1.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"build:staging": "vite build --mode staging",
|
||||
"build:test": "vite build --mode test",
|
||||
"fmt": "prettier --write src *.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oj2/contract": "workspace:*",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@vue-flow/node-resizer": "^1.5.1",
|
||||
"@vue-flow/node-toolbar": "^1.1.1",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@vueuse/router": "^14.4.0",
|
||||
"@wangeditor-next/editor": "^6.2.0",
|
||||
"@wangeditor-next/editor-for-vue": "^6.2.0",
|
||||
"axios": "^1.19.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-chart-wordcloud": "^4.4.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"fflate": "^0.8.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"md-editor-v3": "^6.5.6",
|
||||
"mermaid": "^11.16.1",
|
||||
"mermaid-legacy": "npm:mermaid@^9.4.3",
|
||||
"naive-ui": "^2.44.1",
|
||||
"nanoid": "^6.0.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^4.0.2",
|
||||
"skulpt": "^1.2.0",
|
||||
"vue": "^3.5.41",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-router": "^5.2.0",
|
||||
"y-codemirror.next": "^0.3.5",
|
||||
"y-webrtc": "^10.3.0",
|
||||
"yjs": "^13.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "^5.0.1",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^7.0.2",
|
||||
"unplugin-auto-import": "^21.1.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
BIN
apps/web/public/A.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
apps/web/public/B.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
apps/web/public/C.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/public/Monaco.ttf
Normal file
BIN
apps/web/public/S.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/web/public/badge-1.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/web/public/badge-2.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/web/public/badge-3.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
apps/web/public/badge-4.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
apps/web/public/badge-5.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
apps/web/public/badge-6.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
apps/web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/web/public/noto--dog-face.ico
Normal file
|
After Width: | Height: | Size: 188 KiB |
20
apps/web/public/style.css
Normal file
@@ -0,0 +1,20 @@
|
||||
@font-face {
|
||||
font-family: "Monaco";
|
||||
src: url(/Monaco.ttf);
|
||||
}
|
||||
|
||||
.md-editor-preview .md-editor-code .md-editor-code-head {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
.md-editor-preview img {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.md-editor-preview h1 {
|
||||
font-size: 1.6rem !important;
|
||||
}
|
||||
|
||||
.md-editor-preview h2 {
|
||||
font-size: 1.4rem !important;
|
||||
}
|
||||
77
apps/web/src/App.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
|
||||
import "normalize.css"
|
||||
import "./index.css"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useConfigUpdate } from "shared/composables/configUpdate"
|
||||
import { useMaxKB } from "shared/composables/maxkb"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const isDark = useDark()
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 初始化配置和实时更新
|
||||
onMounted(() => {
|
||||
configStore.getConfig()
|
||||
userStore.getMyProfile()
|
||||
})
|
||||
|
||||
// 使用配置更新和 MaxKB 功能
|
||||
useConfigUpdate()
|
||||
useMaxKB()
|
||||
|
||||
// 延迟加载 highlight.js,避免阻塞首屏
|
||||
const hljsInstance = ref<any>(null)
|
||||
const loadHighlightJS = async () => {
|
||||
if (hljsInstance.value) return hljsInstance.value
|
||||
|
||||
const [hljs, c, cpp, python, java, javascript, go, sql] = await Promise.all([
|
||||
import("highlight.js/lib/core"),
|
||||
import("highlight.js/lib/languages/c"),
|
||||
import("highlight.js/lib/languages/cpp"),
|
||||
import("highlight.js/lib/languages/python"),
|
||||
import("highlight.js/lib/languages/java"),
|
||||
import("highlight.js/lib/languages/javascript"),
|
||||
import("highlight.js/lib/languages/go"),
|
||||
import("highlight.js/lib/languages/sql"),
|
||||
]).then((modules) => modules.map((m) => m.default))
|
||||
|
||||
hljs.registerLanguage("c", c)
|
||||
hljs.registerLanguage("python", python)
|
||||
hljs.registerLanguage("cpp", cpp)
|
||||
hljs.registerLanguage("java", java)
|
||||
hljs.registerLanguage("javascript", javascript)
|
||||
hljs.registerLanguage("go", go)
|
||||
hljs.registerLanguage("sql", sql)
|
||||
|
||||
hljsInstance.value = hljs
|
||||
return hljs
|
||||
}
|
||||
|
||||
// 在空闲时预加载
|
||||
onMounted(() => {
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => loadHighlightJS())
|
||||
} else {
|
||||
setTimeout(() => loadHighlightJS(), 1000)
|
||||
}
|
||||
})
|
||||
|
||||
provide("hljs", hljsInstance)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider
|
||||
:theme="isDark ? darkTheme : null"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:hljs="hljsInstance"
|
||||
>
|
||||
<n-dialog-provider>
|
||||
<n-message-provider>
|
||||
<router-view></router-view>
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
185
apps/web/src/admin/achievement/components/AchievementModal.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
createAchievement,
|
||||
getMetricOptions,
|
||||
updateAchievement,
|
||||
type AdminAchievement,
|
||||
type MetricOption,
|
||||
} from "admin/api"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
|
||||
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
|
||||
label,
|
||||
value,
|
||||
}))
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
editing: AdminAchievement | null
|
||||
}>()
|
||||
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const metrics = ref<MetricOption[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "noto:trophy",
|
||||
rarity: "bronze",
|
||||
hidden: false,
|
||||
metric: "",
|
||||
operator: "gte" as "gte" | "lte",
|
||||
threshold: 1,
|
||||
visible: true,
|
||||
order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
const metricOptions = computed(() =>
|
||||
metrics.value.map((m) => ({ label: `${m.name}(${m.key})`, value: m.key })),
|
||||
)
|
||||
|
||||
const metricHelp = computed(
|
||||
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (show) => {
|
||||
if (!show) return
|
||||
if (!metrics.value.length) {
|
||||
const res = await getMetricOptions()
|
||||
metrics.value = res.data
|
||||
}
|
||||
if (props.editing) {
|
||||
form.value = { ...emptyForm(), ...props.editing }
|
||||
} else {
|
||||
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? "" }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.metric) {
|
||||
message.error("名称和指标不能为空")
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.editing) {
|
||||
await updateAchievement({ ...form.value, id: props.editing.id })
|
||||
} else {
|
||||
await createAchievement(form.value)
|
||||
}
|
||||
message.success("保存成功")
|
||||
emit("update:show", false)
|
||||
emit("saved")
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
style="width: 560px"
|
||||
:title="editing ? '编辑成就' : '新建成就'"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form label-placement="left" :label-width="80">
|
||||
<n-form-item label="名称" required>
|
||||
<n-input v-model:value="form.name" placeholder="成就名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述" required>
|
||||
<n-input
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
placeholder="达成条件的描述,展示给学生看"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="图标">
|
||||
<n-flex vertical :size="4" style="flex: 1">
|
||||
<n-flex align="center" :size="10" :wrap="false">
|
||||
<div class="icon-preview">
|
||||
<AchievementIcon :icon="form.icon" :size="28" />
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="form.icon"
|
||||
placeholder="iconify 图标名,例如 noto:owl"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
填 iconify 图标名(推荐 noto: 开头的彩色 emoji 图标),左侧是实时
|
||||
预览;预览不出来说明名字写错了。图标名可在 icon-sets.iconify.design
|
||||
搜索。
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-form-item label="稀有度">
|
||||
<n-select v-model:value="form.rarity" :options="rarityOptions" />
|
||||
</n-form-item>
|
||||
<n-form-item label="指标" required>
|
||||
<n-select
|
||||
v-model:value="form.metric"
|
||||
:options="metricOptions"
|
||||
filterable
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="metricHelp" label=" ">
|
||||
<n-text depth="3">{{ metricHelp }}</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="条件">
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="form.operator"
|
||||
style="width: 130px"
|
||||
:options="[
|
||||
{ label: '大于等于', value: 'gte' },
|
||||
{ label: '小于等于', value: 'lte' },
|
||||
]"
|
||||
/>
|
||||
<n-input-number v-model:value="form.threshold" :min="0" />
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-form-item label="隐藏成就">
|
||||
<n-switch v-model:value="form.hidden" />
|
||||
<n-text depth="3" style="margin-left: 12px">
|
||||
未解锁时学生只能看到 ???
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="上架">
|
||||
<n-switch v-model:value="form.visible" />
|
||||
</n-form-item>
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="form.order" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="emit('update:show', false)">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">
|
||||
保存
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
144
apps/web/src/admin/achievement/list.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex } from "naive-ui"
|
||||
import {
|
||||
deleteAchievement,
|
||||
getAdminAchievements,
|
||||
type AdminAchievement,
|
||||
} from "admin/api"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import AchievementModal from "./components/AchievementModal.vue"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const list = ref<AdminAchievement[]>([])
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editing = ref<AdminAchievement | null>(null)
|
||||
|
||||
// 下架的只在有的时候才提,全部上架时标题不啰嗦
|
||||
const title = computed(() => {
|
||||
if (!list.value.length) return "成就管理"
|
||||
const offline = list.value.filter((a) => !a.visible).length
|
||||
return offline
|
||||
? `成就管理(${list.value.length} 条,${offline} 条下架)`
|
||||
: `成就管理(${list.value.length} 条)`
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAdminAchievements()
|
||||
list.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function create() {
|
||||
editing.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function edit(row: AdminAchievement) {
|
||||
editing.value = row
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function remove(row: AdminAchievement) {
|
||||
dialog.warning({
|
||||
title: "删除成就",
|
||||
content: `确定删除「${row.name}」?已解锁记录会一并删除。`,
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await deleteAchievement(row.id)
|
||||
message.success("已删除")
|
||||
load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<AdminAchievement>[] = [
|
||||
{
|
||||
title: "图标",
|
||||
key: "icon",
|
||||
width: 60,
|
||||
render: (row) => h(AchievementIcon, { icon: row.icon, size: 24 }),
|
||||
},
|
||||
{ title: "名称", key: "name" },
|
||||
{
|
||||
title: "稀有度",
|
||||
key: "rarity",
|
||||
width: 90,
|
||||
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
|
||||
},
|
||||
{ title: "指标", key: "metric_name" },
|
||||
{
|
||||
title: "条件",
|
||||
key: "threshold",
|
||||
width: 110,
|
||||
render: (row) => `${row.operator === "gte" ? "≥" : "≤"} ${row.threshold}`,
|
||||
},
|
||||
{
|
||||
title: "隐藏",
|
||||
key: "hidden",
|
||||
width: 70,
|
||||
render: (row) => (row.hidden ? "是" : "—"),
|
||||
},
|
||||
{
|
||||
title: "上架",
|
||||
key: "visible",
|
||||
width: 70,
|
||||
render: (row) => (row.visible ? "是" : "否"),
|
||||
},
|
||||
{ title: "已解锁人数", key: "unlock_count", width: 110 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 130,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 8 }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => edit(row) },
|
||||
() => "编辑",
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "error", onClick: () => remove(row) },
|
||||
() => "删除",
|
||||
),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card :title="title">
|
||||
<template #header-extra>
|
||||
<n-button type="primary" @click="create">新建成就</n-button>
|
||||
</template>
|
||||
|
||||
<n-alert type="info" style="margin-bottom: 12px">
|
||||
「已解锁人数」是唯一的仪表盘:配置一周后仍为
|
||||
0,多半是阈值配错了而不是太难。
|
||||
</n-alert>
|
||||
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:row-key="(row: AdminAchievement) => row.id"
|
||||
/>
|
||||
|
||||
<AchievementModal
|
||||
v-model:show="showModal"
|
||||
:editing="editing"
|
||||
@saved="load"
|
||||
/>
|
||||
</n-card>
|
||||
</template>
|
||||
206
apps/web/src/admin/ai/list.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<n-flex justify="space-between" class="titleWrapper">
|
||||
<h2 class="title">AI 学习分析报告</h2>
|
||||
<n-input
|
||||
v-model:value="query.username"
|
||||
clearable
|
||||
placeholder="输入用户名筛选"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-alert
|
||||
v-if="pinnedReports.length > 0"
|
||||
type="warning"
|
||||
:show-icon="true"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
以下 <strong>{{ pinnedReports.length }}</strong> 位用户的 AI
|
||||
分析报告已被锁定,前台将固定显示该报告:
|
||||
<n-flex style="margin-top: 8px" :wrap="true" :size="[8, 6]">
|
||||
<n-tag
|
||||
v-for="r in pinnedReports"
|
||||
:key="r.id"
|
||||
type="warning"
|
||||
size="small"
|
||||
closable
|
||||
@close="togglePin(r)"
|
||||
>
|
||||
{{ r.username }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</n-alert>
|
||||
<n-data-table striped :columns="columns" :data="reports" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
title="分析报告详情"
|
||||
style="width: 800px; max-width: 95vw"
|
||||
>
|
||||
<n-spin :show="loadingDetail">
|
||||
<div v-if="detail" class="detail">
|
||||
<n-descriptions :column="2" bordered size="small" class="meta">
|
||||
<n-descriptions-item label="用户">{{
|
||||
detail.username
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="班级">{{
|
||||
detail.class_name || "-"
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="时间" :span="2">{{
|
||||
parseTime(detail.create_time, "YYYY-MM-DD HH:mm:ss")
|
||||
}}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
|
||||
<MdPreview :model-value="detail.analysis" />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import {
|
||||
getAIReportList,
|
||||
getAIReportDetail,
|
||||
pinAIReport,
|
||||
getPinnedAIReports,
|
||||
} from "../api"
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
|
||||
interface ReportItem {
|
||||
id: number
|
||||
create_time: string
|
||||
username: string
|
||||
analysis_excerpt: string
|
||||
is_pinned: boolean
|
||||
}
|
||||
|
||||
interface ReportDetail extends ReportItem {
|
||||
analysis: string
|
||||
class_name: string | null
|
||||
}
|
||||
|
||||
const reports = ref<ReportItem[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ limit: 10, page: 1, username: "" })
|
||||
const pinnedReports = ref<ReportItem[]>([])
|
||||
|
||||
const showModal = ref(false)
|
||||
const loadingDetail = ref(false)
|
||||
const detail = ref<ReportDetail | null>(null)
|
||||
|
||||
const columns: DataTableColumn<ReportItem>[] = [
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 150,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: row.is_pinned ? "font-weight:600" : "" },
|
||||
row.username,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "AI 分析内容",
|
||||
key: "analysis_excerpt",
|
||||
render: (row) => row.analysis_excerpt || "-",
|
||||
},
|
||||
{
|
||||
title: "生成时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "PIN 状态",
|
||||
key: "is_pinned",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
row.is_pinned
|
||||
? h(NTag, { type: "warning", size: "small" }, () => "已锁定")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 160,
|
||||
render: (row) =>
|
||||
h("span", { style: "display:flex;gap:8px" }, [
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", type: "primary", onClick: () => openDetail(row.id) },
|
||||
() => "查看",
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: row.is_pinned ? "error" : "default",
|
||||
onClick: () => togglePin(row),
|
||||
},
|
||||
() => (row.is_pinned ? "取消 PIN" : "PIN"),
|
||||
),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadPinnedReports() {
|
||||
const res = await getPinnedAIReports()
|
||||
pinnedReports.value = res.data
|
||||
}
|
||||
|
||||
async function togglePin(row: ReportItem) {
|
||||
await pinAIReport(row.id)
|
||||
await Promise.all([listReports(), loadPinnedReports()])
|
||||
}
|
||||
|
||||
async function listReports() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getAIReportList(offset, query.limit, query.username)
|
||||
reports.value = res.data.results
|
||||
total.value = res.data.total
|
||||
}
|
||||
|
||||
async function openDetail(id: number) {
|
||||
showModal.value = true
|
||||
loadingDetail.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
const res = await getAIReportDetail(id)
|
||||
detail.value = res.data
|
||||
} finally {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => Promise.all([listReports(), loadPinnedReports()]))
|
||||
watch(() => [query.page, query.limit], listReports)
|
||||
watchDebounced(() => query.username, listReports, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
.detail .meta {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
39
apps/web/src/admin/announcement/components/Actions.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteAnnouncement } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
announcementID: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin announcement edit",
|
||||
params: { announcementID: props.announcementID },
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteAnnouncement(props.announcementID)
|
||||
message.success("删除成功")
|
||||
emit("deleted")
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="success" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="handleDelete">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" secondary>删除</n-button>
|
||||
</template>
|
||||
确定删除这条公告吗?
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
117
apps/web/src/admin/announcement/detail.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import type { AnnouncementEdit } from "utils/types"
|
||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||
|
||||
interface Props {
|
||||
announcementID?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const props = defineProps<Props>()
|
||||
const [ready, toggleReady] = useToggle()
|
||||
|
||||
const announcement = reactive<AnnouncementEdit>({
|
||||
id: 0,
|
||||
title: "",
|
||||
tag: "公告",
|
||||
content: "",
|
||||
visible: false,
|
||||
top: false,
|
||||
})
|
||||
|
||||
const tags: SelectOption[] = [
|
||||
{ label: "公告", value: "公告" },
|
||||
{ label: "更新", value: "更新" },
|
||||
]
|
||||
|
||||
async function init() {
|
||||
if (!props.announcementID) {
|
||||
toggleReady(true)
|
||||
return
|
||||
}
|
||||
const id = parseInt(route.params.announcementID as string)
|
||||
const res = await getAnnouncement(id)
|
||||
toggleReady(true)
|
||||
announcement.id = id
|
||||
announcement.title = res.data.title
|
||||
announcement.content = res.data.content
|
||||
announcement.visible = res.data.visible
|
||||
announcement.tag = res.data.tag
|
||||
announcement.top = res.data.top
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (announcement.content === "<p><br></p>") {
|
||||
announcement.content = ""
|
||||
}
|
||||
if (!announcement.title || !announcement.content) {
|
||||
message.error("标题和正文必填")
|
||||
return
|
||||
}
|
||||
const api = {
|
||||
"admin announcement create": createAnnouncement,
|
||||
"admin announcement edit": editAnnouncement,
|
||||
}[route.name as string]
|
||||
try {
|
||||
await api!(announcement)
|
||||
if (route.name === "admin announcement create") {
|
||||
message.success("成功新建公告 💐")
|
||||
} else {
|
||||
message.success("修改已保存")
|
||||
}
|
||||
router.push({ name: "admin announcement list" })
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
<template>
|
||||
<h2 class="title">
|
||||
{{ route.name === "admin announcement create" ? "新建公告" : "编辑公告" }}
|
||||
</h2>
|
||||
<n-form inline>
|
||||
<n-form-item label="标题">
|
||||
<n-input class="contestTitle" v-model:value="announcement.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标签">
|
||||
<n-select
|
||||
class="select"
|
||||
v-model:value="announcement.tag"
|
||||
:options="tags"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="announcement.visible" />
|
||||
</n-form-item>
|
||||
<n-form-item label="置顶">
|
||||
<n-switch v-model:value="announcement.top" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<TextEditor
|
||||
v-if="ready"
|
||||
title="正文"
|
||||
v-model:value="announcement.content"
|
||||
:min-height="200"
|
||||
/>
|
||||
<n-flex style="margin-bottom: 100px" justify="end">
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped>
|
||||
.title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.contestTitle {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
113
apps/web/src/admin/announcement/list.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Announcement } from "utils/types"
|
||||
import { editAnnouncement, getAnnouncementList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
})
|
||||
const announcements = ref<Announcement[]>([])
|
||||
|
||||
const columns: DataTableColumn<Announcement>[] = [
|
||||
{ title: "ID", key: "id", width: 60 },
|
||||
{ title: "标题", key: "title", minWidth: 300 },
|
||||
{ title: "标签", key: "tag", width: 80 },
|
||||
{
|
||||
title: "置顶",
|
||||
key: "top",
|
||||
render: (row) => (row.top ? "置顶" : ""),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "上次更新时间",
|
||||
key: "last_update_time",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.last_update_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "作者",
|
||||
key: "created_by",
|
||||
render: (row) => row.created_by.username,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
render: (row) =>
|
||||
h(Actions, { announcementID: row.id, onDeleted: listAnnouncements }),
|
||||
},
|
||||
]
|
||||
|
||||
async function toggleVisible(announcement: Announcement) {
|
||||
announcement.visible = !announcement.visible
|
||||
editAnnouncement({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
content: announcement.content,
|
||||
visible: announcement.visible,
|
||||
top: announcement.top,
|
||||
})
|
||||
}
|
||||
|
||||
async function listAnnouncements() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getAnnouncementList(offset, query.limit)
|
||||
announcements.value = res.data.results
|
||||
total.value = res.data.total
|
||||
}
|
||||
|
||||
onMounted(listAnnouncements)
|
||||
watch(query, listAnnouncements, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex align="center" class="titleWrapper">
|
||||
<h2 class="title">网站公告</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin announcement create' })"
|
||||
>
|
||||
新建
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="announcements" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
600
apps/web/src/admin/api.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import http from "utils/http"
|
||||
import { toProblemListItem } from "admin/transforms"
|
||||
import type {
|
||||
AdminProblem,
|
||||
AdminTag,
|
||||
Announcement,
|
||||
AnnouncementEdit,
|
||||
BlankContest,
|
||||
BlankProblem,
|
||||
Contest,
|
||||
Exercise,
|
||||
ExerciseType,
|
||||
Server,
|
||||
SQLDisplay,
|
||||
TestcaseUploadedReturns,
|
||||
Tutorial,
|
||||
User,
|
||||
WebsiteConfig,
|
||||
} from "utils/types"
|
||||
|
||||
export function getBaseInfo() {
|
||||
return http.get("admin/dashboard_info")
|
||||
}
|
||||
|
||||
export function randomUser10(classroom: string) {
|
||||
return http.get("admin/random_user", { params: { classroom } })
|
||||
}
|
||||
|
||||
export async function getProblemList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
keyword: string,
|
||||
author?: string,
|
||||
contestID?: string,
|
||||
tagId?: number,
|
||||
) {
|
||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||
const res = await http.get<{ results: AdminProblem[]; total: number }>(
|
||||
endpoint,
|
||||
{
|
||||
params: {
|
||||
paging: true,
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
tag_id: tagId,
|
||||
},
|
||||
},
|
||||
)
|
||||
return {
|
||||
results: res.data.results.map(toProblemListItem),
|
||||
total: res.data.total,
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteProblem(id: number) {
|
||||
return http.delete("admin/problem", { params: { id } })
|
||||
}
|
||||
|
||||
export function deleteContestProblem(id: number) {
|
||||
return http.delete("admin/contest/problem", { params: { id } })
|
||||
}
|
||||
|
||||
export function editProblem(problem: AdminProblem | BlankProblem) {
|
||||
return http.put("admin/problem", problem)
|
||||
}
|
||||
|
||||
export function toggleProblemVisible(problemID: number) {
|
||||
return http.put("admin/problem/visible", { id: problemID })
|
||||
}
|
||||
|
||||
export function generateFlowchartFromPythonCode(python: string) {
|
||||
return http.post("admin/problem/flowchart", { python })
|
||||
}
|
||||
|
||||
export function editContestProblem(problem: AdminProblem | BlankProblem) {
|
||||
return http.put("admin/contest/problem", problem)
|
||||
}
|
||||
|
||||
export function getProblem(id: string | number) {
|
||||
return http.get<AdminProblem>("admin/problem", { params: { id } })
|
||||
}
|
||||
|
||||
export function getContestProblem(id: number) {
|
||||
return http.get("admin/contest/problem", { params: { id } })
|
||||
}
|
||||
|
||||
// 标签管理
|
||||
export function getTagAdminList(keyword = "") {
|
||||
return http.get<AdminTag[]>("admin/problem/tag", { params: { keyword } })
|
||||
}
|
||||
|
||||
export function renameTag(id: number, name: string) {
|
||||
return http.put<{
|
||||
merged: boolean
|
||||
id: number
|
||||
name: string
|
||||
affected_count: number
|
||||
}>("admin/problem/tag", { id, name })
|
||||
}
|
||||
|
||||
export function deleteTag(id: number) {
|
||||
return http.delete("admin/problem/tag", { params: { id } })
|
||||
}
|
||||
|
||||
export function batchTagProblems(
|
||||
problemIds: number[],
|
||||
tagNames: string[],
|
||||
action: "add" | "remove",
|
||||
) {
|
||||
return http.post<{ problem_count: number; tag_count: number }>(
|
||||
"admin/problem/batch_tag",
|
||||
{ problem_ids: problemIds, tag_names: tagNames, action },
|
||||
)
|
||||
}
|
||||
|
||||
// 用户列表
|
||||
export function getUserList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
type = "",
|
||||
keyword: string,
|
||||
orderBy = "",
|
||||
) {
|
||||
return http.get("admin/user", {
|
||||
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
export function editUser(user: User) {
|
||||
return http.put("admin/user", user)
|
||||
}
|
||||
|
||||
// 重置用户密码
|
||||
export function resetPassword(userID: number) {
|
||||
return http.post("admin/reset_password", { id: userID })
|
||||
}
|
||||
|
||||
// 导入用户
|
||||
export function importUsers(users: string[][]) {
|
||||
return http.post("admin/user", { users })
|
||||
}
|
||||
|
||||
// 批量删除用户
|
||||
export function deleteUsers(userIDs: number[]) {
|
||||
return http.delete("admin/user", { params: { id: userIDs.join(",") } })
|
||||
}
|
||||
|
||||
export function getContestList(offset = 0, limit = 10, keyword: string) {
|
||||
return http.get("admin/contest", {
|
||||
params: { paging: true, offset, limit, keyword },
|
||||
})
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
export async function uploadImage(file: File): Promise<string> {
|
||||
const form = new window.FormData()
|
||||
form.append("image", file)
|
||||
// 该端点不走 { error, data } 信封,直接返回上传结果
|
||||
const res = (await http.post("admin/upload_image", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})) as unknown as { success: boolean; file_path: string; msg: "Success" }
|
||||
return res.success ? res.file_path : ""
|
||||
}
|
||||
|
||||
// 上传测试用例;SQL 题的压缩包是 1.sql..N.sql(每个文件一个测试点的建表+数据脚本)
|
||||
export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
|
||||
const form = new window.FormData()
|
||||
form.append("file", file)
|
||||
if (options.sql) {
|
||||
form.append("sql", "1")
|
||||
}
|
||||
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
}
|
||||
|
||||
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
|
||||
export function previewSQLTestcase(data: {
|
||||
init_sql: string
|
||||
ref_sql: string
|
||||
mode: "query" | "modify"
|
||||
}) {
|
||||
return http.post<SQLDisplay>("admin/sql_test_case_preview", data)
|
||||
}
|
||||
|
||||
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
|
||||
export function getSQLTestcaseScripts(problemId: number) {
|
||||
return http.get<{ name: string; content: string }[]>(
|
||||
"admin/sql_test_case_scripts",
|
||||
{ params: { problem_id: problemId } },
|
||||
)
|
||||
}
|
||||
|
||||
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
|
||||
export function generateSQLTestcase(data: {
|
||||
ref_sql: string
|
||||
mode: "query" | "modify"
|
||||
}) {
|
||||
return http.post<{ sql: string }>("admin/sql_test_case_ai_gen", data)
|
||||
}
|
||||
|
||||
export function createProblem(problem: BlankProblem) {
|
||||
return http.post("admin/problem", problem)
|
||||
}
|
||||
|
||||
export function createContestProblem(problem: BlankProblem) {
|
||||
return http.post("admin/contest/problem", problem)
|
||||
}
|
||||
|
||||
export function createContest(contest: BlankContest) {
|
||||
return http.post("admin/contest", contest)
|
||||
}
|
||||
|
||||
export function editContest(contest: Contest | BlankContest) {
|
||||
return http.put("admin/contest", contest)
|
||||
}
|
||||
|
||||
export function cloneContest(contest_id: number) {
|
||||
return http.post("admin/contest/clone", { contest_id })
|
||||
}
|
||||
|
||||
export function getContest(id: string) {
|
||||
return http.get<Contest & { password: string }>("admin/contest", {
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export function addProblemForContest(
|
||||
contestID: string,
|
||||
problemID: number,
|
||||
displayID: string,
|
||||
) {
|
||||
return http.post("admin/contest/add_problem_from_public", {
|
||||
contest_id: contestID,
|
||||
problem_id: problemID,
|
||||
display_id: displayID,
|
||||
})
|
||||
}
|
||||
|
||||
export function getWebsite() {
|
||||
return http.get<WebsiteConfig>("admin/website")
|
||||
}
|
||||
|
||||
export function editWebsite(data: WebsiteConfig) {
|
||||
return http.post("admin/website", data)
|
||||
}
|
||||
|
||||
export function listInvalidTestcases() {
|
||||
return http.get("admin/prune_test_case")
|
||||
}
|
||||
|
||||
export function pruneInvalidTestcases(id?: string) {
|
||||
return http.delete("admin/prune_test_case", { params: { id } })
|
||||
}
|
||||
|
||||
export function getJudgeServer() {
|
||||
return http.get<{ token: string; servers: Server[] }>("admin/judge_server")
|
||||
}
|
||||
|
||||
export function deleteJudgeServer(hostname: string) {
|
||||
return http.delete("admin/judge_server", { params: { hostname } })
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return http.get("admin/announcement", {
|
||||
params: { paging: true, offset, limit },
|
||||
})
|
||||
}
|
||||
|
||||
export function getAnnouncement(id: number) {
|
||||
return http.get<Announcement>("admin/announcement", { params: { id } })
|
||||
}
|
||||
|
||||
export function deleteAnnouncement(id: number) {
|
||||
return http.delete("admin/announcement", { params: { id } })
|
||||
}
|
||||
|
||||
export function editAnnouncement(announcement: AnnouncementEdit) {
|
||||
return http.put("admin/announcement", announcement)
|
||||
}
|
||||
|
||||
export function createAnnouncement(announcement: AnnouncementEdit) {
|
||||
return http.post("admin/announcement", announcement)
|
||||
}
|
||||
|
||||
export async function getTutorialList() {
|
||||
const res = await http.get<Tutorial[]>("admin/tutorial")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getTutorial(id: number) {
|
||||
const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createTutorial(data: Partial<Tutorial>) {
|
||||
const res = await http.post<Tutorial>("admin/tutorial", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateTutorial(data: Partial<Tutorial>) {
|
||||
const res = await http.put("admin/tutorial", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function deleteTutorial(id: number) {
|
||||
return http.delete("admin/tutorial", { params: { id } })
|
||||
}
|
||||
|
||||
export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||
return http.put("admin/tutorial/visibility", { id, is_public })
|
||||
}
|
||||
|
||||
export async function getAdminExercises(tutorialId: number) {
|
||||
const res = await http.get<Exercise[]>("admin/exercise", {
|
||||
params: { tutorial_id: tutorialId },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createExercise(data: {
|
||||
tutorial_id: number
|
||||
type: ExerciseType
|
||||
data: object
|
||||
order: number
|
||||
}) {
|
||||
const res = await http.post<Exercise>("admin/exercise", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateExercise(data: {
|
||||
id: number
|
||||
type: ExerciseType
|
||||
data: object
|
||||
order: number
|
||||
}) {
|
||||
const res = await http.put("admin/exercise", data)
|
||||
return res.data as Exercise
|
||||
}
|
||||
|
||||
export function deleteExercise(id: number) {
|
||||
return http.delete("admin/exercise", { params: { id } })
|
||||
}
|
||||
|
||||
// 将竞赛题目转为公开题目
|
||||
export function makeProblemPublic(id: number, display_id: string) {
|
||||
return http.post("admin/contest_problem/make_public", {
|
||||
id,
|
||||
display_id,
|
||||
})
|
||||
}
|
||||
|
||||
// 比赛辅助检查
|
||||
export function getACMHelperList(contest_id: number) {
|
||||
return http.get("admin/contest/acm_helper", {
|
||||
params: { contest_id },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateACMHelperChecked(
|
||||
contest_id: number,
|
||||
rank_id: number,
|
||||
problem_id: string,
|
||||
checked: boolean,
|
||||
) {
|
||||
return http.put("admin/contest/acm_helper", {
|
||||
contest_id,
|
||||
rank_id,
|
||||
problem_id,
|
||||
checked,
|
||||
})
|
||||
}
|
||||
|
||||
// 题单管理 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
|
||||
end_time?: Date | null
|
||||
}) {
|
||||
return http.post("admin/problemset", data)
|
||||
}
|
||||
|
||||
export function editProblemSet(data: {
|
||||
id: number
|
||||
title?: string
|
||||
description?: string
|
||||
difficulty?: string
|
||||
status?: string
|
||||
end_time?: Date | null
|
||||
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}`)
|
||||
}
|
||||
|
||||
// 学生卡点分析
|
||||
export function getStuckProblems() {
|
||||
return http.get("admin/problem/stuck")
|
||||
}
|
||||
|
||||
export function getTopACTrend(params: {
|
||||
since_year: number
|
||||
until_year: number
|
||||
min_per_year: number
|
||||
}) {
|
||||
return http.get("admin/problem/top_ac_trend", { params })
|
||||
}
|
||||
|
||||
// AI 学习分析报告
|
||||
export function getAIReportList(offset = 0, limit = 10, username = "") {
|
||||
return http.get("admin/ai/reports", {
|
||||
params: { paging: true, offset, limit, username: username || undefined },
|
||||
})
|
||||
}
|
||||
|
||||
export function getAIReportDetail(id: number) {
|
||||
return http.get("admin/ai/reports", { params: { id } })
|
||||
}
|
||||
|
||||
export function pinAIReport(id: number) {
|
||||
return http.post("admin/ai/reports", { id })
|
||||
}
|
||||
|
||||
export function getPinnedAIReports() {
|
||||
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
|
||||
}
|
||||
|
||||
// ==================== 成就 ====================
|
||||
|
||||
export interface AdminAchievement {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
rarity: string
|
||||
hidden: boolean
|
||||
metric: string
|
||||
metric_name: string
|
||||
operator: "gte" | "lte"
|
||||
threshold: number
|
||||
visible: boolean
|
||||
unlock_count: number
|
||||
order: number
|
||||
create_time: string
|
||||
}
|
||||
|
||||
export interface MetricOption {
|
||||
key: string
|
||||
name: string
|
||||
help_text: string
|
||||
}
|
||||
|
||||
export function getAdminAchievements() {
|
||||
return http.get<AdminAchievement[]>("admin/achievement")
|
||||
}
|
||||
|
||||
export function getMetricOptions() {
|
||||
return http.get<MetricOption[]>("admin/achievement/metrics")
|
||||
}
|
||||
|
||||
export function createAchievement(data: Partial<AdminAchievement>) {
|
||||
return http.post<AdminAchievement>("admin/achievement", data)
|
||||
}
|
||||
|
||||
export function updateAchievement(data: Partial<AdminAchievement>) {
|
||||
return http.put<AdminAchievement>("admin/achievement", data)
|
||||
}
|
||||
|
||||
export function deleteAchievement(id: number) {
|
||||
return http.delete("admin/achievement", { params: { id } })
|
||||
}
|
||||
2
apps/web/src/admin/communication/messages.vue
Normal file
@@ -0,0 +1,2 @@
|
||||
<template>未完待续</template>
|
||||
<script lang="ts" setup></script>
|
||||
68
apps/web/src/admin/contest/components/Actions.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Contest } from "utils/types"
|
||||
import { cloneContest } from "../../api"
|
||||
|
||||
interface Props {
|
||||
contest: Contest
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin contest edit",
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
function goEditProblems() {
|
||||
router.push({
|
||||
name: "admin contest problem list",
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
function goACMHelper() {
|
||||
router.push({
|
||||
name: "admin contest helper",
|
||||
params: { contestID: props.contest.id },
|
||||
})
|
||||
}
|
||||
|
||||
async function clone() {
|
||||
try {
|
||||
const res = await cloneContest(props.contest.id)
|
||||
message.success("复制成功")
|
||||
router.push({
|
||||
name: "admin contest edit",
|
||||
params: { contestID: res.data.id },
|
||||
})
|
||||
} catch {
|
||||
message.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
const isACM = computed(() => props.contest.rule_type === "ACM")
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
||||
题目
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="isACM"
|
||||
size="small"
|
||||
type="warning"
|
||||
secondary
|
||||
@click="goACMHelper"
|
||||
>
|
||||
审核
|
||||
</n-button>
|
||||
<n-button size="small" type="info" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="small" secondary @click="clone"> 复制 </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
196
apps/web/src/admin/contest/detail.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { formatISO } from "date-fns"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { BlankContest } from "utils/types"
|
||||
import { createContest, editContest, getContest } from "../api"
|
||||
|
||||
interface Props {
|
||||
contestID?: string
|
||||
}
|
||||
|
||||
function getTimes() {
|
||||
const timestamp = Date.now()
|
||||
const rounded = timestamp - (timestamp % 60000) // 确保秒数为0
|
||||
const t1 = rounded + waitMins.value * 60000
|
||||
const t2 = t1 + durationMins.value * 60000
|
||||
return [t1, t2]
|
||||
}
|
||||
|
||||
// 创建的时候
|
||||
const waitMins = ref(5) // 顺延5分钟
|
||||
const durationMins = ref(10) // 比赛默认时长10分钟
|
||||
|
||||
watch([waitMins, durationMins], () => {
|
||||
const times = getTimes()
|
||||
contest.start_time = formatISO(times[0])
|
||||
contest.end_time = formatISO(times[1])
|
||||
})
|
||||
|
||||
// 编辑的时候
|
||||
const startTime = ref(0)
|
||||
const endTime = ref(0)
|
||||
|
||||
watch([startTime, endTime], (values) => {
|
||||
contest.start_time = formatISO(values[0])
|
||||
contest.end_time = formatISO(values[1])
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const [ready, toggleReady] = useToggle()
|
||||
|
||||
const tags: SelectOption[] = [
|
||||
{ label: "练习", value: "练习" },
|
||||
{ label: "期中", value: "期中" },
|
||||
{ label: "期末", value: "期末" },
|
||||
]
|
||||
|
||||
const contest = reactive<BlankContest & { id: number }>({
|
||||
id: 0,
|
||||
title: "",
|
||||
description: "",
|
||||
tag: "练习",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
password: "",
|
||||
visible: false,
|
||||
allowed_ip_ranges: [],
|
||||
})
|
||||
|
||||
async function getContestDetail() {
|
||||
if (!props.contestID) {
|
||||
const times = getTimes()
|
||||
contest.start_time = formatISO(times[0])
|
||||
contest.end_time = formatISO(times[1])
|
||||
toggleReady(true)
|
||||
return
|
||||
}
|
||||
const { data } = await getContest(props.contestID)
|
||||
toggleReady(true)
|
||||
contest.id = data.id
|
||||
contest.title = data.title
|
||||
contest.description = data.description
|
||||
contest.tag = data.tag
|
||||
contest.start_time = data.start_time
|
||||
contest.end_time = data.end_time
|
||||
contest.password = data.password
|
||||
contest.visible = data.visible
|
||||
contest.allowed_ip_ranges = []
|
||||
|
||||
// 显示
|
||||
startTime.value = Date.parse(data.start_time)
|
||||
endTime.value = Date.parse(data.end_time)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (contest.description === "<p><br></p>") {
|
||||
contest.description = contest.title
|
||||
}
|
||||
const api = {
|
||||
"admin contest create": createContest,
|
||||
"admin contest edit": editContest,
|
||||
}[route.name as string]
|
||||
try {
|
||||
await api!(contest)
|
||||
if (route.name === "admin contest create") {
|
||||
message.success("成功新建比赛 💐")
|
||||
} else {
|
||||
message.success("修改已保存")
|
||||
}
|
||||
router.push({ name: "admin contest list" })
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(getContestDetail)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" align="center">
|
||||
<h2 class="title">
|
||||
{{ route.name === "admin contest create" ? "新建比赛" : "编辑比赛" }}
|
||||
</h2>
|
||||
<template v-if="!props.contestID">
|
||||
<n-alert type="success">
|
||||
<template #header>
|
||||
开始时间 {{ parseTime(contest.start_time, "YYYY年M月D日 HH:mm:ss") }}
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert type="warning">
|
||||
<template #header>
|
||||
结束时间 {{ parseTime(contest.end_time, "YYYY年M月D日 HH:mm:ss") }}
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
</n-flex>
|
||||
<n-form inline>
|
||||
<n-form-item label="标题">
|
||||
<n-input style="width: 300px" v-model:value="contest.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标签">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
:options="tags"
|
||||
v-model:value="contest.tag"
|
||||
/>
|
||||
</n-form-item>
|
||||
<template v-if="props.contestID">
|
||||
<n-form-item label="开始">
|
||||
<n-date-picker
|
||||
style="width: 200px"
|
||||
v-model:value="startTime"
|
||||
type="datetime"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="结束">
|
||||
<n-date-picker
|
||||
style="width: 200px"
|
||||
v-model:value="endTime"
|
||||
type="datetime"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-form-item label="几分钟后开始">
|
||||
<n-input-number style="width: 120px" v-model:value="waitMins" />
|
||||
</n-form-item>
|
||||
<n-form-item label="比赛时长">
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
step="5"
|
||||
v-model:value="durationMins"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
<n-form-item label="密码">
|
||||
<n-input style="width: 160px" v-model:value="contest.password" />
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="contest.visible" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<TextEditor
|
||||
v-if="ready"
|
||||
title="描述"
|
||||
v-model:value="contest.description"
|
||||
:min-height="200"
|
||||
/>
|
||||
<n-flex style="margin-bottom: 100px" justify="end">
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
326
apps/web/src/admin/contest/helper.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
||||
import { getSubmission, getSubmissions } from "oj/api"
|
||||
import SubmissionDetail from "oj/submission/detail.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
|
||||
interface Props {
|
||||
contestID: string
|
||||
}
|
||||
|
||||
interface HelperItem {
|
||||
id: number
|
||||
username: string
|
||||
real_name: string
|
||||
problem_id: string
|
||||
problem_display_id: string
|
||||
ac_info: {
|
||||
is_ac: boolean
|
||||
ac_time: number
|
||||
error_number: number
|
||||
checked?: boolean
|
||||
}
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const message = useMessage()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const submissions = ref<HelperItem[]>([])
|
||||
const contestStartTime = ref<Date | null>(null)
|
||||
const query = reactive({
|
||||
username: "",
|
||||
problemId: "",
|
||||
checked: "all",
|
||||
})
|
||||
|
||||
// 检查状态选项
|
||||
const checkedOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "已检查", value: "checked" },
|
||||
{ label: "未检查", value: "unchecked" },
|
||||
]
|
||||
|
||||
// 代码查看模态框
|
||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||
const currentSubmission = ref<any>(null)
|
||||
|
||||
// 格式化 AC 时间(ac_time 是相对于比赛开始的秒数)
|
||||
function formatACTime(relativeSeconds: number) {
|
||||
if (!contestStartTime.value) return "-"
|
||||
const acTime = new Date(
|
||||
contestStartTime.value.getTime() + relativeSeconds * 1000,
|
||||
)
|
||||
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
|
||||
}
|
||||
|
||||
// 切换检查状态
|
||||
async function toggleChecked(item: HelperItem) {
|
||||
const newChecked = !item.checked
|
||||
try {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problem_id,
|
||||
newChecked,
|
||||
)
|
||||
// 更新本地状态
|
||||
item.checked = newChecked
|
||||
item.ac_info.checked = newChecked
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 批量标记为已检查
|
||||
async function markAllAsChecked() {
|
||||
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
|
||||
if (unchecked.length === 0) {
|
||||
message.info("没有需要标记的提交")
|
||||
return
|
||||
}
|
||||
|
||||
const loadingMsg = message.loading("正在标记...", { duration: 0 })
|
||||
try {
|
||||
for (const item of unchecked) {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problem_id,
|
||||
true,
|
||||
)
|
||||
item.checked = true
|
||||
item.ac_info.checked = true
|
||||
}
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
loadingMsg.destroy()
|
||||
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
||||
} catch (err: any) {
|
||||
loadingMsg.destroy()
|
||||
message.error(err.data || "批量操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤后的提交列表
|
||||
const filteredSubmissions = computed(() => {
|
||||
return submissions.value.filter((item) => {
|
||||
if (query.username && !item.username.includes(query.username)) return false
|
||||
if (query.problemId && !item.problem_display_id.includes(query.problemId))
|
||||
return false
|
||||
if (query.checked === "checked" && !item.checked) return false
|
||||
if (query.checked === "unchecked" && item.checked) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 统计信息
|
||||
const stats = computed(() => {
|
||||
const total = submissions.value.length
|
||||
const checked = submissions.value.filter((item) => item.checked).length
|
||||
const unchecked = total - checked
|
||||
return { total, checked, unchecked }
|
||||
})
|
||||
|
||||
// 查看代码 - 获取该用户在该题目的 AC 提交
|
||||
async function viewSubmission(item: HelperItem) {
|
||||
try {
|
||||
// 查询该用户在该竞赛该题目的 AC 提交
|
||||
const res = await getSubmissions({
|
||||
username: item.username,
|
||||
problem_id: item.problem_display_id,
|
||||
contest_id: props.contestID,
|
||||
result: "0", // ACCEPTED
|
||||
language: "",
|
||||
page: 1,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (res.data.results.length === 0) {
|
||||
message.warning("未找到该用户的 AC 提交")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取提交详情
|
||||
const submissionListItem = res.data.results[0]
|
||||
const detailRes = await getSubmission(submissionListItem.id)
|
||||
|
||||
// 手动添加 contest 字段(ACM模式下后端不返回此字段)
|
||||
currentSubmission.value = {
|
||||
...detailRes.data,
|
||||
contest: Number(props.contestID),
|
||||
problem_display_id: item.problem_display_id,
|
||||
}
|
||||
|
||||
toggleCodePanel(true)
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "加载提交失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
try {
|
||||
// 先获取比赛信息,获取开始时间
|
||||
const contestRes = await getContest(props.contestID)
|
||||
contestStartTime.value = new Date(contestRes.data.start_time)
|
||||
|
||||
// 再获取 AC 提交列表
|
||||
const { data } = await getACMHelperList(Number(props.contestID))
|
||||
submissions.value = data
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "加载失败")
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<HelperItem>[] = [
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "题目",
|
||||
key: "problem_display_id",
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { type: "info" }, () => row.problem_display_id),
|
||||
},
|
||||
{
|
||||
title: "AC时间",
|
||||
key: "ac_time",
|
||||
width: 180,
|
||||
render: (row) => formatACTime(row.ac_info.ac_time),
|
||||
},
|
||||
{
|
||||
title: "错误次数",
|
||||
key: "error_number",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: row.ac_info.error_number > 0 ? "warning" : "success",
|
||||
size: "small",
|
||||
},
|
||||
() => row.ac_info.error_number,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "已检查",
|
||||
key: "checked",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NCheckbox, {
|
||||
checked: row.checked,
|
||||
onUpdateChecked: () => toggleChecked(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "primary",
|
||||
secondary: true,
|
||||
onClick: () => viewSubmission(row),
|
||||
},
|
||||
() => "查看代码",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-flex align="center">
|
||||
<h2 style="margin: 0">比赛辅助检查</h2>
|
||||
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag>
|
||||
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag>
|
||||
<n-tag type="warning" size="large">
|
||||
未检查: {{ stats.unchecked }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="stats.unchecked === 0"
|
||||
@click="markAllAsChecked"
|
||||
>
|
||||
标记全部为已检查
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-alert type="info" style="margin-bottom: 16px">
|
||||
<template #header>使用说明</template>
|
||||
此工具用于赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选"已检查"。
|
||||
</n-alert>
|
||||
|
||||
<n-flex align="center" style="margin-bottom: 16px">
|
||||
<n-input
|
||||
v-model:value="query.username"
|
||||
placeholder="筛选用户名"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="query.problemId"
|
||||
placeholder="筛选题目"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.checked"
|
||||
:options="checkedOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="filteredSubmissions"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:bordered="false"
|
||||
/>
|
||||
|
||||
<n-modal
|
||||
v-model:show="codePanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="代码详情"
|
||||
>
|
||||
<SubmissionDetail
|
||||
v-if="currentSubmission"
|
||||
:submission="currentSubmission"
|
||||
:problemID="currentSubmission.problem_display_id"
|
||||
:submissionID="currentSubmission.id"
|
||||
hideList
|
||||
@copied="toggleCodePanel(false)"
|
||||
/>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-data-table) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
132
apps/web/src/admin/contest/list.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch, NTag } from "naive-ui"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import ContestType from "shared/components/ContestType.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Contest } from "utils/types"
|
||||
import { editContest, getContestList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
const contests = ref<Contest[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
keyword: "",
|
||||
})
|
||||
|
||||
function toggleVisible(contest: Contest) {
|
||||
contest.visible = !contest.visible
|
||||
editContest(contest)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<Contest>[] = [
|
||||
{ title: "ID", key: "id", width: 60 },
|
||||
{
|
||||
title: "比赛",
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
render: (row) => h(ContestTitle, { contest: row }),
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
key: "tag",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
key: "contest_type",
|
||||
width: 100,
|
||||
render: (row) => h(ContestType, { contest: row, size: "small" }),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ type: CONTEST_STATUS[row.status]["type"], size: "small" },
|
||||
() => CONTEST_STATUS[row.status]["name"],
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建者",
|
||||
key: "created_by",
|
||||
width: 120,
|
||||
render: (row) => row.created_by.username,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 160,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 300,
|
||||
render: (row) => h(Actions, { contest: row }),
|
||||
},
|
||||
]
|
||||
|
||||
async function listContests() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getContestList(offset, query.limit, query.keyword)
|
||||
contests.value = res.data.results
|
||||
total.value = res.data.total
|
||||
}
|
||||
onMounted(listContests)
|
||||
watch(() => [query.page, query.limit], listContests)
|
||||
watchDebounced(() => query.keyword, listContests, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex justify="space-between" class="titleWrapper">
|
||||
<n-flex align="center">
|
||||
<h2 class="title">比赛列表</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin contest create' })"
|
||||
>
|
||||
新建
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<div>
|
||||
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" />
|
||||
</div>
|
||||
</n-flex>
|
||||
<n-data-table :columns="columns" :data="contests" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
56
apps/web/src/admin/problem/Stuck.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { getStuckProblems } from "admin/api"
|
||||
|
||||
interface StuckProblem {
|
||||
problem_id: string
|
||||
problem_title: string
|
||||
total: number
|
||||
failed: number
|
||||
failed_users: number
|
||||
ac_rate: number
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<StuckProblem[]>([])
|
||||
|
||||
const columns: DataTableColumn<StuckProblem>[] = [
|
||||
{ title: "题目 ID", key: "problem_id", width: 100 },
|
||||
{ title: "题目名称", key: "problem_title", minWidth: 200 },
|
||||
{ title: "总提交", key: "total", width: 100, sorter: "default" },
|
||||
{ title: "失败次数", key: "failed", width: 100, sorter: "default" },
|
||||
{
|
||||
title: "卡住学生数",
|
||||
key: "failed_users",
|
||||
width: 120,
|
||||
sorter: "default",
|
||||
defaultSortOrder: "descend",
|
||||
},
|
||||
{
|
||||
title: "AC 率",
|
||||
key: "ac_rate",
|
||||
width: 100,
|
||||
sorter: "default",
|
||||
render: (row) => `${row.ac_rate}%`,
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getStuckProblems()
|
||||
data.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 style="margin-top: 0">学生卡点分析(只分析前40道题目)</h2>
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
striped
|
||||
:pagination="{ pageSize: 20 }"
|
||||
/>
|
||||
</template>
|
||||
202
apps/web/src/admin/problem/TopACTrend.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { Line } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { getTopACTrend } from "admin/api"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
)
|
||||
|
||||
interface YearlyEntry {
|
||||
year: number
|
||||
total: number
|
||||
accepted: number
|
||||
ac_rate: number
|
||||
}
|
||||
|
||||
interface ProblemTrend {
|
||||
problem_id: string
|
||||
problem_title: string
|
||||
yearly: YearlyEntry[]
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
|
||||
label: String(2022 + i),
|
||||
value: 2022 + i,
|
||||
}))
|
||||
const minPerYearOptions = [
|
||||
{ label: "50", value: 50 },
|
||||
{ label: "100", value: 100 },
|
||||
{ label: "200", value: 200 },
|
||||
]
|
||||
|
||||
const sinceYear = ref(2023)
|
||||
const untilYear = ref(new Date().getFullYear() - 1)
|
||||
const minPerYear = ref(100)
|
||||
const loading = ref(false)
|
||||
const data = ref<ProblemTrend[]>([])
|
||||
|
||||
const acLabelPlugin = {
|
||||
id: "acLabel",
|
||||
afterDatasetsDraw(chart: any) {
|
||||
const ctx = chart.ctx
|
||||
chart.data.datasets.forEach((_: any, i: number) => {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
meta.data.forEach((point: any, j: number) => {
|
||||
const value = chart.data.datasets[i].data[j]
|
||||
if (value === null || value === undefined) return
|
||||
ctx.save()
|
||||
ctx.font = "bold 11px sans-serif"
|
||||
ctx.fillStyle = "rgba(99, 179, 237, 1)"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "bottom"
|
||||
ctx.fillText(`${value}%`, point.x, point.y - 6)
|
||||
ctx.restore()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function getChartData(problem: ProblemTrend) {
|
||||
return {
|
||||
labels: problem.yearly.map((y) => String(y.year)),
|
||||
datasets: [
|
||||
{
|
||||
label: "AC 率",
|
||||
data: problem.yearly.map((y) => y.ac_rate),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
backgroundColor: "rgba(99, 179, 237, 0.2)",
|
||||
borderColor: "rgba(99, 179, 237, 1)",
|
||||
pointBackgroundColor: "rgba(99, 179, 237, 1)",
|
||||
pointRadius: 4,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function getChartOptions(problem: ProblemTrend) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: `${problem.problem_id} · ${problem.problem_title}`,
|
||||
font: { size: 14 },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: any) => {
|
||||
const entry = problem.yearly[ctx.dataIndex]
|
||||
return `AC 率: ${entry.ac_rate}% (${entry.accepted}/${entry.total})`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { callback: (v: any) => `${v}%` },
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: "年份" },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTopACTrend({
|
||||
since_year: sinceYear.value,
|
||||
until_year: untilYear.value,
|
||||
min_per_year: minPerYear.value,
|
||||
})
|
||||
data.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 style="margin-top: 0">年度趋势</h2>
|
||||
<n-space align="center" style="margin-bottom: 16px">
|
||||
<span>年份范围</span>
|
||||
<n-select
|
||||
v-model:value="sinceYear"
|
||||
:options="yearOptions"
|
||||
style="width: 100px"
|
||||
@update:value="fetchData"
|
||||
/>
|
||||
<span>—</span>
|
||||
<n-select
|
||||
v-model:value="untilYear"
|
||||
:options="yearOptions"
|
||||
style="width: 100px"
|
||||
@update:value="fetchData"
|
||||
/>
|
||||
<span>年提交下限</span>
|
||||
<n-select
|
||||
v-model:value="minPerYear"
|
||||
:options="minPerYearOptions"
|
||||
style="width: 90px"
|
||||
@update:value="fetchData"
|
||||
/>
|
||||
<n-tag type="info" size="small">共 {{ data.length }} 题</n-tag>
|
||||
</n-space>
|
||||
<n-spin :show="loading">
|
||||
<div
|
||||
v-if="!loading && data.length === 0"
|
||||
style="text-align: center; padding: 40px"
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
<div v-else class="grid">
|
||||
<div v-for="problem in data" :key="problem.problem_id" class="chart-card">
|
||||
<Line
|
||||
:data="getChartData(problem)"
|
||||
:options="getChartOptions(problem)"
|
||||
:plugins="[acLabelPlugin]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
height: 260px;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
160
apps/web/src/admin/problem/components/Actions.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
deleteContestProblem,
|
||||
deleteProblem,
|
||||
makeProblemPublic,
|
||||
} from "admin/api"
|
||||
import download from "utils/download"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
problemDisplayID: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["updated"])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const isContestProblem = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
|
||||
const showMakePublicModal = ref(false)
|
||||
const newDisplayID = ref("")
|
||||
|
||||
async function handleDeleteProblem() {
|
||||
try {
|
||||
if (route.name === "admin contest problem list") {
|
||||
await deleteContestProblem(props.problemID)
|
||||
} else {
|
||||
await deleteProblem(props.problemID)
|
||||
}
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Can't delete the problem as it has submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
} else {
|
||||
message.error("删除失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloads() {
|
||||
download("test_case?problem_id=" + props.problemID)
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
const name = route.name!.toString().replace("list", "edit")
|
||||
router.push({ name, params: { problemID: props.problemID } })
|
||||
}
|
||||
|
||||
function goCheck() {
|
||||
let data = router.resolve("/problem/" + props.problemDisplayID)
|
||||
if (route.name === "admin contest problem list") {
|
||||
data = router.resolve({
|
||||
name: "contest problem",
|
||||
params: {
|
||||
contestID: route.params.contestID,
|
||||
problemID: props.problemDisplayID,
|
||||
},
|
||||
})
|
||||
}
|
||||
window.open(data.href, "_blank")
|
||||
}
|
||||
|
||||
function openMakePublicModal() {
|
||||
newDisplayID.value = ""
|
||||
showMakePublicModal.value = true
|
||||
}
|
||||
|
||||
async function handleMakePublic() {
|
||||
if (!newDisplayID.value.trim()) {
|
||||
message.error("请输入新的题目编号")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
|
||||
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display ID") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (err.data === "Already be a public problem") {
|
||||
message.error("该题目已经是公开题目")
|
||||
} else {
|
||||
message.error("转换失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" secondary type="primary" @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="small" secondary type="info" @click="goCheck">
|
||||
查看
|
||||
</n-button>
|
||||
<n-tooltip v-if="isContestProblem">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
type="warning"
|
||||
@click="openMakePublicModal"
|
||||
>
|
||||
公开
|
||||
</n-button>
|
||||
</template>
|
||||
将此竞赛题目转为公开题目
|
||||
</n-tooltip>
|
||||
<n-popconfirm @positive-click="handleDeleteProblem">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="error">删除</n-button>
|
||||
</template>
|
||||
确定删除这道题目吗?相关的提交也会被相应删除哦 😯
|
||||
</n-popconfirm>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button size="small" secondary @click="downloads">下载</n-button>
|
||||
</template>
|
||||
下载测试用例
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showMakePublicModal"
|
||||
preset="card"
|
||||
title="转为公开题目"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>
|
||||
将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。
|
||||
</p>
|
||||
<n-form>
|
||||
<n-form-item label="新的题目编号" required>
|
||||
<n-input
|
||||
v-model:value="newDisplayID"
|
||||
placeholder="例如: 1001"
|
||||
clearable
|
||||
@keyup.enter="handleMakePublic"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
|
||||
</n-alert>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showMakePublicModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleMakePublic">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
47
apps/web/src/admin/problem/components/AddButton.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { addProblemForContest } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
contestID: string
|
||||
nextDisplayId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["added"])
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const displayID = ref(props.nextDisplayId || "")
|
||||
|
||||
async function addProblem() {
|
||||
if (!displayID.value) return
|
||||
try {
|
||||
await addProblemForContest(
|
||||
props.contestID,
|
||||
props.problemID,
|
||||
displayID.value,
|
||||
)
|
||||
emit("added")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display id in this contest") {
|
||||
message.error("显示编号重复了,请重新写一个")
|
||||
} else if (err.data === "Contest has ended") {
|
||||
message.error("这场比赛已经结束了,不能添加题目")
|
||||
} else {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-popconfirm :show-icon="false" @positive-click="addProblem">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="primary">添加</n-button>
|
||||
</template>
|
||||
<n-flex vertical>
|
||||
<span>请输入在这场比赛中的显示编号</span>
|
||||
<n-input autofocus v-model:value="displayID" />
|
||||
</n-flex>
|
||||
</n-popconfirm>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
397
apps/web/src/admin/problem/components/AstRulesEditor.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
|
||||
interface AstRule {
|
||||
engine: string
|
||||
target?: string
|
||||
label?: string
|
||||
exact?: number
|
||||
min?: number
|
||||
max?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: { [key: string]: AstRule[] } | null
|
||||
languages: LANGUAGE[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void
|
||||
}>()
|
||||
|
||||
const activeTab = ref(props.languages[0] || "Python3")
|
||||
|
||||
const ENGINE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "节点检查",
|
||||
type: "group",
|
||||
key: "node_group",
|
||||
children: [
|
||||
{ label: "必须存在", value: "must_exist_node" },
|
||||
{ label: "不能存在", value: "must_not_exist_node" },
|
||||
{ label: "出现次数", value: "count_node" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "函数调用",
|
||||
type: "group",
|
||||
key: "func_group",
|
||||
children: [
|
||||
{ label: "必须调用函数", value: "must_call_function" },
|
||||
{ label: "不能调用函数", value: "must_not_call_function" },
|
||||
{ label: "函数调用次数", value: "count_function_call" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "方法调用",
|
||||
type: "group",
|
||||
key: "method_group",
|
||||
children: [
|
||||
{ label: "必须调用方法", value: "must_call_method" },
|
||||
{ label: "不能调用方法", value: "must_not_call_method" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "运算符",
|
||||
type: "group",
|
||||
key: "op_group",
|
||||
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
|
||||
},
|
||||
]
|
||||
|
||||
const NODE_TARGET_OPTIONS: SelectOption[] = [
|
||||
{ label: "for 循环", value: "for_loop" },
|
||||
{ label: "while 循环", value: "while_loop" },
|
||||
{ label: "if 条件", value: "if_statement" },
|
||||
{ label: "else 子句", value: "else_clause" },
|
||||
{ label: "函数定义", value: "function_definition" },
|
||||
{ label: "return 语句", value: "return" },
|
||||
{ label: "break 语句", value: "break" },
|
||||
{ label: "continue 语句", value: "continue" },
|
||||
{ label: "列表推导式", value: "list_comprehension" },
|
||||
{ label: "列表", value: "list_literal" },
|
||||
{ label: "字典", value: "dict_literal" },
|
||||
{ label: "集合", value: "set_literal" },
|
||||
{ label: "f-string", value: "f_string" },
|
||||
{ label: "try-except", value: "try_except" },
|
||||
{ label: "类定义", value: "class_definition" },
|
||||
]
|
||||
|
||||
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
|
||||
{ label: "+", value: "+" },
|
||||
{ label: "-", value: "-" },
|
||||
{ label: "*", value: "*" },
|
||||
{ label: "/", value: "/" },
|
||||
{ label: "//", value: "//" },
|
||||
{ label: "%", value: "%" },
|
||||
{ label: "**", value: "**" },
|
||||
{ label: "+=", value: "+=" },
|
||||
{ label: "-=", value: "-=" },
|
||||
{ label: "==", value: "==" },
|
||||
{ label: "!=", value: "!=" },
|
||||
{ label: ">", value: ">" },
|
||||
{ label: ">=", value: ">=" },
|
||||
{ label: "<", value: "<" },
|
||||
{ label: "<=", value: "<=" },
|
||||
{ label: "and / &&", value: "and" },
|
||||
{ label: "or / ||", value: "or" },
|
||||
{ label: "not / !", value: "not" },
|
||||
]
|
||||
|
||||
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
|
||||
const FUNCTION_ENGINES = [
|
||||
"must_call_function",
|
||||
"must_not_call_function",
|
||||
"count_function_call",
|
||||
]
|
||||
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
|
||||
const OPERATOR_ENGINES = ["must_use_operator"]
|
||||
const COUNT_ENGINES = ["count_node", "count_function_call"]
|
||||
|
||||
function isNodeEngine(engine: string) {
|
||||
return NODE_ENGINES.includes(engine)
|
||||
}
|
||||
function isFunctionEngine(engine: string) {
|
||||
return FUNCTION_ENGINES.includes(engine)
|
||||
}
|
||||
function isMethodEngine(engine: string) {
|
||||
return METHOD_ENGINES.includes(engine)
|
||||
}
|
||||
function isOperatorEngine(engine: string) {
|
||||
return OPERATOR_ENGINES.includes(engine)
|
||||
}
|
||||
function isCountEngine(engine: string) {
|
||||
return COUNT_ENGINES.includes(engine)
|
||||
}
|
||||
|
||||
const COUNT_MODE_OPTIONS: SelectOption[] = [
|
||||
{ label: "精确", value: "exact" },
|
||||
{ label: "范围", value: "range" },
|
||||
]
|
||||
|
||||
function getCountMode(rule: AstRule): "exact" | "range" {
|
||||
return rule.exact !== undefined ? "exact" : "range"
|
||||
}
|
||||
|
||||
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
if (mode === "exact") {
|
||||
rule.exact = rule.min ?? 1
|
||||
delete rule.min
|
||||
delete rule.max
|
||||
} else {
|
||||
delete rule.exact
|
||||
}
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function updateExactCount(lang: string, index: number, v: number | null) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
if (v === null) delete rule.exact
|
||||
else rule.exact = v
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function needsTargetDropdown(engine: string) {
|
||||
return isNodeEngine(engine)
|
||||
}
|
||||
function needsTargetInput(engine: string) {
|
||||
return isFunctionEngine(engine) || isMethodEngine(engine)
|
||||
}
|
||||
function needsOperatorDropdown(engine: string) {
|
||||
return isOperatorEngine(engine)
|
||||
}
|
||||
|
||||
function getRulesForLang(lang: string): AstRule[] {
|
||||
if (!props.modelValue) return []
|
||||
return props.modelValue[lang] || []
|
||||
}
|
||||
|
||||
function updateRules(lang: string, rules: AstRule[]) {
|
||||
const current = { ...(props.modelValue || {}) }
|
||||
if (rules.length === 0) {
|
||||
delete current[lang]
|
||||
} else {
|
||||
current[lang] = rules
|
||||
}
|
||||
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
|
||||
}
|
||||
|
||||
function getTargetLabel(engine: string, target: string): string | undefined {
|
||||
if (isNodeEngine(engine))
|
||||
return (NODE_TARGET_OPTIONS.find((o) => o.value === target) as any)?.label
|
||||
if (isOperatorEngine(engine))
|
||||
return (OPERATOR_TARGET_OPTIONS.find((o) => o.value === target) as any)
|
||||
?.label
|
||||
return undefined
|
||||
}
|
||||
|
||||
function addRule(lang: string) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
rules.push({
|
||||
engine: "must_exist_node",
|
||||
target: "for_loop",
|
||||
label: "for 循环",
|
||||
message: "",
|
||||
})
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function removeRule(lang: string, index: number) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
rules.splice(index, 1)
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function updateRule(lang: string, index: number, field: string, value: any) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
|
||||
if (field === "engine") {
|
||||
rule.engine = value
|
||||
if (isNodeEngine(value)) {
|
||||
rule.target = "for_loop"
|
||||
rule.label = "for 循环"
|
||||
} else if (isOperatorEngine(value)) {
|
||||
rule.target = "+"
|
||||
rule.label = "+"
|
||||
} else {
|
||||
rule.target = ""
|
||||
delete rule.label
|
||||
}
|
||||
delete rule.min
|
||||
delete rule.max
|
||||
delete rule.exact
|
||||
} else if (field === "target") {
|
||||
rule.target = value
|
||||
const lbl = getTargetLabel(rule.engine, value)
|
||||
if (lbl) rule.label = lbl
|
||||
else delete rule.label
|
||||
} else if (field === "min") {
|
||||
if (value === null || value === undefined) delete rule.min
|
||||
else rule.min = value
|
||||
} else if (field === "max") {
|
||||
if (value === null || value === undefined) delete rule.max
|
||||
else rule.max = value
|
||||
} else if (field === "message") {
|
||||
rule.message = value
|
||||
}
|
||||
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.languages,
|
||||
(langs) => {
|
||||
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
|
||||
activeTab.value = langs[0]
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-collapse>
|
||||
<n-collapse-item title="代码规则检查(选填)" name="ast-rules">
|
||||
<n-tabs v-if="languages.length" type="segment" v-model:value="activeTab">
|
||||
<n-tab-pane
|
||||
v-for="lang in languages"
|
||||
:key="lang"
|
||||
:name="lang"
|
||||
:tab="lang"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<div
|
||||
v-for="(rule, index) in getRulesForLang(lang)"
|
||||
:key="index"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<n-flex align="center" :wrap="false">
|
||||
<n-select
|
||||
:options="ENGINE_OPTIONS"
|
||||
:value="rule.engine"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'engine', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<n-select
|
||||
v-if="needsTargetDropdown(rule.engine)"
|
||||
:options="NODE_TARGET_OPTIONS"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
filterable
|
||||
/>
|
||||
<n-input
|
||||
v-if="needsTargetInput(rule.engine)"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
placeholder="函数/方法名"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<n-select
|
||||
v-if="needsOperatorDropdown(rule.engine)"
|
||||
:options="OPERATOR_TARGET_OPTIONS"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<template v-if="isCountEngine(rule.engine)">
|
||||
<n-select
|
||||
:options="COUNT_MODE_OPTIONS"
|
||||
:value="getCountMode(rule)"
|
||||
@update:value="
|
||||
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
|
||||
"
|
||||
style="width: 80px"
|
||||
size="small"
|
||||
/>
|
||||
<n-input-number
|
||||
v-if="getCountMode(rule) === 'exact'"
|
||||
:value="rule.exact ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateExactCount(lang, index, v)
|
||||
"
|
||||
placeholder="次数"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="1"
|
||||
clearable
|
||||
/>
|
||||
<template v-else>
|
||||
<n-input-number
|
||||
:value="rule.min ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateRule(lang, index, 'min', v)
|
||||
"
|
||||
placeholder="最少"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="0"
|
||||
clearable
|
||||
/>
|
||||
<n-input-number
|
||||
:value="rule.max ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateRule(lang, index, 'max', v)
|
||||
"
|
||||
placeholder="最多"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="0"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<n-input
|
||||
:value="rule.message"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'message', v)
|
||||
"
|
||||
placeholder="错误提示(选填)"
|
||||
style="flex: 1"
|
||||
size="small"
|
||||
/>
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
type="error"
|
||||
@click="removeRule(lang, index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
type="primary"
|
||||
@click="addRule(lang)"
|
||||
>
|
||||
添加规则
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-empty v-else description="请先选择编程语言" />
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</template>
|
||||
109
apps/web/src/admin/problem/components/BatchTagModal.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { batchTagProblems, getTagAdminList } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
problemIds: number[]
|
||||
action: "add" | "remove"
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
done: []
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const selected = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
|
||||
const title = computed(() =>
|
||||
props.action === "add" ? "批量添加标签" : "批量移除标签",
|
||||
)
|
||||
|
||||
const selectedSet = computed(() => new Set(selected.value))
|
||||
|
||||
const names = computed(() =>
|
||||
props.action === "add"
|
||||
? Array.from(new Set([...selected.value, ...newTags.value]))
|
||||
: selected.value,
|
||||
)
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const set = new Set(selected.value)
|
||||
if (set.has(name)) set.delete(name)
|
||||
else set.add(name)
|
||||
selected.value = Array.from(set)
|
||||
}
|
||||
|
||||
async function listTags() {
|
||||
const res = await getTagAdminList()
|
||||
tags.value = res.data
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!names.value.length) {
|
||||
message.error("请先选择标签")
|
||||
return
|
||||
}
|
||||
const res = await batchTagProblems(
|
||||
props.problemIds,
|
||||
names.value,
|
||||
props.action,
|
||||
)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
message.success(
|
||||
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`,
|
||||
)
|
||||
close()
|
||||
emit("done")
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
selected.value = []
|
||||
newTags.value = []
|
||||
listTags()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="title"
|
||||
style="width: 600px"
|
||||
:mask-closable="false"
|
||||
@close="close"
|
||||
>
|
||||
<n-flex vertical size="large">
|
||||
<div>已选中 {{ problemIds.length }} 道题目</div>
|
||||
<n-flex size="small">
|
||||
<n-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
checkable
|
||||
:checked="selectedSet.has(tag.name)"
|
||||
@update:checked="toggleTag(tag.name)"
|
||||
>
|
||||
{{ tag.name }}({{ tag.problem_count }})
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />
|
||||
<n-flex justify="end">
|
||||
<n-button @click="close">取消</n-button>
|
||||
<n-button type="primary" @click="submit">确定</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
94
apps/web/src/admin/problem/components/Modal.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import { getProblemList } from "admin/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import AddButton from "./AddButton.vue"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
count: number
|
||||
nextDisplayId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:show", value: boolean): void
|
||||
(e: "change"): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: "",
|
||||
})
|
||||
const total = ref(0)
|
||||
const problems = shallowRef<AdminProblemFiltered[]>([])
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "编号", key: "_id", width: 80 },
|
||||
{ title: "标题", key: "title" },
|
||||
{
|
||||
title: "选项",
|
||||
key: "add",
|
||||
render: (row) =>
|
||||
h(AddButton, {
|
||||
problemID: row.id,
|
||||
contestID: route.params.contestID as string,
|
||||
nextDisplayId: props.nextDisplayId,
|
||||
onAdded: () => emit("change"),
|
||||
}),
|
||||
width: 60,
|
||||
},
|
||||
]
|
||||
|
||||
async function getList() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
|
||||
total.value = res.total
|
||||
problems.value = res.results
|
||||
}
|
||||
watch(
|
||||
() => props.show,
|
||||
(value) => {
|
||||
if (value) getList()
|
||||
},
|
||||
)
|
||||
watch(() => [query.limit, query.page], getList)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
() => {
|
||||
query.page = 1
|
||||
getList()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<n-modal
|
||||
:mask-closable="false"
|
||||
:show="props.show"
|
||||
preset="card"
|
||||
style="width: 600px"
|
||||
title="从题库中添加"
|
||||
@close="$emit('update:show', false)"
|
||||
>
|
||||
<n-input
|
||||
class="search"
|
||||
v-model:value="query.keyword"
|
||||
clearable
|
||||
placeholder="搜索标题或编号"
|
||||
/>
|
||||
<n-data-table striped :columns="columns" :data="problems" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</n-modal>
|
||||
</template>
|
||||
<style scoped>
|
||||
.search {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
345
apps/web/src/admin/problem/components/SQLTestcaseEditor.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
|
||||
import { createZipBlob } from "utils/functions"
|
||||
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
|
||||
import {
|
||||
generateSQLTestcase,
|
||||
getSQLTestcaseScripts,
|
||||
previewSQLTestcase,
|
||||
uploadTestcases,
|
||||
} from "../../api"
|
||||
|
||||
interface ScriptEntry {
|
||||
id: number
|
||||
sql: string
|
||||
display: SQLDisplay | null
|
||||
error: string
|
||||
// 标准答案或题型改过之后,旧预览结果作废,需重新预览才能上传
|
||||
stale: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
answers: { language: LANGUAGE; code: string }[]
|
||||
mode: "query" | "modify"
|
||||
problemId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
let nextId = 0
|
||||
function blankEntry(): ScriptEntry {
|
||||
return { id: nextId++, sql: "", display: null, error: "", stale: false }
|
||||
}
|
||||
|
||||
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
|
||||
|
||||
const refSQL = computed(
|
||||
() =>
|
||||
props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ??
|
||||
"",
|
||||
)
|
||||
|
||||
const isPreviewing = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
|
||||
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
|
||||
|
||||
const filledCount = computed(
|
||||
() => scripts.value.filter((s) => s.sql.trim()).length,
|
||||
)
|
||||
|
||||
const canUpload = computed(() => {
|
||||
const filled = scripts.value.filter((s) => s.sql.trim())
|
||||
return (
|
||||
!isPreviewing.value &&
|
||||
// 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
|
||||
filled.length >= 2 &&
|
||||
filled.every((s) => s.display && !s.error && !s.stale)
|
||||
)
|
||||
})
|
||||
|
||||
watch([refSQL, () => props.mode], () => {
|
||||
for (const s of scripts.value) {
|
||||
if (s.display || s.error) s.stale = true
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑已有 SQL 题时回显已上传的脚本;新题或旧格式测试点则保持空白
|
||||
onMounted(async () => {
|
||||
if (!props.problemId) return
|
||||
try {
|
||||
const res = await getSQLTestcaseScripts(props.problemId)
|
||||
if (res.data.length) {
|
||||
scripts.value = res.data.map((f) => ({ ...blankEntry(), sql: f.content }))
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
function add() {
|
||||
scripts.value.push(blankEntry())
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
scripts.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
scripts.value = [blankEntry(), blankEntry(), blankEntry()]
|
||||
}
|
||||
|
||||
function expectedQuery(d: SQLDisplay) {
|
||||
return "columns" in d.expected ? d.expected : null
|
||||
}
|
||||
|
||||
function changedTables(d: SQLDisplay) {
|
||||
return "changed_tables" in d.expected ? d.expected.changed_tables : []
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const blanks = scripts.value.filter((s) => !s.sql.trim())
|
||||
if (!blanks.length) return
|
||||
isGenerating.value = true
|
||||
await Promise.all(
|
||||
blanks.map(async (s) => {
|
||||
try {
|
||||
const res = await generateSQLTestcase({
|
||||
ref_sql: refSQL.value,
|
||||
mode: props.mode,
|
||||
})
|
||||
s.sql = res.data.sql
|
||||
} catch (err) {
|
||||
const data = (err as { data?: unknown })?.data
|
||||
message.error(typeof data === "string" ? data : "AI 生成失败")
|
||||
}
|
||||
}),
|
||||
)
|
||||
isGenerating.value = false
|
||||
await preview()
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
// 丢弃空脚本
|
||||
scripts.value = scripts.value.filter((s) => s.sql.trim())
|
||||
if (!scripts.value.length) {
|
||||
scripts.value = [blankEntry()]
|
||||
return
|
||||
}
|
||||
isPreviewing.value = true
|
||||
await Promise.all(
|
||||
scripts.value.map(async (s) => {
|
||||
s.display = null
|
||||
s.error = ""
|
||||
s.stale = false
|
||||
try {
|
||||
const res = await previewSQLTestcase({
|
||||
init_sql: s.sql,
|
||||
ref_sql: refSQL.value,
|
||||
mode: props.mode,
|
||||
})
|
||||
s.display = res.data
|
||||
} catch (err) {
|
||||
const data = (err as { data?: unknown })?.data
|
||||
s.error = typeof data === "string" ? data : "预览失败"
|
||||
}
|
||||
}),
|
||||
)
|
||||
isPreviewing.value = false
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const data = scripts.value
|
||||
.filter((s) => s.sql.trim())
|
||||
.map((s, i) => ({
|
||||
name: `${i + 1}.sql`,
|
||||
content: s.sql,
|
||||
}))
|
||||
const blob = createZipBlob(data)
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
const res = await uploadTestcases(file, { sql: true })
|
||||
const testcases: Testcase[] = res.data.info
|
||||
const baseScore = Math.floor(100 / testcases.length)
|
||||
const remainder = 100 - baseScore * testcases.length
|
||||
testcases.forEach((tc, i) => {
|
||||
tc.score = String(
|
||||
i === testcases.length - 1 ? baseScore + remainder : baseScore,
|
||||
)
|
||||
})
|
||||
|
||||
emit("uploaded", res.data.id, testcases)
|
||||
message.success("上传成功")
|
||||
} catch {
|
||||
message.error("上传失败")
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert
|
||||
v-if="!refSQL"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
还没有填写 SQL 标准答案,请先在上方"本题参考答案"中填写,再来编写测试点
|
||||
</n-alert>
|
||||
<n-flex align="center" wrap>
|
||||
<n-button :disabled="isPreviewing || isGenerating" @click="reset">
|
||||
清空
|
||||
</n-button>
|
||||
<n-button :disabled="isPreviewing || isGenerating" @click="add">
|
||||
+1
|
||||
</n-button>
|
||||
<n-tooltip :disabled="!!refSQL && hasBlankScript">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
:loading="isGenerating"
|
||||
:disabled="!refSQL || !hasBlankScript || isPreviewing"
|
||||
@click="generate"
|
||||
>
|
||||
AI 生成
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{ !refSQL ? "请先填写 SQL 标准答案" : "所有脚本都写好了,无需生成" }}
|
||||
</n-tooltip>
|
||||
<n-tooltip :disabled="!!refSQL && hasAnyScript">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
type="success"
|
||||
:loading="isPreviewing"
|
||||
:disabled="!refSQL || !hasAnyScript || isGenerating"
|
||||
@click="preview"
|
||||
>
|
||||
预览验证
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{ !refSQL ? "请先填写 SQL 标准答案" : "请先填写数据脚本" }}
|
||||
</n-tooltip>
|
||||
<n-tooltip :disabled="canUpload || isPreviewing">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isUploading"
|
||||
:disabled="!canUpload || isGenerating"
|
||||
@click="upload"
|
||||
>
|
||||
上传
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{
|
||||
filledCount < 2
|
||||
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
|
||||
: "所有脚本预览验证通过后才能上传"
|
||||
}}
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<div v-for="(s, index) in scripts" :key="s.id" class="scriptBox">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<strong>{{ index + 1 }}.sql</strong>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="scripts.length === 1 || isPreviewing || isGenerating"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="s.sql"
|
||||
:rows="8"
|
||||
placeholder="-- 本测试点的建表 + 插入数据脚本
|
||||
CREATE TABLE ...;
|
||||
INSERT INTO ...;"
|
||||
:status="
|
||||
s.error ? 'error' : s.display && !s.stale ? 'success' : undefined
|
||||
"
|
||||
/>
|
||||
<n-alert v-if="s.error" type="error" :show-icon="false">
|
||||
{{ s.error }}
|
||||
</n-alert>
|
||||
<template v-if="s.display">
|
||||
<n-alert v-if="s.stale" type="warning" :show-icon="false">
|
||||
标准答案或题型已修改,以下预览已过期,请重新预览
|
||||
</n-alert>
|
||||
<div :class="{ stalePreview: s.stale }">
|
||||
<p class="previewTitle">数据表</p>
|
||||
<div v-for="t in s.display.tables" :key="t.name">
|
||||
<p class="sqlTableName">{{ t.name }}</p>
|
||||
<SQLDataTable
|
||||
:columns="t.columns"
|
||||
:rows="t.rows"
|
||||
:total-rows="t.total_rows"
|
||||
:truncated="t.truncated"
|
||||
/>
|
||||
</div>
|
||||
<p class="previewTitle">期望结果</p>
|
||||
<SQLDataTable
|
||||
v-if="expectedQuery(s.display)"
|
||||
:columns="expectedQuery(s.display)!.columns"
|
||||
:rows="expectedQuery(s.display)!.rows"
|
||||
:total-rows="expectedQuery(s.display)!.total_rows"
|
||||
:truncated="expectedQuery(s.display)!.truncated"
|
||||
/>
|
||||
<div v-for="t in changedTables(s.display)" :key="t.name">
|
||||
<p class="sqlTableName">
|
||||
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name} 表` }}
|
||||
</p>
|
||||
<SQLDataTable
|
||||
v-if="!t.dropped"
|
||||
:columns="t.columns"
|
||||
:rows="t.rows"
|
||||
:total-rows="t.total_rows"
|
||||
:truncated="t.truncated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scriptBox {
|
||||
border: 1px solid var(--n-border-color, rgba(128, 128, 128, 0.2));
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
font-weight: bold;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sqlTableName {
|
||||
font-weight: 500;
|
||||
margin: 4px 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.stalePreview {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
147
apps/web/src/admin/problem/components/TagProblemsModal.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import { batchTagProblems, getProblemList } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
tagId: number
|
||||
tagName: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const problems = ref<AdminProblemFiltered[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keyword = ref("")
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "显示编号", key: "_id", width: 100 },
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => goEdit(row) },
|
||||
() => row.title,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: row.visible ? "success" : "default" },
|
||||
() => (row.visible ? "公开" : "隐藏"),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", type: "error", onClick: () => removeTag(row) },
|
||||
() => "移除标签",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
async function listProblems() {
|
||||
if (page.value < 1) page.value = 1
|
||||
const offset = (page.value - 1) * limit.value
|
||||
const res = await getProblemList(
|
||||
offset,
|
||||
limit.value,
|
||||
keyword.value,
|
||||
"",
|
||||
undefined,
|
||||
props.tagId,
|
||||
)
|
||||
problems.value = res.results
|
||||
total.value = res.total
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
function goEdit(row: AdminProblemFiltered) {
|
||||
close()
|
||||
router.push({ name: "admin problem edit", params: { problemID: row.id } })
|
||||
}
|
||||
|
||||
async function removeTag(row: AdminProblemFiltered) {
|
||||
await batchTagProblems([row.id], [props.tagName], "remove")
|
||||
message.success(`已移除「${row.title}」的标签`)
|
||||
emit("changed")
|
||||
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
|
||||
if (problems.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1
|
||||
} else {
|
||||
listProblems()
|
||||
}
|
||||
}
|
||||
|
||||
// 改搜索词就回到第一页
|
||||
watch(keyword, () => (page.value = 1))
|
||||
|
||||
// 每次打开弹窗重置状态,拉取交给下面的 watcher
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
page.value = 1
|
||||
keyword.value = ""
|
||||
},
|
||||
)
|
||||
|
||||
// 打开 / 翻页 / 改每页条数 / 改搜索词都走这里,防抖把同一批变更合并成一次请求
|
||||
watchDebounced(
|
||||
() => [props.show, props.tagId, page.value, limit.value, keyword.value],
|
||||
() => {
|
||||
if (!props.show) return
|
||||
listProblems()
|
||||
},
|
||||
{ debounce: 300, maxWait: 800 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="`标签「${tagName}」下的题目`"
|
||||
style="width: 720px"
|
||||
@close="close"
|
||||
>
|
||||
<n-flex vertical size="large">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<span>共 {{ total }} 道题</span>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
style="width: 220px"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="problems" />
|
||||
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
263
apps/web/src/admin/problem/components/TestcaseGenerator.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import type { LANGUAGE, Testcase } from "utils/types"
|
||||
import { createZipBlob } from "utils/functions"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
import { uploadTestcases } from "../../api"
|
||||
|
||||
interface FileEntry {
|
||||
id: number
|
||||
in: string
|
||||
out: string
|
||||
error: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
answers: { language: LANGUAGE; code: string }[]
|
||||
samples?: { input: string; output: string }[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function makeInitialFiles(): FileEntry[] {
|
||||
const fromSamples = (props.samples ?? []).map((s) => ({
|
||||
id: nextId++,
|
||||
in: s.input,
|
||||
out: s.output,
|
||||
error: false,
|
||||
}))
|
||||
const total = Math.ceil(Math.max(fromSamples.length, 1) / 5) * 5
|
||||
const extra = total - fromSamples.length
|
||||
return [
|
||||
...fromSamples,
|
||||
...Array.from({ length: extra }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
const files = ref<FileEntry[]>(makeInitialFiles())
|
||||
|
||||
const selectedLanguage = ref<LANGUAGE>("Python3")
|
||||
|
||||
// 始终显示所有语言,不管有没有答案代码
|
||||
const availableLanguages = computed(() =>
|
||||
props.answers.map((a) => ({ label: a.language, value: a.language })),
|
||||
)
|
||||
|
||||
const hasAnyAnswerCode = computed(() =>
|
||||
props.answers.some((a) => a.code.trim()),
|
||||
)
|
||||
|
||||
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
|
||||
const hasAnswerCode = computed(() => {
|
||||
const answer = props.answers.find(
|
||||
(a) => a.language === selectedLanguage.value,
|
||||
)
|
||||
return !!answer?.code.trim()
|
||||
})
|
||||
|
||||
// 当语言列表变化时,确保 selectedLanguage 始终指向一个有效值
|
||||
watch(
|
||||
availableLanguages,
|
||||
(langs) => {
|
||||
if (
|
||||
langs.length &&
|
||||
!langs.find((l) => l.value === selectedLanguage.value)
|
||||
) {
|
||||
selectedLanguage.value = langs[0].value
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const isRunning = ref(false)
|
||||
const isUploading = ref(false)
|
||||
|
||||
const hasAnyInput = computed(() => files.value.some((f) => f.in.trim()))
|
||||
|
||||
const canUpload = computed(
|
||||
() =>
|
||||
!isRunning.value &&
|
||||
hasAnyInput.value &&
|
||||
files.value.filter((f) => f.in.trim()).every((f) => f.out && !f.error),
|
||||
)
|
||||
|
||||
function reset() {
|
||||
files.value = Array.from({ length: 5 }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
}))
|
||||
}
|
||||
|
||||
function add(n: number) {
|
||||
files.value.push(
|
||||
...Array.from({ length: n }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const answer = props.answers.find(
|
||||
(a) => a.language === selectedLanguage.value,
|
||||
)
|
||||
if (!answer?.code.trim()) return
|
||||
|
||||
// 过滤空行,去重(按输入内容)
|
||||
const seen = new Set<string>()
|
||||
files.value = files.value.filter((f) => {
|
||||
if (!f.in.trim()) return false
|
||||
if (seen.has(f.in)) return false
|
||||
seen.add(f.in)
|
||||
return true
|
||||
})
|
||||
|
||||
// 清空旧输出
|
||||
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
|
||||
|
||||
isRunning.value = true
|
||||
await Promise.all(
|
||||
files.value.map(async (_, i) => {
|
||||
try {
|
||||
const result = await createTestSubmission(
|
||||
{ language: selectedLanguage.value, value: answer.code },
|
||||
files.value[i].in,
|
||||
)
|
||||
files.value[i] = {
|
||||
...files.value[i],
|
||||
out: result.output,
|
||||
error: result.status !== 3,
|
||||
}
|
||||
} catch {
|
||||
files.value[i] = { ...files.value[i], out: "", error: true }
|
||||
}
|
||||
}),
|
||||
)
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const data = files.value
|
||||
.filter((f) => f.in.trim() && f.out && !f.error)
|
||||
.flatMap((f, i) => [
|
||||
{ name: `${i + 1}.in`, content: f.in },
|
||||
{ name: `${i + 1}.out`, content: f.out },
|
||||
])
|
||||
|
||||
const blob = createZipBlob(data)
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
const res = await uploadTestcases(file)
|
||||
const testcases: Testcase[] = res.data.info
|
||||
const baseScore = Math.floor(100 / testcases.length)
|
||||
const remainder = 100 - baseScore * testcases.length
|
||||
testcases.forEach((tc, i) => {
|
||||
tc.score = String(
|
||||
i === testcases.length - 1 ? baseScore + remainder : baseScore,
|
||||
)
|
||||
})
|
||||
|
||||
emit("uploaded", res.data.id, testcases)
|
||||
message.success("上传成功")
|
||||
} catch {
|
||||
message.error("上传失败")
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert
|
||||
v-if="!hasAnyAnswerCode"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
还没有填写答案代码,请先在上方"本题参考答案"中填写至少一种语言的答案,再来生成测试用例
|
||||
</n-alert>
|
||||
<n-flex align="center" wrap>
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
:options="availableLanguages"
|
||||
v-model:value="selectedLanguage"
|
||||
/>
|
||||
<n-button :disabled="isRunning" @click="reset">清空</n-button>
|
||||
<n-button :disabled="isRunning" @click="add(1)">+1</n-button>
|
||||
<n-button :disabled="isRunning" @click="add(5)">+5</n-button>
|
||||
<n-tooltip :disabled="hasAnswerCode && hasAnyInput">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
type="success"
|
||||
:loading="isRunning"
|
||||
:disabled="!hasAnswerCode || !hasAnyInput"
|
||||
@click="run"
|
||||
>
|
||||
先运行
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{ !hasAnswerCode ? "请先在题目中填写答案代码" : "请先填写输入" }}
|
||||
</n-tooltip>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isUploading"
|
||||
:disabled="!canUpload"
|
||||
@click="upload"
|
||||
>
|
||||
上传
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-flex
|
||||
v-for="(file, index) in files"
|
||||
:key="file.id"
|
||||
align="start"
|
||||
style="gap: 8px"
|
||||
>
|
||||
<n-flex vertical style="flex: 1">
|
||||
<span>{{ index + 1 }}.in</span>
|
||||
<n-input type="textarea" v-model:value="file.in" :rows="3" />
|
||||
</n-flex>
|
||||
<n-flex vertical style="flex: 1">
|
||||
<span>{{ index + 1 }}.out</span>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="file.out"
|
||||
:rows="3"
|
||||
:status="file.out ? (file.error ? 'error' : 'success') : undefined"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-button
|
||||
:disabled="files.length === 1 || isRunning"
|
||||
style="margin-top: 22px"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</template>
|
||||
920
apps/web/src/admin/problem/detail.vue
Normal file
@@ -0,0 +1,920 @@
|
||||
<script setup lang="ts">
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
|
||||
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
|
||||
import AstRulesEditor from "./components/AstRulesEditor.vue"
|
||||
import {
|
||||
CODE_TEMPLATES,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
STORAGE_KEY,
|
||||
} from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { unique } from "utils/functions"
|
||||
import type {
|
||||
BlankProblem,
|
||||
LANGUAGE,
|
||||
SQLConfig,
|
||||
Tag,
|
||||
Testcase,
|
||||
} from "utils/types"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
editContestProblem,
|
||||
editProblem,
|
||||
generateFlowchartFromPythonCode,
|
||||
getProblem,
|
||||
uploadTestcases,
|
||||
} from "../api"
|
||||
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
|
||||
const MermaidEditor = defineAsyncComponent(
|
||||
() => import("shared/components/MermaidEditor.vue"),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
problemID?: string
|
||||
contestID?: string
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const title = computed(
|
||||
() =>
|
||||
({
|
||||
"admin problem create": "新建题目",
|
||||
"admin problem edit": "编辑题目",
|
||||
"admin contest problem create": "新建比赛题目",
|
||||
"admin contest problem edit": "编辑比赛题目",
|
||||
})[route.name as string],
|
||||
)
|
||||
|
||||
const isAIGenerating = ref(false)
|
||||
|
||||
const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
||||
_id: "",
|
||||
title: "",
|
||||
description: "",
|
||||
input_description: "",
|
||||
output_description: "",
|
||||
time_limit: 1000,
|
||||
memory_limit: 64,
|
||||
difficulty: "Low" as "Low" | "Mid" | "High",
|
||||
visible: false,
|
||||
share_submission: false,
|
||||
tags: [],
|
||||
languages: ["Python3", "C"] as LANGUAGE[],
|
||||
template: {} as { [key in LANGUAGE]?: string },
|
||||
samples: [
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
],
|
||||
test_case_id: "",
|
||||
test_case_score: [] as Testcase[],
|
||||
hint: "",
|
||||
source: "",
|
||||
prompt: "",
|
||||
answers: [] as { language: LANGUAGE; code: string }[],
|
||||
contest_id: "",
|
||||
allow_flowchart: false,
|
||||
mermaid_code: "",
|
||||
flowchart_data: {},
|
||||
flowchart_hint: "",
|
||||
show_flowchart: false,
|
||||
ast_rules: null as { [key: string]: any[] } | null,
|
||||
sql_config: null as SQLConfig | null,
|
||||
})
|
||||
|
||||
// 从服务器来的tag列表
|
||||
const tagList = shallowRef<Tag[]>([])
|
||||
const tagListLoaded = ref(false)
|
||||
|
||||
const selectedTags = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
const selectedTagSet = computed(() => new Set(selectedTags.value))
|
||||
let syncingTagInputs = false
|
||||
|
||||
function normalizeTagNames(tags: unknown): string[] {
|
||||
if (!Array.isArray(tags)) return []
|
||||
return unique(
|
||||
tags
|
||||
.map((tag) => (typeof tag === "string" ? tag : tag?.name))
|
||||
.filter((tag): tag is string => !!tag),
|
||||
)
|
||||
}
|
||||
|
||||
function syncProblemTags() {
|
||||
problem.value.tags = unique([...selectedTags.value, ...newTags.value])
|
||||
}
|
||||
|
||||
function syncTagInputsFromProblemTags(tags: unknown = problem.value.tags) {
|
||||
const tagNames = normalizeTagNames(tags)
|
||||
const existingTagNames = new Set(tagList.value.map((tag) => tag.name))
|
||||
|
||||
syncingTagInputs = true
|
||||
if (!tagListLoaded.value) {
|
||||
selectedTags.value = tagNames
|
||||
newTags.value = []
|
||||
} else {
|
||||
selectedTags.value = tagNames.filter((tag) => existingTagNames.has(tag))
|
||||
newTags.value = tagNames.filter((tag) => !existingTagNames.has(tag))
|
||||
}
|
||||
syncingTagInputs = false
|
||||
syncProblemTags()
|
||||
}
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const set = new Set(selectedTags.value)
|
||||
if (set.has(name)) set.delete(name)
|
||||
else set.add(name)
|
||||
selectedTags.value = Array.from(set)
|
||||
}
|
||||
|
||||
function validateNewTags(v: string[]) {
|
||||
const existing = new Set(tagList.value.map((t) => t.name))
|
||||
const blanks: string[] = []
|
||||
for (const tag of unique(v)) {
|
||||
if (existing.has(tag)) {
|
||||
message.error("已经存在标签:" + tag)
|
||||
break
|
||||
}
|
||||
blanks.push(tag)
|
||||
}
|
||||
newTags.value = blanks
|
||||
}
|
||||
|
||||
// 这几个用的少,就不缓存本地了
|
||||
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
||||
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
|
||||
const currentActiveTemplate = ref<LANGUAGE>("Python3")
|
||||
const currentActiveAnswer = ref<LANGUAGE>("Python3")
|
||||
|
||||
// 给 TextEditor 用
|
||||
const [ready, toggleReady] = useToggle(false)
|
||||
|
||||
// Mermaid 渲染状态
|
||||
const mermaidRenderSuccess = ref(false)
|
||||
|
||||
const difficultyOptions: SelectOption[] = [
|
||||
{ label: "简单", value: "Low" },
|
||||
{ label: "中等", value: "Mid" },
|
||||
{ label: "困难", value: "High" },
|
||||
]
|
||||
|
||||
const languageOptions = [
|
||||
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" },
|
||||
]
|
||||
|
||||
const isSQLProblem = computed(() => !!problem.value?.languages.includes("SQL"))
|
||||
|
||||
// SQL 题联动:SQL 必须是唯一语言(后端强校验),不需要预制代码,自动初始化 sql_config
|
||||
watch(
|
||||
() => problem.value?.languages,
|
||||
(langs) => {
|
||||
if (!langs) return
|
||||
if (langs.includes("SQL")) {
|
||||
if (langs.length > 1) {
|
||||
problem.value.languages = ["SQL"]
|
||||
return
|
||||
}
|
||||
needTemplate.value = false
|
||||
if (!problem.value.sql_config) {
|
||||
problem.value.sql_config = { mode: "query", order_sensitive: false }
|
||||
}
|
||||
currentActiveAnswer.value = "SQL"
|
||||
// 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据
|
||||
if (problem.value.ast_rules) {
|
||||
problem.value.ast_rules = null
|
||||
}
|
||||
// 流程图依赖 Python 答案生成,对 SQL 没有意义
|
||||
problem.value.allow_flowchart = false
|
||||
problem.value.show_flowchart = false
|
||||
} else if (problem.value.sql_config) {
|
||||
problem.value.sql_config = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function getProblemDetail() {
|
||||
if (!props.problemID) {
|
||||
syncTagInputsFromProblemTags()
|
||||
toggleReady(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { data } = await getProblem(props.problemID)
|
||||
problem.value.id = data.id
|
||||
problem.value._id = data._id
|
||||
problem.value.title = data.title
|
||||
problem.value.description = data.description
|
||||
problem.value.input_description = data.input_description
|
||||
problem.value.output_description = data.output_description
|
||||
problem.value.time_limit = data.time_limit
|
||||
problem.value.memory_limit = data.memory_limit
|
||||
problem.value.memory_limit = data.memory_limit
|
||||
problem.value.difficulty = data.difficulty
|
||||
problem.value.visible = data.visible
|
||||
problem.value.share_submission = data.share_submission
|
||||
problem.value.tags = normalizeTagNames(data.tags)
|
||||
problem.value.languages = data.languages
|
||||
problem.value.template = data.template
|
||||
problem.value.samples = data.samples
|
||||
problem.value.samples = data.samples
|
||||
problem.value.test_case_id = data.test_case_id
|
||||
problem.value.test_case_score = data.test_case_score
|
||||
problem.value.hint = data.hint
|
||||
problem.value.source = data.source
|
||||
problem.value.prompt = data.prompt
|
||||
// 流程图相关字段
|
||||
problem.value.allow_flowchart = data.allow_flowchart
|
||||
problem.value.show_flowchart = data.show_flowchart
|
||||
problem.value.mermaid_code = data.mermaid_code ?? ""
|
||||
problem.value.flowchart_hint = data.flowchart_hint ?? ""
|
||||
problem.value.flowchart_data = data.flowchart_data
|
||||
problem.value.ast_rules = data.ast_rules ?? null
|
||||
problem.value.sql_config = data.sql_config ?? null
|
||||
if (data.answers && data.answers.length) {
|
||||
problem.value.answers = data.answers
|
||||
} else {
|
||||
problem.value.answers = data.languages.map((lang: LANGUAGE) => ({
|
||||
language: lang,
|
||||
code: "",
|
||||
}))
|
||||
}
|
||||
if (problem.value.contest_id) {
|
||||
problem.value.contest_id = problem.value.contest_id
|
||||
}
|
||||
|
||||
// 下面是用来显示的:
|
||||
// 代码模板 和 模板开关
|
||||
problem.value.languages.forEach((lang) => {
|
||||
if (data.template[lang]) {
|
||||
template[lang] = data.template[lang]
|
||||
toggleNeedTemplate(true)
|
||||
}
|
||||
})
|
||||
// 标签
|
||||
syncTagInputsFromProblemTags(problem.value.tags)
|
||||
toggleReady(true)
|
||||
} catch (error) {
|
||||
message.error("获取题目失败")
|
||||
router.push({ name: "admin problem list" })
|
||||
}
|
||||
}
|
||||
|
||||
async function getTagList() {
|
||||
const res = await getProblemTagList()
|
||||
tagList.value = res.data
|
||||
tagListLoaded.value = true
|
||||
syncTagInputsFromProblemTags()
|
||||
}
|
||||
|
||||
function addSample() {
|
||||
problem.value.samples.push({ input: "", output: "" })
|
||||
}
|
||||
|
||||
function removeSample(index: number) {
|
||||
problem.value.samples.splice(index, 1)
|
||||
}
|
||||
|
||||
function resetTemplate(language: LANGUAGE) {
|
||||
template[language] = CODE_TEMPLATES[language]
|
||||
}
|
||||
|
||||
async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
||||
try {
|
||||
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
|
||||
// @ts-ignore
|
||||
if (res.error) {
|
||||
message.error("上传测试用例失败")
|
||||
return
|
||||
}
|
||||
const testcases = res.data.info
|
||||
for (let file of testcases) {
|
||||
file.score = (100 / testcases.length).toFixed(0)
|
||||
}
|
||||
problem.value.test_case_score = testcases
|
||||
problem.value.test_case_id = res.data.id
|
||||
} catch (err) {
|
||||
message.error("上传测试用例失败")
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTestcases() {
|
||||
download("test_case?problem_id=" + problem.value.id)
|
||||
}
|
||||
|
||||
// Mermaid 渲染事件处理
|
||||
function onMermaidRenderSuccess() {
|
||||
mermaidRenderSuccess.value = true
|
||||
}
|
||||
|
||||
// 题目是否有漏写的
|
||||
async function validateProblem() {
|
||||
let hasErrors = false
|
||||
// 标题
|
||||
if (!problem.value._id || !problem.value.title) {
|
||||
message.error("编号或标题没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
// 标签
|
||||
else if (selectedTags.value.length === 0 && newTags.value.length === 0) {
|
||||
message.error("标签没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
// 题目
|
||||
else if (
|
||||
!problem.value.description ||
|
||||
(!isSQLProblem.value &&
|
||||
(!problem.value.input_description || !problem.value.output_description))
|
||||
) {
|
||||
message.error("题目或输入或输出没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
// 样例
|
||||
else if (!isSQLProblem.value && problem.value.samples.length == 0) {
|
||||
message.error("样例没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
// 样例是空的
|
||||
else if (
|
||||
!isSQLProblem.value &&
|
||||
problem.value.samples.some(
|
||||
(sample) => sample.output === "" || sample.input === "",
|
||||
)
|
||||
) {
|
||||
message.error("空样例没有删干净")
|
||||
hasErrors = true
|
||||
}
|
||||
// 测试用例
|
||||
else if (problem.value.test_case_score.length === 0) {
|
||||
message.error("测试用例没有上传")
|
||||
hasErrors = true
|
||||
} else if (problem.value.languages.length === 0) {
|
||||
message.error("编程语言没有选择")
|
||||
hasErrors = true
|
||||
}
|
||||
// SQL 题验证
|
||||
else if (isSQLProblem.value && !problem.value.sql_config?.mode) {
|
||||
message.error("SQL 题需要选择题型(查询题/增删改题)")
|
||||
hasErrors = true
|
||||
} else if (
|
||||
isSQLProblem.value &&
|
||||
!problem.value.answers.find(
|
||||
(ans) => ans.language === "SQL" && ans.code.trim() !== "",
|
||||
)
|
||||
) {
|
||||
message.error("SQL 题必须填写标准答案(判题时用它生成期望结果)")
|
||||
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 {
|
||||
hasErrors = false
|
||||
}
|
||||
return hasErrors
|
||||
}
|
||||
|
||||
function getTemplate() {
|
||||
if (!needTemplate.value) {
|
||||
problem.value.template = {}
|
||||
} else {
|
||||
problem.value.languages.forEach((lang) => {
|
||||
if (CODE_TEMPLATES[lang] !== template[lang]) {
|
||||
problem.value.template[lang] = template[lang]
|
||||
} else {
|
||||
delete problem.value.template[lang]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function filterHint() {
|
||||
// 编辑器会自动添加一段 HTML
|
||||
if (problem.value.hint === "<p><br></p>") {
|
||||
problem.value.hint = ""
|
||||
}
|
||||
}
|
||||
|
||||
function filterAnswers() {
|
||||
problem.value.answers = problem.value.answers.filter(
|
||||
(ans) => ans.code.trim() !== "",
|
||||
)
|
||||
}
|
||||
|
||||
function filterSamplesForSQL() {
|
||||
// SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例
|
||||
if (isSQLProblem.value) problem.value.samples = []
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const hasValidationErrors = await validateProblem()
|
||||
if (hasValidationErrors) return
|
||||
filterHint()
|
||||
getTemplate()
|
||||
filterAnswers()
|
||||
filterSamplesForSQL()
|
||||
syncProblemTags()
|
||||
const api = {
|
||||
"admin problem create": createProblem,
|
||||
"admin problem edit": editProblem,
|
||||
"admin contest problem create": createContestProblem,
|
||||
"admin contest problem edit": editContestProblem,
|
||||
}[route.name as string]
|
||||
if (
|
||||
route.name === "admin contest problem create" ||
|
||||
route.name === "admin contest problem edit"
|
||||
) {
|
||||
problem.value.contest_id = props.contestID
|
||||
}
|
||||
try {
|
||||
await api!(problem.value)
|
||||
problem.value = null
|
||||
selectedTags.value = []
|
||||
newTags.value = []
|
||||
if (
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin contest problem create"
|
||||
) {
|
||||
message.success("恭喜你 💐 出题成功")
|
||||
}
|
||||
if (
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin problem edit"
|
||||
) {
|
||||
router.push({ name: "admin problem list" })
|
||||
} else {
|
||||
router.push({
|
||||
name: "admin contest problem list",
|
||||
params: { contestID: props.contestID },
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.data === "Display ID already exists") {
|
||||
message.error("显示编号重复了,请换一个显示编号")
|
||||
} else {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showClear = computed(
|
||||
() =>
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin contest problem create",
|
||||
)
|
||||
|
||||
function clear() {
|
||||
problem.value = null
|
||||
selectedTags.value = []
|
||||
newTags.value = []
|
||||
// 为了给所有状态初始化,刷新页面
|
||||
location.reload()
|
||||
}
|
||||
|
||||
async function generateMermaid() {
|
||||
isAIGenerating.value = true
|
||||
const res = await generateFlowchartFromPythonCode(
|
||||
problem.value.answers.filter((a) => a.language === "Python3")[0].code,
|
||||
)
|
||||
isAIGenerating.value = false
|
||||
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
|
||||
problem.value.mermaid_code = res.data.flowchart
|
||||
}
|
||||
|
||||
const showGeneratorModal = ref(false)
|
||||
|
||||
function handleTestcasesGenerated(
|
||||
testCaseId: string,
|
||||
testCaseScore: Testcase[],
|
||||
) {
|
||||
problem.value.test_case_id = testCaseId
|
||||
problem.value.test_case_score = testCaseScore
|
||||
showGeneratorModal.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTagList()
|
||||
getProblemDetail()
|
||||
})
|
||||
|
||||
watch([selectedTags, newTags], ([sel, newT]) => {
|
||||
if (syncingTagInputs) return
|
||||
problem.value.tags = unique([...sel, ...newT])
|
||||
})
|
||||
watch(
|
||||
() => problem.value.languages,
|
||||
(langs) => {
|
||||
const answers = langs.map((lang) => {
|
||||
const existing = problem.value.answers.find(
|
||||
(ans) => ans.language === lang,
|
||||
)
|
||||
return existing || { language: lang, code: "" }
|
||||
})
|
||||
problem.value.answers = answers
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex>
|
||||
<h2 class="title">{{ title }}</h2>
|
||||
<n-button v-if="showClear" @click="clear">清空缓存</n-button>
|
||||
</n-flex>
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="显示编号">
|
||||
<n-input class="w-100" v-model:value="problem._id" />
|
||||
</n-form-item>
|
||||
<n-form-item label="题目">
|
||||
<n-input class="problemTitleInput" v-model:value="problem.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="难度">
|
||||
<n-select
|
||||
class="w-100"
|
||||
:options="difficultyOptions"
|
||||
v-model:value="problem.difficulty"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="problem.visible" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="标签">
|
||||
<n-flex vertical style="width: 100%">
|
||||
<n-flex size="small" style="flex-wrap: wrap">
|
||||
<n-tag
|
||||
v-for="tag in tagList"
|
||||
:key="tag.id"
|
||||
checkable
|
||||
:checked="selectedTagSet.has(tag.name)"
|
||||
@update:checked="toggleTag(tag.name)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-dynamic-tags
|
||||
v-model:value="newTags"
|
||||
@update:value="validateNewTags"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<TextEditor
|
||||
v-if="ready"
|
||||
v-model:value="problem.description"
|
||||
title="题目的描述"
|
||||
:min-height="300"
|
||||
/>
|
||||
<TextEditor
|
||||
v-if="ready && !isSQLProblem"
|
||||
v-model:value="problem.input_description"
|
||||
title="输入的描述"
|
||||
/>
|
||||
<TextEditor
|
||||
v-if="ready && !isSQLProblem"
|
||||
v-model:value="problem.output_description"
|
||||
title="输出的描述"
|
||||
/>
|
||||
<template v-if="!isSQLProblem">
|
||||
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<strong>测试样例 {{ index + 1 }}</strong>
|
||||
<n-button
|
||||
tertiary
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="removeSample(index)"
|
||||
>
|
||||
删除 {{ index + 1 }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-grid x-gap="20" cols="2">
|
||||
<n-gi span="1">
|
||||
<n-flex vertical>
|
||||
<span>输入样例</span>
|
||||
<n-input type="textarea" v-model:value="sample.input" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-flex vertical>
|
||||
<span>输出样例</span>
|
||||
<n-input type="textarea" v-model:value="sample.output" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
||||
添加用例
|
||||
</n-button>
|
||||
</template>
|
||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
|
||||
<n-form>
|
||||
<n-form-item label="题目的来源(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.source"
|
||||
placeholder="比如来自某道题的改编等,或者网上的资料"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
|
||||
<n-input
|
||||
v-model:value="problem.prompt"
|
||||
placeholder="比如考察选择、循环、算法等知识点"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">代码区域</h2>
|
||||
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="编程语言">
|
||||
<n-checkbox-group v-model:value="problem.languages">
|
||||
<n-flex align="center">
|
||||
<n-checkbox
|
||||
v-for="(language, index) in languageOptions"
|
||||
:key="index"
|
||||
:value="language.value"
|
||||
:label="language.label"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="!isSQLProblem">
|
||||
<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-form
|
||||
v-if="isSQLProblem && problem.sql_config"
|
||||
inline
|
||||
label-placement="left"
|
||||
>
|
||||
<n-form-item label="SQL 题型">
|
||||
<n-radio-group v-model:value="problem.sql_config.mode">
|
||||
<n-radio-button value="query">查询题(比对查询结果)</n-radio-button>
|
||||
<n-radio-button value="modify">
|
||||
增删改题(比对执行后的表数据)
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="严格比对行顺序">
|
||||
<n-switch v-model:value="problem.sql_config.order_sensitive" />
|
||||
<n-text depth="3" style="margin-left: 12px">
|
||||
题目要求 ORDER BY 时开启;关闭则按无序集合比对
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-grid :cols="2" x-gap="20">
|
||||
<n-gi>
|
||||
<n-form>
|
||||
<n-form-item
|
||||
:label="
|
||||
isSQLProblem
|
||||
? '标准答案(必填,判题依据:每个测试点会运行它生成期望结果)'
|
||||
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
|
||||
"
|
||||
>
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="Python3"
|
||||
v-model:value="currentActiveAnswer"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="(answer, index) in problem.answers"
|
||||
:key="index"
|
||||
:name="answer.language"
|
||||
>
|
||||
<CodeEditor
|
||||
v-model:value="answer.code"
|
||||
:language="answer.language"
|
||||
:font-size="16"
|
||||
height="300px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form v-if="needTemplate">
|
||||
<n-form-item label="编写预制代码">
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="Python3"
|
||||
v-model:value="currentActiveTemplate"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="(lang, index) in problem.languages"
|
||||
:key="index"
|
||||
:name="lang"
|
||||
>
|
||||
<CodeEditor
|
||||
v-model:value="template[lang]"
|
||||
:language="lang"
|
||||
:font-size="16"
|
||||
height="300px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-grid v-if="!isSQLProblem" :cols="2">
|
||||
<n-gi :span="1">
|
||||
<AstRulesEditor
|
||||
v-model="problem.ast_rules!"
|
||||
:languages="problem.languages"
|
||||
/>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">测试用例区域</h2>
|
||||
|
||||
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
|
||||
<div>
|
||||
<n-button type="success" @click="showGeneratorModal = true">
|
||||
(新)直接生成
|
||||
</n-button>
|
||||
</div>
|
||||
<div>
|
||||
<n-upload
|
||||
:show-file-list="false"
|
||||
accept=".zip"
|
||||
:custom-request="handleUploadTestcases"
|
||||
>
|
||||
<n-button type="info">(老)手动上传</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
<n-tooltip placement="right" style="max-width: 320px; white-space: normal">
|
||||
<template #trigger>
|
||||
<n-button text>温馨提醒</n-button>
|
||||
</template>
|
||||
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<SQLTestcaseEditor
|
||||
v-if="isSQLProblem"
|
||||
:answers="problem.answers"
|
||||
:mode="problem.sql_config?.mode ?? 'query'"
|
||||
:problem-id="problem.id"
|
||||
@uploaded="handleTestcasesGenerated"
|
||||
/>
|
||||
|
||||
<n-alert
|
||||
class="box"
|
||||
v-if="problem.test_case_score.length"
|
||||
:show-icon="false"
|
||||
type="info"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
|
||||
{{ problem.test_case_score.length }}
|
||||
条测试用例
|
||||
</div>
|
||||
<n-button
|
||||
v-if="problem.id"
|
||||
tertiary
|
||||
type="info"
|
||||
size="small"
|
||||
@click="downloadTestcases"
|
||||
>
|
||||
下载
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showGeneratorModal"
|
||||
preset="card"
|
||||
title="测试用例生成器"
|
||||
style="width: 80vw; max-width: 900px"
|
||||
:mask-closable="false"
|
||||
display-directive="show"
|
||||
>
|
||||
<TestcaseGenerator
|
||||
:answers="problem.answers"
|
||||
:samples="problem.samples"
|
||||
@uploaded="handleTestcasesGenerated"
|
||||
/>
|
||||
</n-modal>
|
||||
|
||||
<template v-if="!isSQLProblem">
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">流程图区域</h2>
|
||||
|
||||
<!-- 流程图相关设置 -->
|
||||
<n-form inline label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="根据上面的【Python答案】智能生成 Mermaid 代码">
|
||||
<n-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="
|
||||
!problem.answers.filter((a) => a.language === 'Python3')[0]?.code
|
||||
.length
|
||||
"
|
||||
:loading="isAIGenerating"
|
||||
@click="generateMermaid"
|
||||
>
|
||||
AI 生成
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<n-flex style="margin: 16px 0 120px" align="center" justify="end">
|
||||
<n-button type="primary" @click="submit">提交</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.box {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.problemTitleInput {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.addSamples {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
335
apps/web/src/admin/problem/list.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<script setup lang="ts">
|
||||
import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { getTagColor, parseTime } from "utils/functions"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import { DIFFICULTY, REACTIONS } from "utils/constants"
|
||||
import { getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Modal from "./components/Modal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
import type { DataTableRowKey } from "naive-ui"
|
||||
import BatchTagModal from "./components/BatchTagModal.vue"
|
||||
|
||||
interface Props {
|
||||
contestID?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const title = computed(
|
||||
() =>
|
||||
({
|
||||
"admin problem list": "题目列表",
|
||||
"admin contest problem list": "比赛题目列表",
|
||||
})[route.name as string],
|
||||
)
|
||||
const isContestProblemList = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
|
||||
const [show, toggleShow] = useToggle()
|
||||
const { count, inc } = useCounter(0)
|
||||
const total = ref(0)
|
||||
const problems = ref<AdminProblemFiltered[]>([])
|
||||
|
||||
const selectedRowKeys = ref<DataTableRowKey[]>([])
|
||||
const batchTagAction = ref<"add" | "remove">("add")
|
||||
const [showBatchTag, toggleBatchTag] = useToggle(false)
|
||||
|
||||
const selectedProblemIds = computed(() =>
|
||||
selectedRowKeys.value.map((key) => Number(key)),
|
||||
)
|
||||
|
||||
const rowKey = (row: AdminProblemFiltered) => row.id
|
||||
|
||||
function chooseProblems(rowKeys: DataTableRowKey[]) {
|
||||
selectedRowKeys.value = rowKeys
|
||||
}
|
||||
|
||||
function openBatchTag(action: "add" | "remove") {
|
||||
batchTagAction.value = action
|
||||
toggleBatchTag(true)
|
||||
}
|
||||
|
||||
function onBatchTagDone() {
|
||||
selectedRowKeys.value = []
|
||||
listProblems()
|
||||
}
|
||||
|
||||
const nextDisplayID = computed(() => {
|
||||
if (!isContestProblemList.value) return ""
|
||||
if (problems.value.length === 0) return "1"
|
||||
const ids = problems.value.map((p) => p._id)
|
||||
if (ids.every((id) => /^\d+$/.test(id))) {
|
||||
return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
author: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
})
|
||||
|
||||
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "ID", key: "id", width: 100 },
|
||||
{ title: "显示编号", key: "_id", width: 100 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{
|
||||
title: "难度",
|
||||
key: "difficulty",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ type: getTagColor(row.difficulty), size: "small" },
|
||||
() => DIFFICULTY[row.difficulty],
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
key: "tags",
|
||||
minWidth: 120,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 4 }, () =>
|
||||
row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t)),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "功能",
|
||||
key: "features",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 4, align: "center" }, () => [
|
||||
row.allow_flowchart
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-drawio",
|
||||
title: "绘图",
|
||||
})
|
||||
: row.show_flowchart
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-graphql",
|
||||
title: "流程图",
|
||||
})
|
||||
: null,
|
||||
row.has_ast_rules
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-light-todo",
|
||||
title: "AST",
|
||||
})
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "反馈",
|
||||
key: "top_reaction",
|
||||
width: 60,
|
||||
render: (row) => {
|
||||
const top = row.top_reaction
|
||||
if (!top) return null
|
||||
const reaction = REACTIONS.find((it) => it.key === top.type)
|
||||
if (!reaction) return null
|
||||
return h(NTooltip, null, {
|
||||
trigger: () => h(Icon, { width: 18, icon: reaction.icon }),
|
||||
default: () => `${reaction.label} ${top.count} 人`,
|
||||
})
|
||||
},
|
||||
},
|
||||
{ title: "出题人", key: "username", width: 120 },
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
minWidth: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 320,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemID: row.id,
|
||||
problemDisplayID: row._id,
|
||||
onUpdated: listProblems,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
|
||||
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
|
||||
isContestProblemList.value
|
||||
? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction")
|
||||
: [{ type: "selection" }, ...baseColumns],
|
||||
)
|
||||
|
||||
async function listProblems() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.author,
|
||||
props.contestID,
|
||||
)
|
||||
total.value = res.total
|
||||
problems.value = res.results
|
||||
}
|
||||
|
||||
async function toggleVisible(problemID: number) {
|
||||
await toggleProblemVisible(problemID)
|
||||
problems.value = problems.value.map((it) => {
|
||||
if (it.id === problemID) {
|
||||
it.visible = !it.visible
|
||||
}
|
||||
return it
|
||||
})
|
||||
}
|
||||
|
||||
function createContestProblem() {
|
||||
router.push({
|
||||
name: "admin contest problem create",
|
||||
params: { contestID: props.contestID },
|
||||
})
|
||||
}
|
||||
|
||||
async function selectProblems() {
|
||||
toggleShow(true)
|
||||
inc()
|
||||
}
|
||||
|
||||
onMounted(listProblems)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listProblems, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" justify="space-between">
|
||||
<n-flex align="center">
|
||||
<h2 class="title">{{ title }}</h2>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin problem create' })"
|
||||
>
|
||||
新建
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin stuck problems' })"
|
||||
>
|
||||
卡点分析
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin top ac trend' })"
|
||||
>
|
||||
年度趋势
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin tag list' })"
|
||||
>
|
||||
标签管理
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex>
|
||||
<template v-if="!isContestProblemList && selectedProblemIds.length">
|
||||
<n-button type="primary" @click="openBatchTag('add')">
|
||||
添加标签({{ selectedProblemIds.length }})
|
||||
</n-button>
|
||||
<n-button @click="openBatchTag('remove')">移除标签</n-button>
|
||||
</template>
|
||||
<n-button v-if="isContestProblemList" @click="createContestProblem">
|
||||
新建比赛题目
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="isContestProblemList"
|
||||
type="primary"
|
||||
@click="selectProblems"
|
||||
>
|
||||
从题目中选择
|
||||
</n-button>
|
||||
<n-flex align="center" v-if="!props.contestID">
|
||||
<span>出题人</span>
|
||||
<AuthorSelect v-model:value="query.author" all />
|
||||
</n-flex>
|
||||
<div>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="problems"
|
||||
:row-key="rowKey"
|
||||
@update:checked-row-keys="chooseProblems"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<Modal
|
||||
v-model:show="show"
|
||||
:count="count"
|
||||
:next-display-id="nextDisplayID"
|
||||
@change="listProblems"
|
||||
/>
|
||||
<BatchTagModal
|
||||
v-model:show="showBatchTag"
|
||||
:problem-ids="selectedProblemIds"
|
||||
:action="batchTagAction"
|
||||
@done="onBatchTagDone"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
186
apps/web/src/admin/problem/tags.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex, NInput } from "naive-ui"
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { deleteTag, getTagAdminList, renameTag } from "../api"
|
||||
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const keyword = ref("")
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingName = ref("")
|
||||
|
||||
const activeTag = ref<AdminTag | null>(null)
|
||||
const [showTagProblems, toggleTagProblems] = useToggle(false)
|
||||
|
||||
function openTagProblems(tag: AdminTag) {
|
||||
activeTag.value = tag
|
||||
toggleTagProblems(true)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<AdminTag>[] = [
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{
|
||||
title: "标签名",
|
||||
key: "name",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
editingId.value === row.id
|
||||
? h(NInput, {
|
||||
value: editingName.value,
|
||||
autofocus: true,
|
||||
size: "small",
|
||||
style: "max-width: 240px",
|
||||
onUpdateValue: (v: string) => (editingName.value = v),
|
||||
onKeyup: (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") saveTag(row)
|
||||
if (e.key === "Escape") cancelEdit()
|
||||
},
|
||||
})
|
||||
: h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "primary",
|
||||
onClick: () => openTagProblems(row),
|
||||
},
|
||||
() => row.name,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "题目数",
|
||||
key: "problem_count",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
|
||||
() => String(row.problem_count),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 200,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 8 }, () =>
|
||||
editingId.value === row.id
|
||||
? [
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", type: "primary", onClick: () => saveTag(row) },
|
||||
() => "保存",
|
||||
),
|
||||
h(NButton, { size: "small", onClick: cancelEdit }, () => "取消"),
|
||||
]
|
||||
: [
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", onClick: () => startEdit(row) },
|
||||
() => "重命名",
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "error",
|
||||
onClick: () => confirmDelete(row),
|
||||
},
|
||||
() => "删除",
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
async function listTags() {
|
||||
const res = await getTagAdminList(keyword.value)
|
||||
tags.value = res.data
|
||||
}
|
||||
|
||||
function startEdit(tag: AdminTag) {
|
||||
editingId.value = tag.id
|
||||
editingName.value = tag.name
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
editingName.value = ""
|
||||
}
|
||||
|
||||
async function saveTag(tag: AdminTag) {
|
||||
const name = editingName.value.trim()
|
||||
if (!name) {
|
||||
message.error("标签名不能为空")
|
||||
return
|
||||
}
|
||||
if (name === tag.name) {
|
||||
cancelEdit()
|
||||
return
|
||||
}
|
||||
const res = await renameTag(tag.id, name)
|
||||
if (res.data.merged) {
|
||||
message.success(
|
||||
`已合并到「${res.data.name}」,影响 ${res.data.affected_count} 道题`,
|
||||
)
|
||||
} else {
|
||||
message.success("已重命名")
|
||||
}
|
||||
cancelEdit()
|
||||
listTags()
|
||||
}
|
||||
|
||||
function confirmDelete(tag: AdminTag) {
|
||||
dialog.warning({
|
||||
title: "删除标签",
|
||||
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problem_count} 道题在使用它,删除后这些题目会失去该标签。`,
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await deleteTag(tag.id)
|
||||
message.success("已删除")
|
||||
listTags()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(listTags)
|
||||
|
||||
watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" justify="space-between">
|
||||
<n-flex align="center">
|
||||
<h2 class="title">标签管理</h2>
|
||||
<n-button @click="$router.push({ name: 'admin problem list' })">
|
||||
返回题目列表
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
style="width: 200px"
|
||||
placeholder="搜索标签"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="tags" />
|
||||
<TagProblemsModal
|
||||
v-model:show="showTagProblems"
|
||||
:tag-id="activeTag?.id ?? 0"
|
||||
:tag-name="activeTag?.name ?? ''"
|
||||
@changed="listTags"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
105
apps/web/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
apps/web/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
apps/web/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
apps/web/src/admin/problemset/components/BadgeManagement.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import type { 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
apps/web/src/admin/problemset/components/EditBadgeModal.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import type { 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
apps/web/src/admin/problemset/components/EditProblemModal.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import type { 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>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||
import type { 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
apps/web/src/admin/problemset/components/ProblemSetInfo.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { 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>
|
||||
@@ -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 type { 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
apps/web/src/admin/problemset/detail.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
|
||||
import type {
|
||||
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>
|
||||
169
apps/web/src/admin/problemset/edit.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import type { 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,
|
||||
end_time: null,
|
||||
})
|
||||
|
||||
const endTimeTimestamp = computed({
|
||||
get: () =>
|
||||
formData.value.end_time
|
||||
? new Date(formData.value.end_time).getTime()
|
||||
: null,
|
||||
set: (val: number | null) => {
|
||||
formData.value.end_time = val ? new Date(val) : null
|
||||
},
|
||||
})
|
||||
|
||||
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,
|
||||
end_time: data.end_time ? new Date(data.end_time) : null,
|
||||
}
|
||||
} 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 label="截止时间">
|
||||
<n-date-picker
|
||||
v-model:value="endTimeTimestamp"
|
||||
type="datetime"
|
||||
clearable
|
||||
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>
|
||||
212
apps/web/src/admin/problemset/list.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { 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, size: "small" },
|
||||
{ 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, size: "small" },
|
||||
{ default: () => config.text },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 100,
|
||||
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>
|
||||
314
apps/web/src/admin/setting/config.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import {
|
||||
CLASS_NAME_MAX_DIGITS,
|
||||
CLASS_NAME_MIN_DIGITS,
|
||||
CLASS_NAME_RE,
|
||||
} from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Server } from "utils/types"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useConfigWebSocket } from "shared/composables/websocket"
|
||||
import {
|
||||
deleteJudgeServer,
|
||||
editWebsite,
|
||||
getJudgeServer,
|
||||
getWebsite,
|
||||
listInvalidTestcases,
|
||||
pruneInvalidTestcases,
|
||||
} from "../api"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
interface Testcase {
|
||||
id: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
const { updateConfig } = useConfigWebSocket()
|
||||
|
||||
// 确保只有登录用户才能使用WebSocket
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
(isAuthed) => {
|
||||
if (!isAuthed) {
|
||||
// 如果用户未登录,禁用WebSocket功能
|
||||
console.warn("用户未登录,WebSocket配置更新功能已禁用")
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const testcaseColumns: DataTableColumn<Testcase>[] = [
|
||||
{ title: "测试用例 ID", key: "id" },
|
||||
{
|
||||
title: "选项",
|
||||
key: "delete",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", onClick: () => deleteTestcase(row.id) },
|
||||
() => "删除",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const statusMap: {
|
||||
[key in "normal" | "abnormal"]: { color: "primary" | "error"; label: string }
|
||||
} = {
|
||||
normal: { color: "primary", label: "正常" },
|
||||
abnormal: { color: "error", label: "异常" },
|
||||
}
|
||||
|
||||
const serverColumns: DataTableColumn<Server>[] = [
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ type: statusMap[row.status].color, size: "small" },
|
||||
() => statusMap[row.status].label,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "options",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
type: "primary",
|
||||
size: "small",
|
||||
disabled: row.status === "normal",
|
||||
onClick: () => delJudgeServer(row.hostname),
|
||||
},
|
||||
() => "删除",
|
||||
),
|
||||
},
|
||||
{ title: "主机", key: "hostname", width: 140 },
|
||||
{
|
||||
title: "内存占用",
|
||||
key: "memory_usage",
|
||||
render: (row) => row.memory_usage + "%",
|
||||
width: 100,
|
||||
},
|
||||
{ title: "IP", key: "ip", width: 140 },
|
||||
{ title: "判题机版本", key: "judger_version", width: 100 },
|
||||
{ title: "服务器 URL", key: "service_url", width: 200 },
|
||||
{
|
||||
title: "上一次心跳",
|
||||
key: "last_heartbeat",
|
||||
render: (row) => parseTime(row.last_heartbeat, "YYYY-MM-DD HH:mm:ss"),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
width: 120,
|
||||
},
|
||||
]
|
||||
|
||||
const testcases = ref<Testcase[]>([])
|
||||
const token = ref("")
|
||||
const servers = ref<Server[]>([])
|
||||
const abnormalServers = computed(() =>
|
||||
servers.value.filter((item) => item.status === "abnormal"),
|
||||
)
|
||||
|
||||
const websiteConfig = reactive({
|
||||
website_base_url: import.meta.env.PUBLIC_OJ_URL,
|
||||
website_name: "判题狗",
|
||||
website_name_shortcut: "判题狗",
|
||||
website_footer: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
|
||||
allow_register: true,
|
||||
submission_list_show_all: true,
|
||||
class_list: [],
|
||||
enable_maxkb: true,
|
||||
})
|
||||
|
||||
async function getWebsiteConfig() {
|
||||
const res = await getWebsite()
|
||||
websiteConfig.website_base_url = res.data.website_base_url
|
||||
websiteConfig.website_name = res.data.website_name
|
||||
websiteConfig.website_name_shortcut = res.data.website_name_shortcut
|
||||
websiteConfig.website_footer = res.data.website_footer
|
||||
websiteConfig.allow_register = res.data.allow_register
|
||||
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
|
||||
websiteConfig.class_list = res.data.class_list
|
||||
websiteConfig.enable_maxkb = res.data.enable_maxkb
|
||||
}
|
||||
|
||||
async function saveWebsiteConfig() {
|
||||
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
|
||||
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
|
||||
const invalid = websiteConfig.class_list.filter((c) => !CLASS_NAME_RE.test(c))
|
||||
if (invalid.length) {
|
||||
message.error(
|
||||
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await editWebsite(websiteConfig)
|
||||
} catch (err: any) {
|
||||
message.error("保存失败:" + err.data)
|
||||
return
|
||||
}
|
||||
message.success("网站配置保存成功")
|
||||
getWebsiteConfig()
|
||||
configStore.getConfig()
|
||||
|
||||
// 通过 WebSocket 广播配置变化,实现实时切换
|
||||
updateConfig("enable_maxkb", websiteConfig.enable_maxkb)
|
||||
updateConfig(
|
||||
"submission_list_show_all",
|
||||
websiteConfig.submission_list_show_all,
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteTestcase(id?: string) {
|
||||
await pruneInvalidTestcases(id)
|
||||
message.success("删除成功")
|
||||
getTestcases()
|
||||
}
|
||||
|
||||
async function getTestcases() {
|
||||
const res = await listInvalidTestcases()
|
||||
testcases.value = res.data
|
||||
}
|
||||
|
||||
async function getJudgeServerData() {
|
||||
const res = await getJudgeServer()
|
||||
token.value = res.data.token
|
||||
servers.value = res.data.servers
|
||||
}
|
||||
|
||||
async function delJudgeServer(hostname: string) {
|
||||
await deleteJudgeServer(hostname)
|
||||
message.success("删除成功")
|
||||
}
|
||||
|
||||
async function deleteAbnormalServers() {
|
||||
const dels = abnormalServers.value.map((item) =>
|
||||
deleteJudgeServer(item.hostname),
|
||||
)
|
||||
await Promise.all(dels)
|
||||
message.success("删除成功")
|
||||
getJudgeServerData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getWebsiteConfig()
|
||||
getTestcases()
|
||||
getJudgeServerData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card class="box">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
网站设置
|
||||
<n-button type="primary" size="small" @click="saveWebsiteConfig">
|
||||
保存
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="网站 URL">
|
||||
<n-input class="url" v-model:value="websiteConfig.website_base_url" />
|
||||
</n-form-item>
|
||||
<n-form-item label="网站名">
|
||||
<n-input v-model:value="websiteConfig.website_name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="网站简称">
|
||||
<n-input v-model:value="websiteConfig.website_name_shortcut" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form label-placement="left">
|
||||
<n-form-item label="班级列表">
|
||||
<n-flex vertical size="small">
|
||||
<n-dynamic-tags v-model:value="websiteConfig.class_list" />
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
填 {{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
|
||||
位数字,如 251、2510,要和用户名里 ks 后面那段一致
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex align="center">
|
||||
<n-flex align="center">
|
||||
<span>是否允许注册</span>
|
||||
<n-switch v-model:value="websiteConfig.allow_register" />
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span>显示所有提交</span>
|
||||
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span>启用AI小助手</span>
|
||||
<n-switch v-model:value="websiteConfig.enable_maxkb" />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card class="box">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
判题服务器
|
||||
<n-button
|
||||
v-if="abnormalServers.length"
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="deleteAbnormalServers"
|
||||
>
|
||||
删除无效服务器
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<div class="box">
|
||||
接口凭证 <n-tag size="small">{{ token }}</n-tag>
|
||||
</div>
|
||||
<n-data-table
|
||||
:single-line="false"
|
||||
striped
|
||||
:columns="serverColumns"
|
||||
:data="servers"
|
||||
/>
|
||||
</n-card>
|
||||
<n-card class="box" v-if="testcases.length">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
无效的测试用例
|
||||
<n-button size="small" type="warning" @click="() => deleteTestcase()">
|
||||
全部删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-data-table
|
||||
striped
|
||||
class="table"
|
||||
:columns="testcaseColumns"
|
||||
:data="testcases"
|
||||
/>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.url {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.box {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 40%;
|
||||
}
|
||||
</style>
|
||||
244
apps/web/src/admin/setting/home.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { NButton } from "naive-ui"
|
||||
import { getRank } from "oj/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { getACRate } from "utils/functions"
|
||||
import type { Rank } from "utils/types"
|
||||
import { getBaseInfo, randomUser10 } from "../api"
|
||||
|
||||
const userCount = ref(0)
|
||||
const submissionCount = ref(0)
|
||||
const contestCount = ref(0)
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const showModal = ref(false)
|
||||
const luckyGuy = ref("")
|
||||
const isRolling = ref(false)
|
||||
const rollingNames = ref<string[]>([])
|
||||
const pulseKey = ref(0)
|
||||
let rollingTimer: ReturnType<typeof setInterval> | null = null
|
||||
let rollingStopper: ReturnType<typeof setTimeout> | null = null
|
||||
const data = ref<Rank[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
classroom: "",
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<Rank>[] = [
|
||||
{
|
||||
title: "排名",
|
||||
key: "index",
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (_, index) => index + (query.page - 1) * query.limit + 1,
|
||||
},
|
||||
{
|
||||
title: "用户",
|
||||
key: "username",
|
||||
width: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "info",
|
||||
onClick: () => router.push("/user?name=" + row.user.username),
|
||||
},
|
||||
() => row.user.username,
|
||||
),
|
||||
},
|
||||
{ title: "个性签名", key: "mood" },
|
||||
{ title: "已解决", key: "accepted_number", width: 100 },
|
||||
{ title: "提交数", key: "submission_number", width: 100 },
|
||||
{
|
||||
title: "正确率",
|
||||
key: "rate",
|
||||
width: 100,
|
||||
render: (row) => getACRate(row.accepted_number, row.submission_number),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getBaseInfo()
|
||||
userCount.value = res.data.user_count
|
||||
submissionCount.value = res.data.today_submission_count
|
||||
contestCount.value = res.data.recent_contest_count
|
||||
})
|
||||
|
||||
async function listRanks() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getRank(offset, query.limit, 0, query.classroom)
|
||||
data.value = res.data.results
|
||||
total.value = res.data.total
|
||||
}
|
||||
|
||||
function stopRolling() {
|
||||
if (rollingTimer) {
|
||||
clearInterval(rollingTimer)
|
||||
rollingTimer = null
|
||||
}
|
||||
if (rollingStopper) {
|
||||
clearTimeout(rollingStopper)
|
||||
rollingStopper = null
|
||||
}
|
||||
isRolling.value = false
|
||||
}
|
||||
|
||||
function startRolling(finalName: string) {
|
||||
stopRolling()
|
||||
if (!rollingNames.value.length) return
|
||||
isRolling.value = true
|
||||
const interval = 80
|
||||
const duration = 2000
|
||||
let index = 0
|
||||
rollingTimer = setInterval(() => {
|
||||
luckyGuy.value = rollingNames.value[index % rollingNames.value.length]
|
||||
index += 1
|
||||
}, interval)
|
||||
rollingStopper = setTimeout(() => {
|
||||
stopRolling()
|
||||
luckyGuy.value = finalName
|
||||
pulseKey.value += 1
|
||||
}, duration)
|
||||
}
|
||||
|
||||
async function getRandom() {
|
||||
const res = await randomUser10(query.classroom)
|
||||
const names = (res.data as string[]).map(
|
||||
(name) => name.split(query.classroom)[1],
|
||||
)
|
||||
rollingNames.value = names
|
||||
const finalName = names[names.length - 1]
|
||||
startRolling(finalName)
|
||||
}
|
||||
|
||||
async function getRandomModal() {
|
||||
showModal.value = true
|
||||
stopRolling()
|
||||
luckyGuy.value = ""
|
||||
}
|
||||
|
||||
watch(() => query.page, listRanks)
|
||||
watch(
|
||||
() => query.limit,
|
||||
() => {
|
||||
query.page = 1
|
||||
listRanks()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => query.classroom,
|
||||
(v) => {
|
||||
query.page = 1
|
||||
if (!v) {
|
||||
data.value = []
|
||||
total.value = 0
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(showModal, (v) => {
|
||||
if (!v) {
|
||||
stopRolling()
|
||||
luckyGuy.value = ""
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-avatar round :size="60" :src="userStore.profile?.avatar" />
|
||||
<h1 class="name">亲爱的管理员:{{ userStore.user?.username }}</h1>
|
||||
</n-flex>
|
||||
<n-flex>
|
||||
<h2>
|
||||
<n-gradient-text type="info"> 总用户数:{{ userCount }} </n-gradient-text>
|
||||
</h2>
|
||||
<h2>
|
||||
<n-gradient-text type="error">
|
||||
今日提交:{{ submissionCount }}
|
||||
</n-gradient-text>
|
||||
</h2>
|
||||
<h2>
|
||||
<n-gradient-text type="warning">
|
||||
近期比赛:{{ contestCount }}
|
||||
</n-gradient-text>
|
||||
</h2>
|
||||
</n-flex>
|
||||
<n-flex align="center" class="actions">
|
||||
<span>我猜你要:</span>
|
||||
<n-button @click="router.push('/admin/problem/create')">新题目</n-button>
|
||||
<n-button @click="router.push('/admin/contest/create')">新比赛</n-button>
|
||||
<div>
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
clearable
|
||||
v-model:value="query.classroom"
|
||||
placeholder="班级前缀"
|
||||
/>
|
||||
</div>
|
||||
<n-button @click="listRanks">用户排名</n-button>
|
||||
<n-button @click="getRandomModal" v-if="query.classroom">随机抽签</n-button>
|
||||
<Pagination
|
||||
class="pagination"
|
||||
:total="total"
|
||||
v-model:page="query.page"
|
||||
v-model:limit="query.limit"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table v-if="data.length" striped :data="data" :columns="columns" />
|
||||
<n-modal
|
||||
preset="card"
|
||||
title="猜猜看幸运儿是谁?"
|
||||
v-model:show="showModal"
|
||||
style="width: 400px"
|
||||
>
|
||||
<n-flex vertical justify="center" align="center">
|
||||
<n-h1 :key="pulseKey" class="lucky pulse">{{ luckyGuy }}</n-h1>
|
||||
<n-button block :disabled="isRolling" @click="getRandom">
|
||||
{{ luckyGuy ? "再来一次" : "开始抽签" }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.name {
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lucky {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: lucky-pulse 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes lucky-pulse {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
apps/web/src/admin/transforms.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { AdminProblem } from "utils/types"
|
||||
|
||||
// 把后端的 AdminProblem 塑形成管理端列表项,与请求逻辑解耦。
|
||||
export function toProblemListItem(result: AdminProblem) {
|
||||
return {
|
||||
id: result.id,
|
||||
_id: result._id,
|
||||
title: result.title,
|
||||
username: result.created_by.username,
|
||||
create_time: result.create_time,
|
||||
visible: result.visible,
|
||||
difficulty: result.difficulty,
|
||||
tags: result.tags,
|
||||
has_ast_rules: result.has_ast_rules,
|
||||
allow_flowchart: result.allow_flowchart,
|
||||
show_flowchart: result.show_flowchart,
|
||||
// 比赛题目列表接口不返回这个字段
|
||||
top_reaction: result.top_reaction ?? null,
|
||||
}
|
||||
}
|
||||
43
apps/web/src/admin/tutorial/components/Actions.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteTutorial } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
tutorialID: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin tutorial edit",
|
||||
params: { tutorialID: props.tutorialID },
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteTutorial(props.tutorialID)
|
||||
message.success("删除成功")
|
||||
emit("deleted")
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="success" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="handleDelete">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" secondary>删除</n-button>
|
||||
</template>
|
||||
确定删除这个教程吗?
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
652
apps/web/src/admin/tutorial/components/ExerciseManager.vue
Normal file
@@ -0,0 +1,652 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Exercise,
|
||||
ExerciseType,
|
||||
ExerciseMcqData,
|
||||
ExerciseSortData,
|
||||
ExerciseFillData,
|
||||
ExerciseMatchData,
|
||||
ExercisePredictData,
|
||||
ExerciseDebugData,
|
||||
ExerciseGroupData,
|
||||
} from "utils/types"
|
||||
import {
|
||||
getAdminExercises,
|
||||
createExercise,
|
||||
updateExercise,
|
||||
deleteExercise,
|
||||
} from "admin/api"
|
||||
|
||||
const props = defineProps<{ tutorialId: number }>()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const exercises = ref<Exercise[]>([])
|
||||
const showForm = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formType = ref<ExerciseType>("mcq")
|
||||
const formOrder = ref(0)
|
||||
|
||||
const mcqQuestion = ref("")
|
||||
const mcqOptions = ref(["", ""])
|
||||
const mcqAnswer = ref<number[]>([])
|
||||
|
||||
const sortQuestion = ref("")
|
||||
const sortCode = ref("")
|
||||
|
||||
const fillQuestion = ref("")
|
||||
const fillCode = ref("")
|
||||
|
||||
const matchQuestion = ref("")
|
||||
const matchLeft = ref("")
|
||||
const matchRight = ref("")
|
||||
|
||||
const predictQuestion = ref("")
|
||||
const predictCode = ref("")
|
||||
const predictAnswer = ref("")
|
||||
|
||||
const debugQuestion = ref("")
|
||||
const debugCode = ref("")
|
||||
const debugAnswer = ref<number[]>([])
|
||||
const debugExplanation = ref("")
|
||||
|
||||
const groupQuestion = ref("")
|
||||
const groupBuckets = ref("")
|
||||
const groupItems = ref("")
|
||||
|
||||
const debugLines = computed(() =>
|
||||
debugCode.value === "" ? [] : debugCode.value.split("\n"),
|
||||
)
|
||||
|
||||
async function load() {
|
||||
exercises.value = await getAdminExercises(props.tutorialId)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function resetForms() {
|
||||
mcqQuestion.value = ""
|
||||
mcqOptions.value = ["", ""]
|
||||
mcqAnswer.value = []
|
||||
sortQuestion.value = ""
|
||||
sortCode.value = ""
|
||||
fillQuestion.value = ""
|
||||
fillCode.value = ""
|
||||
matchQuestion.value = ""
|
||||
matchLeft.value = ""
|
||||
matchRight.value = ""
|
||||
predictQuestion.value = ""
|
||||
predictCode.value = ""
|
||||
predictAnswer.value = ""
|
||||
debugQuestion.value = ""
|
||||
debugCode.value = ""
|
||||
debugAnswer.value = []
|
||||
debugExplanation.value = ""
|
||||
groupQuestion.value = ""
|
||||
groupBuckets.value = ""
|
||||
groupItems.value = ""
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
formType.value = "mcq"
|
||||
formOrder.value = exercises.value.length
|
||||
resetForms()
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openEdit(ex: Exercise) {
|
||||
editingId.value = ex.id
|
||||
formType.value = ex.type
|
||||
formOrder.value = ex.order
|
||||
resetForms()
|
||||
if (ex.type === "mcq") {
|
||||
const d = ex.data as ExerciseMcqData
|
||||
mcqQuestion.value = d.question
|
||||
mcqOptions.value = [...d.options]
|
||||
mcqAnswer.value = [...d.answer]
|
||||
} else if (ex.type === "sort") {
|
||||
const d = ex.data as ExerciseSortData
|
||||
sortQuestion.value = d.question
|
||||
sortCode.value = d.lines.join("\n")
|
||||
} else if (ex.type === "fill") {
|
||||
const d = ex.data as ExerciseFillData
|
||||
fillQuestion.value = d.question
|
||||
fillCode.value = d.code
|
||||
} else if (ex.type === "match") {
|
||||
const d = ex.data as ExerciseMatchData
|
||||
matchQuestion.value = d.question
|
||||
matchLeft.value = d.left.join("\n")
|
||||
// 按答案顺序还原右列,重存时识别答案保持为顺序对应
|
||||
matchRight.value = d.answer.map((a) => d.right[a]).join("\n")
|
||||
} else if (ex.type === "predict") {
|
||||
const d = ex.data as ExercisePredictData
|
||||
predictQuestion.value = d.question
|
||||
predictCode.value = d.code
|
||||
predictAnswer.value = d.answer.join("\n===\n")
|
||||
} else if (ex.type === "debug") {
|
||||
const d = ex.data as ExerciseDebugData
|
||||
debugQuestion.value = d.question
|
||||
debugCode.value = d.lines.join("\n")
|
||||
debugAnswer.value = [...d.answer]
|
||||
debugExplanation.value = d.explanation ?? ""
|
||||
} else if (ex.type === "group") {
|
||||
const d = ex.data as ExerciseGroupData
|
||||
groupQuestion.value = d.question
|
||||
groupBuckets.value = d.buckets.join("\n")
|
||||
groupItems.value = d.items
|
||||
.map((it, i) => `${it} => ${d.buckets[d.answer[i]]}`)
|
||||
.join("\n")
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function toggleAnswer(i: number) {
|
||||
const idx = mcqAnswer.value.indexOf(i)
|
||||
if (idx === -1) mcqAnswer.value.push(i)
|
||||
else mcqAnswer.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function toggleDebug(i: number) {
|
||||
const idx = debugAnswer.value.indexOf(i)
|
||||
if (idx === -1) debugAnswer.value.push(i)
|
||||
else debugAnswer.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l !== "")
|
||||
}
|
||||
|
||||
function buildData(): Record<string, unknown> | null {
|
||||
if (formType.value === "mcq") {
|
||||
if (mcqAnswer.value.length === 0) {
|
||||
message.error("请至少勾选一个正确答案")
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: mcqQuestion.value || "下面选项中正确是哪个?",
|
||||
options: mcqOptions.value,
|
||||
answer: mcqAnswer.value,
|
||||
}
|
||||
}
|
||||
if (formType.value === "sort") {
|
||||
return {
|
||||
question: sortQuestion.value || "将下列代码行排列为正确顺序",
|
||||
lines: sortCode.value.split("\n").filter((l) => l.trim() !== ""),
|
||||
}
|
||||
}
|
||||
if (formType.value === "fill") {
|
||||
return { question: fillQuestion.value, code: fillCode.value }
|
||||
}
|
||||
if (formType.value === "match") {
|
||||
const left = splitLines(matchLeft.value)
|
||||
const right = splitLines(matchRight.value)
|
||||
if (left.length < 2 || left.length !== right.length) {
|
||||
message.error("左右两列需各至少 2 项且行数相等(按行一一对应)")
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: matchQuestion.value || "把左右两列正确连线",
|
||||
left,
|
||||
right,
|
||||
answer: left.map((_, i) => i),
|
||||
}
|
||||
}
|
||||
if (formType.value === "predict") {
|
||||
if (predictCode.value.trim() === "") {
|
||||
message.error("请填写代码")
|
||||
return null
|
||||
}
|
||||
const answer = predictAnswer.value
|
||||
.split(/\n===\n/)
|
||||
.map((a) => a.replace(/\s+$/, ""))
|
||||
.filter((a) => a.trim() !== "")
|
||||
if (answer.length === 0) {
|
||||
message.error("请填写至少一个正确输出")
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: predictQuestion.value || "这段代码会输出什么?",
|
||||
code: predictCode.value,
|
||||
answer,
|
||||
}
|
||||
}
|
||||
if (formType.value === "debug") {
|
||||
const lines = debugCode.value.split("\n")
|
||||
const answer = debugAnswer.value
|
||||
.filter((i) => i < lines.length)
|
||||
.sort((a, b) => a - b)
|
||||
if (lines.length === 0 || answer.length === 0) {
|
||||
message.error("请填写代码并勾选至少一行错误")
|
||||
return null
|
||||
}
|
||||
const data: Record<string, unknown> = {
|
||||
question: debugQuestion.value || "下面代码哪几行有错?",
|
||||
lines,
|
||||
answer,
|
||||
}
|
||||
if (debugExplanation.value.trim() !== "") {
|
||||
data.explanation = debugExplanation.value.trim()
|
||||
}
|
||||
return data
|
||||
}
|
||||
// group
|
||||
const buckets = splitLines(groupBuckets.value)
|
||||
if (buckets.length < 2) {
|
||||
message.error("请至少填写 2 个分组")
|
||||
return null
|
||||
}
|
||||
const items: string[] = []
|
||||
const answer: number[] = []
|
||||
for (const line of groupItems.value.split("\n")) {
|
||||
if (line.trim() === "") continue
|
||||
const parts = line.split("=>")
|
||||
if (parts.length !== 2) {
|
||||
message.error(`项目格式应为「项目 => 分组名」:${line}`)
|
||||
return null
|
||||
}
|
||||
const item = parts[0].trim()
|
||||
const bucket = buckets.indexOf(parts[1].trim())
|
||||
if (item === "" || bucket === -1) {
|
||||
message.error(`项目或分组名无效:${line}`)
|
||||
return null
|
||||
}
|
||||
items.push(item)
|
||||
answer.push(bucket)
|
||||
}
|
||||
if (items.length === 0) {
|
||||
message.error("请至少填写一个项目")
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: groupQuestion.value || "把下列项目归类到正确的分组",
|
||||
buckets,
|
||||
items,
|
||||
answer,
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const data = buildData()
|
||||
if (data === null) return
|
||||
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateExercise({
|
||||
id: editingId.value,
|
||||
type: formType.value,
|
||||
data,
|
||||
order: formOrder.value,
|
||||
})
|
||||
message.success("练习题已更新")
|
||||
} else {
|
||||
await createExercise({
|
||||
tutorial_id: props.tutorialId,
|
||||
type: formType.value,
|
||||
data,
|
||||
order: formOrder.value,
|
||||
})
|
||||
message.success("练习题已创建")
|
||||
}
|
||||
showForm.value = false
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
message.error(e.data ?? "保存失败")
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
dialog.warning({
|
||||
title: "删除练习题",
|
||||
content: "此操作不可撤销",
|
||||
positiveText: "删除",
|
||||
onPositiveClick: async () => {
|
||||
await deleteExercise(id)
|
||||
message.success("已删除")
|
||||
await load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function copyPlaceholder(id: number) {
|
||||
navigator.clipboard.writeText(`[[exercise:${id}]]`)
|
||||
message.success(`已复制 [[exercise:${id}]]`)
|
||||
}
|
||||
|
||||
const TYPE_NAMES: Record<ExerciseType, string> = {
|
||||
mcq: "选择题",
|
||||
sort: "代码排序",
|
||||
fill: "代码填空",
|
||||
match: "连线匹配",
|
||||
predict: "输出预测",
|
||||
debug: "代码找错",
|
||||
group: "归类分组",
|
||||
}
|
||||
|
||||
const TYPE_TAGS: Record<
|
||||
ExerciseType,
|
||||
"success" | "info" | "warning" | "error" | "primary" | "default"
|
||||
> = {
|
||||
mcq: "success",
|
||||
sort: "info",
|
||||
fill: "warning",
|
||||
match: "primary",
|
||||
predict: "error",
|
||||
debug: "info",
|
||||
group: "warning",
|
||||
}
|
||||
|
||||
function typeName(type: ExerciseType) {
|
||||
return TYPE_NAMES[type] ?? type
|
||||
}
|
||||
|
||||
function typeTagType(type: ExerciseType) {
|
||||
return TYPE_TAGS[type] ?? "default"
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<n-text>共 {{ exercises.length }} 道练习题</n-text>
|
||||
<n-button type="primary" size="small" @click="openCreate"
|
||||
>+ 添加练习题</n-button
|
||||
>
|
||||
</n-flex>
|
||||
|
||||
<n-empty v-if="exercises.length === 0" description="暂无练习题" />
|
||||
|
||||
<n-list v-else bordered>
|
||||
<n-list-item v-for="ex in exercises" :key="ex.id">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<div>
|
||||
<n-tag size="small" :type="typeTagType(ex.type)" :bordered="false">
|
||||
{{ typeName(ex.type) }}
|
||||
</n-tag>
|
||||
<n-text style="margin-left: 10px">
|
||||
{{ (ex.data as any).question }}
|
||||
</n-text>
|
||||
</div>
|
||||
<n-space :size="8">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button size="small" @click="copyPlaceholder(ex.id)">
|
||||
复制占位符
|
||||
</n-button>
|
||||
</template>
|
||||
将 [[exercise:{{ ex.id }}]] 粘贴到 Markdown 内容中
|
||||
</n-tooltip>
|
||||
<n-button size="small" @click="openEdit(ex)">编辑</n-button>
|
||||
<n-button size="small" type="error" @click="confirmDelete(ex.id)">
|
||||
删除
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-flex>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showForm"
|
||||
:title="editingId ? '编辑练习题' : '新建练习题'"
|
||||
preset="card"
|
||||
style="width: 560px"
|
||||
>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="题型">
|
||||
<n-radio-group v-model:value="formType" :disabled="!!editingId">
|
||||
<n-radio value="mcq">选择题</n-radio>
|
||||
<n-radio value="sort">代码排序</n-radio>
|
||||
<n-radio value="fill">代码填空</n-radio>
|
||||
<n-radio value="match">连线匹配</n-radio>
|
||||
<n-radio value="predict">输出预测</n-radio>
|
||||
<n-radio value="debug">代码找错</n-radio>
|
||||
<n-radio value="group">归类分组</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="formOrder"
|
||||
:min="0"
|
||||
style="width: 100px"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<template v-if="formType === 'mcq'">
|
||||
<n-form-item label="题目">
|
||||
<n-input
|
||||
v-model:value="mcqQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="下面选项中正确是哪个?"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="选项(勾选所有正确答案)">
|
||||
<n-space vertical style="width: 100%">
|
||||
<n-flex
|
||||
v-for="(opt, i) in mcqOptions"
|
||||
:key="i"
|
||||
align="center"
|
||||
:size="8"
|
||||
>
|
||||
<n-checkbox
|
||||
:checked="mcqAnswer.includes(i)"
|
||||
@update:checked="toggleAnswer(i)"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="mcqOptions[i]"
|
||||
:placeholder="`选项 ${String.fromCharCode(65 + i)}`"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="mcqOptions.length <= 2"
|
||||
@click="
|
||||
() => {
|
||||
mcqOptions.splice(i, 1)
|
||||
mcqAnswer = mcqAnswer
|
||||
.filter((a) => a !== i)
|
||||
.map((a) => (a > i ? a - 1 : a))
|
||||
}
|
||||
"
|
||||
>
|
||||
✕
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-button size="small" @click="mcqOptions.push('')">
|
||||
+ 添加选项
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'sort'">
|
||||
<n-form-item label="题目">
|
||||
<n-input
|
||||
v-model:value="sortQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="将下列代码行排列为正确顺序"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="正确代码(每行将自动成为一道排序项)">
|
||||
<n-input
|
||||
v-model:value="sortCode"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="在此粘贴正确的代码,保存后将自动按行拆分并乱序"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'fill'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="fillQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:补全下面的循环语句"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="含空位的代码">
|
||||
<n-input
|
||||
v-model:value="fillCode"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="用 {{答案}} 标记空位,多个合法答案用 | 分隔,例如:for {{i|idx}} in range(10):"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'match'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="matchQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:把函数和它的功能连起来"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="左列(每行一项)">
|
||||
<n-input
|
||||
v-model:value="matchLeft"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="print len type"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="右列(与左列按行一一对应,保存后右列自动乱序)">
|
||||
<n-input
|
||||
v-model:value="matchRight"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="输出内容 返回长度 返回类型"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'predict'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="predictQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:这段代码会输出什么?"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="代码">
|
||||
<n-input
|
||||
v-model:value="predictCode"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="print(1 + 2)"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="正确输出(多个可接受答案之间用单独一行 === 分隔)"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="predictAnswer"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="3"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'debug'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="debugQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:下面代码哪几行有错?"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="代码(每行一项)">
|
||||
<n-input
|
||||
v-model:value="debugCode"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="在此粘贴含错误的代码"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="勾选错误行">
|
||||
<n-space vertical style="width: 100%">
|
||||
<n-empty
|
||||
v-if="debugLines.length === 0"
|
||||
description="先填写代码"
|
||||
size="small"
|
||||
/>
|
||||
<n-flex
|
||||
v-for="(line, i) in debugLines"
|
||||
:key="i"
|
||||
align="center"
|
||||
:size="8"
|
||||
>
|
||||
<n-checkbox
|
||||
:checked="debugAnswer.includes(i)"
|
||||
@update:checked="toggleDebug(i)"
|
||||
/>
|
||||
<n-text style="font-family: Monaco; white-space: pre">
|
||||
{{ i + 1 }}. {{ line }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
<n-form-item label="错误说明(可选,提交后展示)">
|
||||
<n-input
|
||||
v-model:value="debugExplanation"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:第 2 行少了冒号"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'group'">
|
||||
<n-form-item label="题目说明">
|
||||
<n-input
|
||||
v-model:value="groupQuestion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例:把下面的值归类到正确的类型"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="分组(每行一个分组名)">
|
||||
<n-input
|
||||
v-model:value="groupBuckets"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="int float str"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="项目(每行「项目 => 分组名」)">
|
||||
<n-input
|
||||
v-model:value="groupItems"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="3 => int 3.14 => float hello => str"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<n-flex justify="end" :size="8">
|
||||
<n-button @click="showForm = false">取消</n-button>
|
||||
<n-button type="primary" @click="save">保存</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
133
apps/web/src/admin/tutorial/detail.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||
import type { Tutorial } from "utils/types"
|
||||
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
||||
import ExerciseManager from "./components/ExerciseManager.vue"
|
||||
|
||||
interface Props {
|
||||
tutorialID?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const tutorial = reactive<Tutorial>({
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
code: "",
|
||||
is_public: false,
|
||||
order: 0,
|
||||
type: "python", // 默认选择 Python
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "Python", value: "python" },
|
||||
{ label: "C 语言", value: "c" },
|
||||
]
|
||||
|
||||
async function init() {
|
||||
if (!props.tutorialID) {
|
||||
return
|
||||
}
|
||||
const id = parseInt(route.params.tutorialID as string)
|
||||
const data = await getTutorial(id)
|
||||
tutorial.id = data.id
|
||||
tutorial.title = data.title
|
||||
tutorial.content = data.content
|
||||
tutorial.code = data.code || ""
|
||||
tutorial.is_public = data.is_public
|
||||
tutorial.order = data.order
|
||||
tutorial.type = data.type || "python"
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!tutorial.title || !tutorial.content) {
|
||||
message.error("标题和正文必填")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (route.name === "admin tutorial create") {
|
||||
await createTutorial({
|
||||
title: tutorial.title,
|
||||
content: tutorial.content,
|
||||
code: tutorial.code,
|
||||
is_public: tutorial.is_public,
|
||||
order: tutorial.order,
|
||||
type: tutorial.type,
|
||||
})
|
||||
message.success("成功新建教程 💐")
|
||||
} else {
|
||||
await updateTutorial(tutorial)
|
||||
message.success("修改已保存")
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
<template>
|
||||
<h2 class="title">
|
||||
{{ route.name === "admin tutorial create" ? "新建教程" : "编辑教程" }}
|
||||
</h2>
|
||||
<n-form inline>
|
||||
<n-form-item label="标题">
|
||||
<n-input class="contestTitle" v-model:value="tutorial.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="语言">
|
||||
<n-select
|
||||
v-model:value="tutorial.type"
|
||||
:options="typeOptions"
|
||||
class="select"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
style="width: 100px"
|
||||
v-model:value="tutorial.order"
|
||||
:min="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="tutorial.is_public" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="content" tab="教程内容">
|
||||
<MarkdownEditor v-model:value="tutorial.content" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="code" tab="示例代码">
|
||||
<CodeEditor
|
||||
v-model:value="tutorial.code"
|
||||
:language="tutorial.type === 'python' ? 'Python3' : 'C'"
|
||||
height="400px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="exercises" tab="练习题" :disabled="!tutorial.id">
|
||||
<ExerciseManager v-if="tutorial.id" :tutorial-id="tutorial.id" />
|
||||
<n-empty v-else description="请先保存教程后再添加练习题" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
<style scoped>
|
||||
.title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.contestTitle {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
107
apps/web/src/admin/tutorial/list.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Tutorial } from "utils/types"
|
||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
const tutorials = ref<{ [key: string]: Tutorial[] }>({
|
||||
python: [],
|
||||
c: [],
|
||||
})
|
||||
const message = useMessage()
|
||||
const activeTab = ref("python")
|
||||
|
||||
const columns: DataTableColumn<Tutorial>[] = [
|
||||
{
|
||||
title: "顺序",
|
||||
key: "order",
|
||||
width: 80,
|
||||
},
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{
|
||||
title: "作者",
|
||||
key: "created_by",
|
||||
render: (row) => row.created_by?.username,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "created_at",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.created_at!, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
key: "updated_at",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.updated_at!, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "is_public",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.is_public,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
render: (row) =>
|
||||
h(Actions, { tutorialID: row.id, onDeleted: listTutorials }),
|
||||
},
|
||||
]
|
||||
|
||||
async function toggleVisible(tutorial: Tutorial) {
|
||||
tutorial.is_public = !tutorial.is_public
|
||||
try {
|
||||
await setTutorialVisibility(tutorial.id, tutorial.is_public)
|
||||
message.success("更新成功")
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
tutorial.is_public = !tutorial.is_public
|
||||
}
|
||||
}
|
||||
|
||||
async function listTutorials() {
|
||||
tutorials.value = await getTutorialList()
|
||||
}
|
||||
|
||||
onMounted(listTutorials)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex align="center" class="titleWrapper">
|
||||
<h2 class="title">教程列表</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin tutorial create' })"
|
||||
>
|
||||
新建
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<n-tab-pane name="python" tab="Python">
|
||||
<n-data-table striped :columns="columns" :data="tutorials.python" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="c" tab="C 语言">
|
||||
<n-data-table striped :columns="columns" :data="tutorials.c" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
55
apps/web/src/admin/user/components/Actions.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts" setup>
|
||||
import { editUser } from "admin/api"
|
||||
import type { User } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "deleteUser", value: number[]): void
|
||||
(e: "userBanned", value: User): void
|
||||
(e: "openEditModal", value: User): void
|
||||
(e: "resetPassword", value: User): void
|
||||
}>()
|
||||
|
||||
async function banUser() {
|
||||
props.user.is_disabled = !props.user.is_disabled
|
||||
await editUser(props.user)
|
||||
emit("userBanned", props.user)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
secondary
|
||||
@click="$emit('resetPassword', props.user)"
|
||||
>
|
||||
重置密码
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="$emit('openEditModal', props.user)"
|
||||
>
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
:type="props.user.is_disabled ? 'info' : 'error'"
|
||||
@click="banUser"
|
||||
>
|
||||
{{ props.user.is_disabled ? "解封" : "封号" }}
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="$emit('deleteUser', [props.user.id])">
|
||||
<template #trigger>
|
||||
<n-button size="small" secondary type="warning">删除</n-button>
|
||||
</template>
|
||||
确定删除这个用户吗?删除后无法恢复!
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
</template>
|
||||
42
apps/web/src/admin/user/components/Name.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { getUserRole } from "utils/functions"
|
||||
import type { User } from "utils/types"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const isNotRegularUser = computed(
|
||||
() => props.user.admin_type !== USER_TYPE.REGULAR_USER,
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-tag v-if="props.user.is_disabled" type="error" size="small">
|
||||
封号中
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-if="isNotRegularUser"
|
||||
:type="getUserRole(props.user.admin_type).type"
|
||||
size="small"
|
||||
>
|
||||
{{ getUserRole(props.user.admin_type).label }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
size="small"
|
||||
v-if="
|
||||
props.user.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
props.user.admin_type === USER_TYPE.TEACHER_ADMIN
|
||||
"
|
||||
>
|
||||
{{
|
||||
props.user.problem_permission === PROBLEM_PERMISSION.ALL
|
||||
? "全部"
|
||||
: "仅自己"
|
||||
}}
|
||||
</n-tag>
|
||||
<TextCopy>{{ props.user.username }}</TextCopy>
|
||||
</n-flex>
|
||||
</template>
|
||||
107
apps/web/src/admin/user/generate.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CLASS_NAME_MAX_DIGITS,
|
||||
CLASS_NAME_MAX_VALUE,
|
||||
CLASS_NAME_MIN_DIGITS,
|
||||
CLASS_NAME_MIN_VALUE,
|
||||
} from "utils/constants"
|
||||
import { importUsers } from "../api"
|
||||
|
||||
const message = useMessage()
|
||||
const prefix = ref(0)
|
||||
const rawInput = ref("")
|
||||
const [loading, toggleLoading] = useToggle()
|
||||
const users = shallowRef<string[][]>([])
|
||||
|
||||
function generateUsers() {
|
||||
if (!rawInput.value || !rawInput.value.trim()) {
|
||||
message.info("请填写相关内容")
|
||||
return false
|
||||
}
|
||||
// 后端 get_class_name 只认合法位数的班级号,位数不对会整批拒绝导入,
|
||||
// 这里先拦一道,省得填完一屏用户名才被打回来
|
||||
if (
|
||||
prefix.value &&
|
||||
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE)
|
||||
) {
|
||||
message.error(
|
||||
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
let className = !!prefix.value ? `ks${prefix.value}` : ""
|
||||
rawInput.value = rawInput.value.trim()
|
||||
const inputs = rawInput.value.split("\n")
|
||||
users.value = inputs.map((u, i) => {
|
||||
const username = className + u
|
||||
let password = ""
|
||||
for (let j = 0; j < 6; j++) {
|
||||
password += "123456789".charAt(Math.floor(Math.random() * 9))
|
||||
}
|
||||
const realName = u
|
||||
const email = `${className}.${i + 1}@example.com`
|
||||
return [username, password, email, realName]
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async function uploadUsers() {
|
||||
try {
|
||||
toggleLoading(true)
|
||||
await importUsers(users.value)
|
||||
message.success("用户已上传成功")
|
||||
const csv = users.value.map((u) => u.join(",")).join("\n")
|
||||
const hiddenElement = document.createElement("a")
|
||||
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
|
||||
hiddenElement.target = "_blank"
|
||||
hiddenElement.download = prefix.value + ".csv"
|
||||
hiddenElement.click()
|
||||
hiddenElement.remove()
|
||||
} catch (err: any) {
|
||||
message.error("上传失败:" + err.data)
|
||||
} finally {
|
||||
toggleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const ok = generateUsers()
|
||||
if (ok) {
|
||||
uploadUsers()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-space>
|
||||
<n-flex vertical>
|
||||
<n-flex align="center">
|
||||
<div style="width: 18px; font-size: 1.2rem">ks</div>
|
||||
<n-input-number
|
||||
style="width: 170px"
|
||||
v-model:value="prefix"
|
||||
clearable
|
||||
:max="CLASS_NAME_MAX_VALUE"
|
||||
:min="0"
|
||||
:placeholder="`班级号(${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-input
|
||||
type="textarea"
|
||||
class="inputArea"
|
||||
placeholder="每行一个用户名"
|
||||
v-model:value="rawInput"
|
||||
/>
|
||||
<n-button type="warning" :loading="loading" @click="submit">
|
||||
确定导入
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inputArea {
|
||||
width: 200px;
|
||||
height: 500px;
|
||||
}
|
||||
</style>
|
||||
359
apps/web/src/admin/user/list.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { DataTableRowKey, SelectOption } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { User } from "utils/types"
|
||||
import {
|
||||
deleteUsers,
|
||||
editUser,
|
||||
getUserList,
|
||||
importUsers,
|
||||
resetPassword,
|
||||
} from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Name from "./components/Name.vue"
|
||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import TextCopy from "shared/components/TextCopy.vue"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
interface UserQuery {
|
||||
keyword: string
|
||||
type: string
|
||||
orderBy: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<UserQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
type: useRouteQuery("type", "").value,
|
||||
orderBy: useRouteQuery("orderBy", "").value,
|
||||
})
|
||||
|
||||
const total = ref(0)
|
||||
const users = ref<User[]>([])
|
||||
const userEditing = ref<User | null>(null)
|
||||
|
||||
const adminOptions = [
|
||||
{ label: "全部用户", value: "" },
|
||||
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
|
||||
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "默认排序", value: "" },
|
||||
{ label: "最近登录", value: "-last_login" },
|
||||
]
|
||||
const [create, toggleCreate] = useToggle(false)
|
||||
const password = ref("")
|
||||
const userIDs = ref<DataTableRowKey[]>([])
|
||||
|
||||
const rowKey = (row: User) => row.id
|
||||
|
||||
const columns: DataTableColumn<User>[] = [
|
||||
{ type: "selection" },
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 220,
|
||||
render: (row) => h(Name, { user: row }),
|
||||
},
|
||||
{
|
||||
title: "密码",
|
||||
key: "raw_password",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.raw_password),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "上次登录",
|
||||
key: "last_login",
|
||||
width: 200,
|
||||
render: (row) =>
|
||||
row.last_login
|
||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||
: "从未登录",
|
||||
},
|
||||
{
|
||||
title: "真名",
|
||||
key: "real_name",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.real_name),
|
||||
},
|
||||
{ title: "邮箱", key: "email", width: 200 },
|
||||
{
|
||||
key: "actions",
|
||||
title: "选项",
|
||||
width: 280,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
user: row,
|
||||
onDeleteUser: onDeleteUsers,
|
||||
onUserBanned,
|
||||
onOpenEditModal,
|
||||
onResetPassword,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "普通", value: USER_TYPE.REGULAR_USER },
|
||||
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
|
||||
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
const problemPermissionOptions: SelectOption[] = [
|
||||
{ label: "无权限", value: PROBLEM_PERMISSION.NONE },
|
||||
{ label: "仅管理自己创建", value: PROBLEM_PERMISSION.OWN },
|
||||
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
|
||||
]
|
||||
|
||||
async function listUsers() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getUserList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.type,
|
||||
query.keyword,
|
||||
query.orderBy,
|
||||
)
|
||||
total.value = res.data.total
|
||||
users.value = res.data.results
|
||||
}
|
||||
|
||||
function chooseUsers(rowKeys: DataTableRowKey[]) {
|
||||
userIDs.value = rowKeys
|
||||
}
|
||||
|
||||
async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
|
||||
await deleteUsers(toRaw(userIDs) as number[])
|
||||
listUsers()
|
||||
}
|
||||
|
||||
async function onResetPassword(user: User) {
|
||||
const res = await resetPassword(user.id)
|
||||
message.success(`【${user.username}】的密码已重置成【${res.data}】`)
|
||||
users.value = users.value.map((it) => {
|
||||
if (it.id === user.id && user.admin_type === USER_TYPE.REGULAR_USER) {
|
||||
it.raw_password = res.data
|
||||
}
|
||||
return it
|
||||
})
|
||||
}
|
||||
|
||||
async function onUserBanned(user: User) {
|
||||
users.value = users.value.map((it) => {
|
||||
if (it.id === user.id) {
|
||||
it.is_disabled = user.is_disabled
|
||||
}
|
||||
return it
|
||||
})
|
||||
}
|
||||
|
||||
function createNewUser() {
|
||||
toggleCreate(true)
|
||||
userEditing.value = {
|
||||
id: 0,
|
||||
username: "",
|
||||
real_name: "",
|
||||
email: "",
|
||||
admin_type: "Student Admin",
|
||||
problem_permission: "None",
|
||||
create_time: new Date(),
|
||||
last_login: new Date(),
|
||||
open_api: false,
|
||||
is_disabled: false,
|
||||
password: "",
|
||||
}
|
||||
password.value = ""
|
||||
}
|
||||
|
||||
function onOpenEditModal(user: User) {
|
||||
userEditing.value = user
|
||||
password.value = ""
|
||||
}
|
||||
|
||||
function onCloseEditModal() {
|
||||
userEditing.value = null
|
||||
password.value = ""
|
||||
toggleCreate(false)
|
||||
}
|
||||
|
||||
async function handleEditUser() {
|
||||
if (!userEditing.value) return
|
||||
if (password.value && password.value.length < 6) {
|
||||
message.error("密码长度不得小于 6")
|
||||
return
|
||||
}
|
||||
// http 拦截器只对 login-required / permission-denied 自动弹提示,
|
||||
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
|
||||
try {
|
||||
if (create.value) {
|
||||
const newUser = [
|
||||
[
|
||||
userEditing.value.username,
|
||||
password.value,
|
||||
userEditing.value.email,
|
||||
userEditing.value.real_name,
|
||||
],
|
||||
]
|
||||
await importUsers(newUser)
|
||||
listUsers()
|
||||
} else {
|
||||
const user = Object.assign(userEditing.value, {
|
||||
password: password.value,
|
||||
})
|
||||
await editUser(user)
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error("保存失败:" + err.data)
|
||||
return
|
||||
}
|
||||
userEditing.value = null
|
||||
password.value = ""
|
||||
toggleCreate(false)
|
||||
}
|
||||
|
||||
onMounted(listUsers)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" justify="space-between">
|
||||
<n-flex>
|
||||
<h2 class="title">用户列表</h2>
|
||||
<n-button type="primary" @click="createNewUser">新建</n-button>
|
||||
<n-button @click="$router.push({ name: 'admin user generate' })">
|
||||
导入
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex>
|
||||
<n-popconfirm
|
||||
v-if="userIDs.length"
|
||||
@positive-click="onDeleteUsers(userIDs)"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-button type="warning">删除</n-button>
|
||||
</template>
|
||||
确定删除选中的用户吗?删除后无法恢复!
|
||||
</n-popconfirm>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="query.orderBy"
|
||||
:options="sortOptions"
|
||||
placeholder="排序方式"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.type"
|
||||
:options="adminOptions"
|
||||
placeholder="选择用户类型"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<div>
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
v-model:value="query.keyword"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
:data="users"
|
||||
:columns="columns"
|
||||
striped
|
||||
:row-key="rowKey"
|
||||
@update:checked-row-keys="chooseUsers"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<n-modal
|
||||
:mask-closable="false"
|
||||
:show="!!userEditing"
|
||||
preset="card"
|
||||
:title="create ? '新建用户' : '编辑用户'"
|
||||
style="width: 700px"
|
||||
@close="onCloseEditModal"
|
||||
>
|
||||
<n-form label-placement="left" v-if="userEditing">
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-form-item-gi :span="1" label="用户">
|
||||
<n-input v-model:value="userEditing.username" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="1" label="真名">
|
||||
<n-input v-model:value="userEditing.real_name" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi v-if="!create" :span="1" label="班级">
|
||||
<n-input v-model:value="userEditing.class_name" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="1" label="邮箱">
|
||||
<n-input v-model:value="userEditing.email" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi v-if="!create" :span="1" label="类型">
|
||||
<n-select v-model:value="userEditing.admin_type" :options="options" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
:span="1"
|
||||
label="密码"
|
||||
label-style="color: red; font-weight: bold"
|
||||
>
|
||||
<n-input v-model:value="password" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="
|
||||
!create &&
|
||||
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)
|
||||
"
|
||||
:span="1"
|
||||
label="出题权限"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="userEditing.problem_permission"
|
||||
:options="problemPermissionOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
|
||||
<n-form-item-gi v-if="!create" :span="1" label="是否封禁">
|
||||
<n-switch v-model:value="userEditing.is_disabled">封号</n-switch>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="onCloseEditModal">取消</n-button>
|
||||
<n-button type="primary" @click="handleEditUser">保存</n-button>
|
||||
</n-flex>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
16
apps/web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_ENV: string
|
||||
readonly PUBLIC_MAXKB_URL: string
|
||||
readonly PUBLIC_OJ_URL: string
|
||||
readonly PUBLIC_CODE_URL: string
|
||||
readonly PUBLIC_JUDGE0_URL: string
|
||||
readonly PUBLIC_ICONIFY_URL: string
|
||||
readonly PUBLIC_SIGNALING_URL: string
|
||||
readonly PUBLIC_WS_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
29
apps/web/src/index.css
Normal file
@@ -0,0 +1,29 @@
|
||||
body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.md-editor-dark {
|
||||
--md-bk-color: var(--n-body-color) !important;
|
||||
}
|
||||
|
||||
.md-editor-dark div.vuepress-theme {
|
||||
--md-theme-color: var(--n-text-color) !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-surface {
|
||||
box-sizing: border-box;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.oj-mermaid-surface > svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
92
apps/web/src/main.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { addAPIProvider } from "@iconify/vue"
|
||||
import { createPinia } from "pinia"
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
|
||||
import App from "./App.vue"
|
||||
import { admins, ojs } from "./routes"
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [ojs, admins],
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
// 创建 app 并安装插件
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// 现在可以安全地使用 Store
|
||||
import { useAuthModalStore } from "./shared/store/authModal"
|
||||
import { useUserStore } from "./shared/store/user"
|
||||
|
||||
const authStore = useAuthModalStore()
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 检查是否需要认证
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
authStore.openLoginModal()
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (
|
||||
to.matched.some(
|
||||
(record) =>
|
||||
record.meta.requiresSuperAdmin ||
|
||||
record.meta.requiresTeacherAdmin ||
|
||||
record.meta.requiresProblemPermission,
|
||||
)
|
||||
) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
authStore.openLoginModal()
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.user) {
|
||||
try {
|
||||
await userStore.getMyProfile()
|
||||
} catch (error) {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
}
|
||||
if (to.matched.some((record) => record.meta.requiresSuperAdmin)) {
|
||||
if (!userStore.isSuperAdmin) {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
} else if (to.matched.some((record) => record.meta.requiresTeacherAdmin)) {
|
||||
if (!userStore.isTeacherOrAbove) {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
} else if (
|
||||
to.matched.some((record) => record.meta.requiresProblemPermission)
|
||||
) {
|
||||
if (!userStore.hasProblemPermission) {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
app.mount("#app")
|
||||
|
||||
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||
addAPIProvider("", {
|
||||
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
|
||||
})
|
||||
}
|
||||
1
apps/web/src/mermaid-legacy.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "mermaid-legacy"
|
||||
27
apps/web/src/oj/achievement/api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import http from "utils/http"
|
||||
import type {
|
||||
Achievement,
|
||||
AchievementSummary,
|
||||
PendingAchievement,
|
||||
} from "utils/types"
|
||||
|
||||
export function getAchievements(name?: string) {
|
||||
return http.get<{ username: string; achievements: Achievement[] }>(
|
||||
"achievements",
|
||||
{ params: name ? { name } : {} },
|
||||
)
|
||||
}
|
||||
|
||||
export function getAchievementSummary(name?: string) {
|
||||
return http.get<AchievementSummary>("achievements/summary", {
|
||||
params: name ? { name } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getPendingAchievements() {
|
||||
return http.get<PendingAchievement[]>("achievements/pending")
|
||||
}
|
||||
|
||||
export function markAchievementsRead(ids: number[]) {
|
||||
return http.post("achievements/pending", { ids })
|
||||
}
|
||||
134
apps/web/src/oj/achievement/components/AchievementCard.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
|
||||
import type { Achievement } from "utils/types"
|
||||
|
||||
const props = defineProps<{ achievement: Achievement }>()
|
||||
|
||||
// 边框用原色,tag 里的文字用跟主题走的那套
|
||||
const rarityTextColor = useRarityColor()
|
||||
|
||||
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null,
|
||||
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
|
||||
const masked = computed(
|
||||
() => props.achievement.hidden && !props.achievement.unlocked,
|
||||
)
|
||||
|
||||
// 获得率低于 5% 的加稀有闪光边框
|
||||
const isRare = computed(
|
||||
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
|
||||
)
|
||||
|
||||
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
|
||||
// 画成百分比毫无意义,改成直接显示当前最好成绩
|
||||
const showProgressBar = computed(
|
||||
() =>
|
||||
!masked.value &&
|
||||
!props.achievement.unlocked &&
|
||||
props.achievement.operator === "gte" &&
|
||||
props.achievement.threshold !== null,
|
||||
)
|
||||
|
||||
const showBestSoFar = computed(
|
||||
() =>
|
||||
!masked.value &&
|
||||
!props.achievement.unlocked &&
|
||||
props.achievement.operator === "lte" &&
|
||||
props.achievement.threshold !== null,
|
||||
)
|
||||
|
||||
const percent = computed(() => {
|
||||
const { progress, threshold } = props.achievement
|
||||
if (threshold === null || threshold <= 0) return 100
|
||||
return Math.min(100, Math.round(((progress ?? 0) / threshold) * 100))
|
||||
})
|
||||
|
||||
const unlockDate = computed(() => {
|
||||
const { unlock_time, backfilled } = props.achievement
|
||||
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
|
||||
if (backfilled || !unlock_time) return "已获得"
|
||||
return `${new Date(unlock_time).toLocaleDateString()} 获得`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
size="small"
|
||||
:class="{ locked: !achievement.unlocked, rare: isRare }"
|
||||
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
|
||||
>
|
||||
<n-thing>
|
||||
<template #avatar>
|
||||
<AchievementIcon :icon="achievement.icon" :size="32" />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<n-text strong>{{ achievement.name }}</n-text>
|
||||
<n-tag
|
||||
size="tiny"
|
||||
:color="{
|
||||
borderColor: RARITY_COLOR[achievement.rarity],
|
||||
textColor: rarityTextColor[achievement.rarity],
|
||||
}"
|
||||
>
|
||||
{{ RARITY_LABEL[achievement.rarity] }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
<n-text depth="3">{{ achievement.description }}</n-text>
|
||||
</template>
|
||||
|
||||
<n-flex align="center" :size="8" :wrap="false">
|
||||
<template v-if="achievement.unlocked">
|
||||
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
|
||||
<n-text depth="3" class="nowrap">
|
||||
仅 {{ achievement.unlock_rate }}% 的人获得
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showProgressBar">
|
||||
<n-progress
|
||||
style="flex: 1"
|
||||
type="line"
|
||||
:percentage="percent"
|
||||
:height="6"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<n-text depth="3" class="nowrap">
|
||||
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showBestSoFar">
|
||||
<n-text depth="3" class="nowrap">
|
||||
目标 ≤ {{ achievement.threshold }}
|
||||
</n-text>
|
||||
<n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
|
||||
当前最好 {{ achievement.progress }}
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<n-text v-else depth="3" class="nowrap">
|
||||
仅 {{ achievement.unlock_rate }}% 的人获得
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-thing>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.locked {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rare {
|
||||
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
|
||||
}
|
||||
</style>
|
||||
227
apps/web/src/oj/achievement/index.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
|
||||
import { getUserBadges } from "oj/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import type {
|
||||
Achievement,
|
||||
AchievementRarity,
|
||||
AchievementSummary,
|
||||
} from "utils/types"
|
||||
import AchievementCard from "./components/AchievementCard.vue"
|
||||
|
||||
interface UserBadge {
|
||||
id: number
|
||||
earned_time: string
|
||||
badge: {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
|
||||
problemset: {
|
||||
id: number
|
||||
title: string
|
||||
} | null
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const name = computed(() => (route.query.name as string) || undefined)
|
||||
|
||||
// 标签和进度条同色,整行读作一个单位
|
||||
const rarityColor = useRarityColor()
|
||||
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const summary = ref<AchievementSummary | null>(null)
|
||||
|
||||
// 白金排最前,青铜垫底:稀有的先亮相,接口给的顺序是反的
|
||||
const RARITY_RANK: Record<AchievementRarity, number> = {
|
||||
platinum: 0,
|
||||
gold: 1,
|
||||
silver: 2,
|
||||
bronze: 3,
|
||||
}
|
||||
|
||||
const rarities = computed(() =>
|
||||
[...(summary.value?.rarity ?? [])].sort(
|
||||
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
|
||||
),
|
||||
)
|
||||
const badges = ref<UserBadge[]>([])
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const tab = ref("all")
|
||||
const loading = ref(true)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (tab.value === "unlocked")
|
||||
return achievements.value.filter((a) => a.unlocked)
|
||||
if (tab.value === "locked")
|
||||
return achievements.value.filter((a) => !a.unlocked)
|
||||
return achievements.value
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [list, sum, badgeRes] = await Promise.all([
|
||||
getAchievements(name.value),
|
||||
getAchievementSummary(name.value),
|
||||
getUserBadges(name.value),
|
||||
])
|
||||
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
|
||||
achievements.value = list.data.achievements
|
||||
summary.value = sum.data
|
||||
badges.value = (badgeRes.data ?? []) as UserBadge[]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(name, load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hall">
|
||||
<!-- delay 50ms:缓存命中时数据几乎立刻回来,不闪一下转圈 -->
|
||||
<n-spin :show="loading" :delay="50" style="min-height: 240px">
|
||||
<n-card v-if="summary">
|
||||
<n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
|
||||
<n-flex vertical align="center" :size="6">
|
||||
<n-progress
|
||||
type="circle"
|
||||
:percentage="summary.percent"
|
||||
:stroke-width="8"
|
||||
>
|
||||
<n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
|
||||
{{ summary.percent }}%
|
||||
</n-text>
|
||||
</n-progress>
|
||||
<n-text depth="3" class="nowrap">
|
||||
已获得 {{ summary.unlocked }} / {{ summary.total }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
<n-flex vertical :size="8" class="rarity">
|
||||
<n-flex
|
||||
v-for="r in rarities"
|
||||
:key="r.rarity"
|
||||
align="center"
|
||||
:wrap="false"
|
||||
:size="10"
|
||||
>
|
||||
<n-text strong :style="{ color: rarityColor[r.rarity] }">
|
||||
{{ r.label }}
|
||||
</n-text>
|
||||
<n-progress
|
||||
style="flex: 1"
|
||||
type="line"
|
||||
:percentage="r.total ? (r.unlocked / r.total) * 100 : 0"
|
||||
:height="6"
|
||||
:border-radius="3"
|
||||
:fill-border-radius="3"
|
||||
:color="rarityColor[r.rarity]"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<n-text depth="3" class="nowrap">
|
||||
{{ r.unlocked }} / {{ r.total }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
<n-tabs v-model:value="tab" type="line" class="tabs">
|
||||
<n-tab name="all">全部</n-tab>
|
||||
<n-tab name="unlocked">已获得</n-tab>
|
||||
<n-tab name="locked">未获得</n-tab>
|
||||
<n-tab name="badges">题单奖章</n-tab>
|
||||
</n-tabs>
|
||||
|
||||
<template v-if="tab !== 'badges'">
|
||||
<n-grid
|
||||
v-if="filtered.length"
|
||||
responsive="screen"
|
||||
cols="1 s:2 l:3"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
>
|
||||
<n-gi v-for="a in filtered" :key="a.id">
|
||||
<AchievementCard :achievement="a" />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<!-- 加载中不显示空态,不然首屏会闪一下"什么都没有" -->
|
||||
<n-empty v-else-if="!loading" description="这里还什么都没有" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<n-grid
|
||||
v-if="badges.length"
|
||||
responsive="screen"
|
||||
cols="1 s:2 l:3"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
>
|
||||
<n-gi v-for="b in badges" :key="b.id">
|
||||
<n-card size="small">
|
||||
<n-thing
|
||||
:title="b.badge?.name"
|
||||
:description="b.badge?.description"
|
||||
>
|
||||
<template #avatar v-if="b.badge?.icon">
|
||||
<n-avatar
|
||||
:size="40"
|
||||
:src="b.badge.icon"
|
||||
color="transparent"
|
||||
object-fit="contain"
|
||||
/>
|
||||
</template>
|
||||
<n-text v-if="b.problemset" depth="3" class="source">
|
||||
来自题单
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'problemset',
|
||||
params: { problemSetId: b.problemset.id },
|
||||
}"
|
||||
>
|
||||
{{ b.problemset.title }}
|
||||
</router-link>
|
||||
</n-text>
|
||||
</n-thing>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hall {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.rarity {
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.source {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.source a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
122
apps/web/src/oj/ai/analysis.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<n-spin :show="aiStore.loading.fetching" :delay="50">
|
||||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
||||
<n-gi :span="1">
|
||||
<n-flex vertical size="large">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-h3 style="margin: 0">请选择时间范围,智能分析学习情况</n-h3>
|
||||
<n-flex align="center">
|
||||
<n-input
|
||||
v-if="userStore.isSuperAdmin"
|
||||
v-model:value="urlUsername"
|
||||
placeholder="查看指定用户"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="onUsernameChange"
|
||||
@clear="onUsernameChange"
|
||||
/>
|
||||
<n-select
|
||||
style="width: 140px"
|
||||
:options="options"
|
||||
v-model:value="urlDuration"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<Overview />
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20">
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<DifficultyGradeChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<TagsRadarChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<RankDistributionChart />
|
||||
</n-gi>
|
||||
<n-gi :span="isDesktop ? 1 : 2">
|
||||
<TimeActivityHeatmap />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<SolvedTable />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="1">
|
||||
<n-flex vertical size="large">
|
||||
<Heatmap />
|
||||
<ProgressChart />
|
||||
<EfficiencyChart />
|
||||
<DurationChart />
|
||||
<AI v-if="aiStore.detailsData.solved.length > 10" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="2">
|
||||
<AI
|
||||
v-if="
|
||||
aiStore.detailsData.solved.length > 0 &&
|
||||
aiStore.detailsData.solved.length <= 10
|
||||
"
|
||||
/>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import TagsRadarChart from "./components/TagsRadarChart.vue"
|
||||
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
|
||||
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
|
||||
import RankDistributionChart from "./components/RankDistributionChart.vue"
|
||||
import Overview from "./components/Overview.vue"
|
||||
import Heatmap from "./components/Heatmap.vue"
|
||||
import ProgressChart from "./components/ProgressChart.vue"
|
||||
import DurationChart from "./components/DurationChart.vue"
|
||||
import EfficiencyChart from "./components/EfficiencyChart.vue"
|
||||
import AI from "./components/AI.vue"
|
||||
import SolvedTable from "./components/SolvedTable.vue"
|
||||
import { useAIStore } from "../store/ai"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { DURATION_OPTIONS } from "utils/constants"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
const userStore = useUserStore()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const options = [...DURATION_OPTIONS]
|
||||
|
||||
const urlUsername = useRouteQuery<string>("username", "")
|
||||
const urlDuration = useRouteQuery<string>("duration", "months:6")
|
||||
|
||||
// Initialize store synchronously from URL params before watch fires
|
||||
aiStore.targetUsername = urlUsername.value
|
||||
aiStore.duration = urlDuration.value
|
||||
|
||||
const subOptions = computed<Duration>(() => {
|
||||
let dur = options.find((it) => it.value === aiStore.duration) ?? options[0]
|
||||
const x = dur.value!.toString().split(":")
|
||||
return { [x[0]]: parseInt(x[1]) } as Duration
|
||||
})
|
||||
|
||||
const start = computed(() => formatISO(sub(new Date(), subOptions.value)))
|
||||
const end = computed(() => formatISO(new Date()))
|
||||
|
||||
function onUsernameChange() {
|
||||
aiStore.targetUsername = urlUsername.value
|
||||
aiStore.fetchHeatmapData()
|
||||
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
aiStore.fetchHeatmapData()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => urlDuration.value,
|
||||
(val) => {
|
||||
aiStore.duration = val
|
||||
aiStore.fetchAnalysisData(start.value, end.value, val)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
91
apps/web/src/oj/ai/components/AI.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<n-card size="small">
|
||||
<template #header>
|
||||
<div class="cool-title">
|
||||
<span class="title-text">AI 帮你分析</span>
|
||||
</div>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.ai" :delay="50">
|
||||
<n-flex align="center" justify="center" class="container">
|
||||
<n-button
|
||||
v-if="!aiStore.mdContent && !aiStore.loading.ai"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="aiStore.loading.fetching"
|
||||
@click="handleAnalyze"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="ph:sparkle" />
|
||||
</template>
|
||||
开始分析
|
||||
</n-button>
|
||||
<MdPreview v-else :model-value="aiStore.mdContent" />
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (aiStore.loading.fetching || aiStore.loading.ai) {
|
||||
return
|
||||
}
|
||||
if (aiStore.pinnedReport) {
|
||||
await aiStore.simulatePinnedStream()
|
||||
} else {
|
||||
await aiStore.fetchAIAnalysis()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!aiStore.targetUsername) {
|
||||
await aiStore.fetchPinnedReport()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.cool-title {
|
||||
position: relative;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2, #f093fb);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.8px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: gradient-flow 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-flow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
:deep(.md-editor-preview h1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
144
apps/web/src/oj/ai/components/DifficultyGradeChart.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<n-card title="难度掌握情况" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
了解不同难度题目的完成等级分布
|
||||
</n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Bar :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import type { Grade } from "utils/types"
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
// 难度和等级的顺序(后端返回的是中文)
|
||||
const difficultyOrder = ["简单", "中等", "困难"]
|
||||
const gradeOrder: Grade[] = ["S", "A", "B", "C"]
|
||||
|
||||
// 统计每个难度-等级组合的题目数量
|
||||
const matrix = computed(() => {
|
||||
const result: { [difficulty: string]: { [grade: string]: number } } = {}
|
||||
|
||||
// 初始化矩阵
|
||||
difficultyOrder.forEach((diff) => {
|
||||
result[diff] = {}
|
||||
gradeOrder.forEach((grade) => {
|
||||
result[diff][grade] = 0
|
||||
})
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
aiStore.detailsData.solved.forEach((item) => {
|
||||
const diff = item.difficulty
|
||||
const grade = item.grade
|
||||
if (diff && grade && result[diff]) {
|
||||
result[diff][grade]++
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const show = computed(() => {
|
||||
return aiStore.detailsData.solved.length > 0
|
||||
})
|
||||
|
||||
// 为每个等级准备数据集
|
||||
const data = computed(() => {
|
||||
// 为每个等级生成一个 dataset
|
||||
const datasets = gradeOrder.map((grade) => {
|
||||
return {
|
||||
label: `等级 ${grade}`,
|
||||
data: difficultyOrder.map((diff) => matrix.value[diff][grade]),
|
||||
backgroundColor: getGradeColor(grade),
|
||||
borderColor: getGradeColor(grade),
|
||||
borderWidth: 1,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels: difficultyOrder,
|
||||
datasets,
|
||||
}
|
||||
})
|
||||
|
||||
// 根据等级返回对应的颜色
|
||||
function getGradeColor(grade: Grade): string {
|
||||
const colors: { [key in Grade]: string } = {
|
||||
S: "#FF6384",
|
||||
A: "#FFCE56",
|
||||
B: "#36A2EB",
|
||||
C: "#95F204",
|
||||
}
|
||||
return colors[grade]
|
||||
}
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index" as const,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "题目数量",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer: (items: any[]) => {
|
||||
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
|
||||
return `该难度总计: ${total} 题`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
204
apps/web/src/oj/ai/components/DurationChart.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="bar" :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
import { Chart } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
LineController,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
// 注册混合图表(Bar + Line)所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
LineController,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
|
||||
const title = computed(() => {
|
||||
if (aiStore.duration === "months:2") {
|
||||
return "过去两个月的每周综合情况"
|
||||
} else if (aiStore.duration === "months:6") {
|
||||
return "过去半年的每月综合情况"
|
||||
} else if (aiStore.duration === "years:1") {
|
||||
return "过去一年的每月综合情况"
|
||||
} else {
|
||||
return "过去四周的综合情况"
|
||||
}
|
||||
})
|
||||
|
||||
const data = computed<ChartData<"bar" | "line">>(() => {
|
||||
return {
|
||||
labels: aiStore.durationData.map((duration) => {
|
||||
let prefix = "周"
|
||||
if (duration.unit === "months") {
|
||||
prefix = "月"
|
||||
}
|
||||
return [
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~")
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
type: "bar",
|
||||
label: "完成题目数",
|
||||
data: aiStore.durationData.map((duration) => duration.problem_count),
|
||||
yAxisID: "y",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: "bar",
|
||||
label: "总提交次数",
|
||||
data: aiStore.durationData.map((duration) => duration.submission_count),
|
||||
yAxisID: "y",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "等级",
|
||||
data: aiStore.durationData.map((duration) =>
|
||||
duration.grade ? gradeOrder.indexOf(duration.grade) : null,
|
||||
),
|
||||
spanGaps: false,
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
barThickness: 10,
|
||||
order: 1,
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed<ChartOptions<"bar" | "line">>(() => {
|
||||
return {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "数量",
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
y1: {
|
||||
type: "linear",
|
||||
position: "right",
|
||||
min: -0.5,
|
||||
max: gradeOrder.length - 0.5,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (v) => {
|
||||
const idx = Number(v)
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "等级",
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: TooltipItem<"bar" | "line">) => {
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||
const idx = Number(ctx.parsed.y)
|
||||
return `${dsLabel}: ${gradeOrder[idx] || ""}`
|
||||
}
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
},
|
||||
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||||
const barItems = items.filter(
|
||||
(item) => (item.dataset as any).yAxisID === "y",
|
||||
)
|
||||
if (barItems.length >= 2) {
|
||||
const problemCount =
|
||||
barItems.find((item) => item.dataset.label === "完成题目数")
|
||||
?.parsed.y || 0
|
||||
const submissionCount =
|
||||
barItems.find((item) => item.dataset.label === "总提交次数")
|
||||
?.parsed.y || 0
|
||||
const efficiency =
|
||||
submissionCount > 0
|
||||
? ((problemCount / submissionCount) * 100).toFixed(1)
|
||||
: "0"
|
||||
return `AC率: ${efficiency}%`
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
234
apps/web/src/oj/ai/components/EfficiencyChart.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">反映刷题质量提升</n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="line" :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
import { Chart } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
// 注册折线图所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const title = computed(() => {
|
||||
if (aiStore.duration === "months:2") {
|
||||
return "过去两个月的每周提交效率"
|
||||
} else if (aiStore.duration === "months:6") {
|
||||
return "过去半年的每月提交效率"
|
||||
} else if (aiStore.duration === "years:1") {
|
||||
return "过去一年的每月提交效率"
|
||||
} else {
|
||||
return "过去四周的提交效率"
|
||||
}
|
||||
})
|
||||
|
||||
// 判断是否有数据
|
||||
const show = computed(() => {
|
||||
return aiStore.durationData.length > 0
|
||||
})
|
||||
|
||||
// 计算提交效率数据
|
||||
const efficiencyData = computed(() => {
|
||||
return aiStore.durationData.map((duration) => {
|
||||
const problemCount = duration.problem_count || 0
|
||||
const submissionCount = duration.submission_count || 0
|
||||
|
||||
// 计算效率:提交次数/完成题目数
|
||||
// 值越接近1,说明一次AC率越高
|
||||
const efficiency = problemCount > 0 ? submissionCount / problemCount : 0
|
||||
|
||||
// AC率:AC题目数 / 总提交次数(越高说明提交质量越好)
|
||||
const onePassRate =
|
||||
submissionCount > 0 ? (problemCount / submissionCount) * 100 : 0
|
||||
|
||||
return {
|
||||
label: [
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~"),
|
||||
efficiency: efficiency,
|
||||
onePassRate: onePassRate,
|
||||
problemCount: problemCount,
|
||||
submissionCount: submissionCount,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 图表数据
|
||||
const data = computed<ChartData<"line">>(() => {
|
||||
const efficiency = efficiencyData.value
|
||||
|
||||
return {
|
||||
labels: efficiency.map((e) => e.label),
|
||||
datasets: [
|
||||
{
|
||||
label: "平均提交次数",
|
||||
data: efficiency.map((e) => e.efficiency),
|
||||
borderColor: "rgb(99, 102, 241)",
|
||||
backgroundColor: "rgba(99, 102, 241, 0.1)",
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: "rgb(99, 102, 241)",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
label: "提交AC率",
|
||||
data: efficiency.map((e) => e.onePassRate),
|
||||
borderColor: "rgb(34, 197, 94)",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: "rgb(34, 197, 94)",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
yAxisID: "y1",
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const options = computed(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
autoSkip: true,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: "linear" as const,
|
||||
position: "left" as const,
|
||||
title: {
|
||||
display: true,
|
||||
text: "平均提交次数(次/题)",
|
||||
font: {
|
||||
size: 13,
|
||||
},
|
||||
},
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function (value: string | number) {
|
||||
return Number(value).toFixed(1)
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: "linear" as const,
|
||||
position: "right" as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
title: {
|
||||
display: true,
|
||||
text: "提交AC率(%)",
|
||||
font: {
|
||||
size: 13,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
callback: function (value: string | number) {
|
||||
return Number(value).toFixed(0) + "%"
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: function (ctx: TooltipItem<"line">) {
|
||||
const index = ctx.dataIndex
|
||||
const item = efficiencyData.value[index]
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
|
||||
if (ctx.datasetIndex === 0) {
|
||||
// 平均提交次数
|
||||
return [
|
||||
`${dsLabel}: ${item.efficiency.toFixed(2)} 次/题`,
|
||||
`完成题目: ${item.problemCount} 题`,
|
||||
`总提交: ${item.submissionCount} 次`,
|
||||
]
|
||||
} else {
|
||||
// 提交AC率
|
||||
return [
|
||||
`${dsLabel}: ${item.onePassRate.toFixed(1)}%`,
|
||||
`提示: AC题目数 / 总提交次数,越高表示提交质量越好`,
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
53
apps/web/src/oj/ai/components/Grade.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div align="center" style="display: inline-flex; margin: 0 10px">
|
||||
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
|
||||
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
|
||||
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
|
||||
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-icon size="16" style="cursor: help">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
</template>
|
||||
<div style="max-width: 300px; line-height: 1.4">
|
||||
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
|
||||
<div>使用加权平均方法计算综合等级:</div>
|
||||
<div>• S级 = 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>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
grade: "S" | "A" | "B" | "C"
|
||||
}>()
|
||||
</script>
|
||||
<style scoped>
|
||||
img {
|
||||
animation: shake 0.5s infinite;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
251
apps/web/src/oj/ai/components/Heatmap.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<n-card title="过去一年的提交热力图" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
||||
<div class="heatmap-container" ref="containerRef">
|
||||
<svg
|
||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
class="heatmap-svg"
|
||||
>
|
||||
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
|
||||
<text :x="label.x" :y="10" class="label" font-size="10">
|
||||
{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g v-for="(day, i) in WEEK_DAYS" :key="i">
|
||||
<text
|
||||
:x="0"
|
||||
:y="MONTH_HEIGHT + i * CELL_TOTAL + 8"
|
||||
class="label"
|
||||
font-size="9"
|
||||
>
|
||||
{{ day }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g :transform="`translate(${DAY_WIDTH}, ${MONTH_HEIGHT})`">
|
||||
<rect
|
||||
v-for="(cell, i) in cells"
|
||||
:key="i"
|
||||
:x="cell.x"
|
||||
:y="cell.y"
|
||||
:width="CELL_SIZE"
|
||||
:height="CELL_SIZE"
|
||||
:fill="cell.color"
|
||||
class="cell"
|
||||
rx="2"
|
||||
@mouseenter="(e) => showTooltip(e, cell)"
|
||||
@mouseleave="hideTooltip"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
|
||||
<div class="tooltip-date">{{ tooltip.date }}</div>
|
||||
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
|
||||
{{ tooltip.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
const containerRef = useTemplateRef<HTMLElement>("containerRef")
|
||||
|
||||
const CELL_SIZE = 12
|
||||
const CELL_GAP = 3
|
||||
const CELL_TOTAL = CELL_SIZE + CELL_GAP
|
||||
const DAY_WIDTH = 20
|
||||
const MONTH_HEIGHT = 20
|
||||
const RIGHT_PADDING = 5
|
||||
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
|
||||
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
|
||||
|
||||
const getColor = (count: number) =>
|
||||
count === 0
|
||||
? COLORS[0]
|
||||
: count <= 2
|
||||
? COLORS[1]
|
||||
: count <= 4
|
||||
? COLORS[2]
|
||||
: count <= 7
|
||||
? COLORS[3]
|
||||
: COLORS[4]
|
||||
|
||||
const cells = computed(() =>
|
||||
aiStore.heatmapData.map((item, i) => ({
|
||||
date: new Date(item.timestamp),
|
||||
count: item.value,
|
||||
color: getColor(item.value),
|
||||
week: Math.floor(i / 7),
|
||||
day: i % 7,
|
||||
x: Math.floor(i / 7) * CELL_TOTAL,
|
||||
y: (i % 7) * CELL_TOTAL,
|
||||
})),
|
||||
)
|
||||
|
||||
const monthLabels = computed(() => {
|
||||
const labels: { text: string; x: number }[] = []
|
||||
let lastMonth = -1
|
||||
|
||||
cells.value.forEach((cell, i) => {
|
||||
const month = cell.date.getMonth()
|
||||
const isWeekStart = cell.date.getDay() === 0 || i === 0
|
||||
|
||||
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
|
||||
labels.push({
|
||||
text: `${month + 1}月`,
|
||||
x: DAY_WIDTH + cell.week * CELL_TOTAL,
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
})
|
||||
|
||||
return labels
|
||||
})
|
||||
|
||||
const svgWidth = computed(
|
||||
() =>
|
||||
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
|
||||
)
|
||||
|
||||
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
|
||||
|
||||
interface Cell {
|
||||
date: Date
|
||||
count: number
|
||||
color: string
|
||||
week: number
|
||||
day: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const tooltip = ref<{
|
||||
x: number
|
||||
y: number
|
||||
date: string
|
||||
text: string
|
||||
count: number
|
||||
} | null>(null)
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltip.value?.x}px`,
|
||||
top: `${tooltip.value?.y}px`,
|
||||
}))
|
||||
|
||||
const getTooltipText = (count: number) =>
|
||||
count === 0 ? "没有提交记录" : `提交了 ${count} 次`
|
||||
|
||||
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
const containerRect = containerRef.value?.getBoundingClientRect()
|
||||
|
||||
if (containerRect) {
|
||||
tooltip.value = {
|
||||
x: rect.left - containerRect.left + rect.width / 2,
|
||||
y: rect.top - containerRect.top - 10,
|
||||
date: parseTime(cell.date, "YYYY年M月D日"),
|
||||
text: getTooltipText(cell.count),
|
||||
count: cell.count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
tooltip.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-container {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.label {
|
||||
fill: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cell {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
stroke: rgba(0, 0, 0, 0.05);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
stroke: rgba(0, 0, 0, 0.3);
|
||||
stroke-width: 1.5;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-date {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tooltip-count {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tooltip-count.active {
|
||||
color: #7bc96f;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-100% - 5px));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
apps/web/src/oj/ai/components/Overview.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="aiStore.detailsData.solved.length"
|
||||
>
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>你一共解决 </span>
|
||||
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
|
||||
<span> 道题</span>
|
||||
<span v-if="aiStore.detailsData.contest_count > 0">
|
||||
,并且参加
|
||||
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
|
||||
次比赛
|
||||
</span>
|
||||
<span>,综合评价给到</span>
|
||||
<Grade :grade="aiStore.detailsData.grade" />
|
||||
<span>{{ greeting }}</span>
|
||||
</n-alert>
|
||||
<n-flex vertical size="large" v-else>
|
||||
<n-alert type="error" title="你还没有完成任何题目">
|
||||
开始解题,看看你的学习能力吧!
|
||||
</n-alert>
|
||||
<AI />
|
||||
</n-flex>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import Grade from "./Grade.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import AI from "./AI.vue"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (aiStore.duration.includes("hours")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
|
||||
} else if (aiStore.duration.includes("days")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
|
||||
} else if (
|
||||
aiStore.duration.includes("weeks") ||
|
||||
aiStore.duration.includes("months")
|
||||
) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
|
||||
} else {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
|
||||
}
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
return {
|
||||
S: "要不试试高难度题目?",
|
||||
A: "你很棒,继续保持!",
|
||||
B: "请再接再厉!",
|
||||
C: "你还需要努力!",
|
||||
}[aiStore.detailsData.grade]
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.charming {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
271
apps/web/src/oj/ai/components/ProgressChart.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">追踪学习成长轨迹</n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="line" :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
import { Chart } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
Filler,
|
||||
} from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Grade } from "utils/types"
|
||||
|
||||
// 注册折线图所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
Filler,
|
||||
)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
const gradeColors: Record<Grade, string> = {
|
||||
C: "#95F204",
|
||||
B: "#36A2EB",
|
||||
A: "#FFCE56",
|
||||
S: "#FF6384",
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (aiStore.duration === "months:2") {
|
||||
return "过去两个月的进步曲线"
|
||||
} else if (aiStore.duration === "months:6") {
|
||||
return "过去半年的进步曲线"
|
||||
} else if (aiStore.duration === "years:1") {
|
||||
return "过去一年的进步曲线"
|
||||
} else {
|
||||
return "过去四周的进步曲线"
|
||||
}
|
||||
})
|
||||
|
||||
// 判断是否有数据
|
||||
const show = computed(() => {
|
||||
return aiStore.durationData.length > 0
|
||||
})
|
||||
|
||||
// 计算累计题目数量和等级趋势
|
||||
const progressData = computed(() => {
|
||||
let cumulativeCount = 0
|
||||
let totalWeightedGrade = 0 // 累计加权等级
|
||||
let totalProblems = 0 // 累计题目总数
|
||||
|
||||
return aiStore.durationData.map((duration) => {
|
||||
const problemCount = duration.problem_count || 0
|
||||
cumulativeCount += problemCount
|
||||
|
||||
// 计算本期等级的权重值
|
||||
const currentGradeValue = gradeOrder.indexOf(duration.grade || "C")
|
||||
|
||||
// 累加加权等级
|
||||
totalWeightedGrade += currentGradeValue * problemCount
|
||||
totalProblems += problemCount
|
||||
|
||||
// 计算累计平均等级
|
||||
const avgGradeValue =
|
||||
totalProblems > 0 ? totalWeightedGrade / totalProblems : 0
|
||||
|
||||
return {
|
||||
label: [
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~"),
|
||||
start: parseTime(duration.start, "YYYY-MM-DD"),
|
||||
end: parseTime(duration.end, "YYYY-MM-DD"),
|
||||
count: cumulativeCount,
|
||||
grade: duration.grade || "C",
|
||||
gradeValue: currentGradeValue,
|
||||
avgGradeValue: avgGradeValue, // 累计平均等级
|
||||
problemCount: problemCount,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 图表数据
|
||||
const data = computed<ChartData<"line">>(() => {
|
||||
const progress = progressData.value
|
||||
|
||||
return {
|
||||
labels: progress.map((p) => p.label),
|
||||
datasets: [
|
||||
{
|
||||
type: "line",
|
||||
label: "累计完成题目",
|
||||
data: progress.map((p) => p.count),
|
||||
borderColor: "#4CAF50",
|
||||
backgroundColor: "rgba(76, 175, 80, 0.1)",
|
||||
tension: 0.4,
|
||||
yAxisID: "y",
|
||||
fill: true,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: "#4CAF50",
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "累计平均等级",
|
||||
data: progress.map((p) => p.avgGradeValue),
|
||||
borderColor: "#FF9800",
|
||||
backgroundColor: "rgba(255, 152, 0, 0.1)",
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
fill: false,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const options = computed<ChartOptions<"line">>(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 15,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: "linear",
|
||||
position: "left",
|
||||
title: {
|
||||
display: true,
|
||||
text: "累计题目数",
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
y1: {
|
||||
type: "linear",
|
||||
position: "right",
|
||||
min: -0.5,
|
||||
max: gradeOrder.length - 0.5,
|
||||
title: {
|
||||
display: true,
|
||||
text: "累计平均等级",
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (v: string | number) => {
|
||||
const idx = Math.round(Number(v))
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
title: (items: TooltipItem<"line">[]) => {
|
||||
if (items.length > 0) {
|
||||
const idx = items[0].dataIndex
|
||||
const progress = progressData.value[idx]
|
||||
return progress ? `${progress.start} ~ ${progress.end}` : ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
label: (ctx: TooltipItem<"line">) => {
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
const idx = ctx.dataIndex
|
||||
const progress = progressData.value[idx]
|
||||
|
||||
if (!progress) {
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
}
|
||||
|
||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||
// 累计平均等级轴
|
||||
const avgIdx = Math.round(Number(ctx.parsed.y))
|
||||
return [
|
||||
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
|
||||
`本期等级: ${progress.grade}`,
|
||||
`本期完成: ${progress.problemCount} 题`,
|
||||
]
|
||||
} else {
|
||||
// 累计题目数轴
|
||||
return [
|
||||
`${dsLabel}: ${ctx.formattedValue} 题`,
|
||||
`本期完成: ${progress.problemCount} 题`,
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
132
apps/web/src/oj/ai/components/RankDistributionChart.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<n-card title="同期解题排名分布" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
了解同期解题速度和竞争力
|
||||
</n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Pie :data="data" :options="options" />
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Pie } from "vue-chartjs"
|
||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
|
||||
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
||||
|
||||
const aiStore = useAIStore()
|
||||
|
||||
// 排名区间定义
|
||||
const RANK_RANGES = [
|
||||
{ label: "前10%", min: 0, max: 10, color: "#FF6384" },
|
||||
{ label: "10-30%", min: 10, max: 30, color: "#FFCE56" },
|
||||
{ label: "30-50%", min: 30, max: 50, color: "#36A2EB" },
|
||||
{ label: "50-70%", min: 50, max: 70, color: "#4BC0C0" },
|
||||
{ label: "70%以后", min: 70, max: 100, color: "#9966FF" },
|
||||
]
|
||||
|
||||
// 计算每道题的排名百分位并分类
|
||||
const rankDistribution = computed(() => {
|
||||
const distribution = RANK_RANGES.map((range) => ({
|
||||
...range,
|
||||
count: 0,
|
||||
problems: [] as string[],
|
||||
}))
|
||||
|
||||
aiStore.detailsData.solved.forEach((item) => {
|
||||
const rank = item.period_rank
|
||||
const acCount = item.period_ac_count
|
||||
|
||||
if (rank && acCount && acCount > 0) {
|
||||
const percentile = (rank / acCount) * 100
|
||||
|
||||
// 找到对应的区间
|
||||
const rangeIndex = RANK_RANGES.findIndex(
|
||||
(r) => percentile >= r.min && percentile < r.max,
|
||||
)
|
||||
|
||||
if (rangeIndex !== -1) {
|
||||
distribution[rangeIndex].count++
|
||||
distribution[rangeIndex].problems.push(
|
||||
`${item.problem.display_id}: ${item.problem.title}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return distribution
|
||||
})
|
||||
|
||||
const show = computed(() => {
|
||||
return aiStore.detailsData.solved.length > 0
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
return {
|
||||
labels: RANK_RANGES.map((r) => r.label),
|
||||
datasets: [
|
||||
{
|
||||
label: "题目数量",
|
||||
data: rankDistribution.value.map((r) => r.count),
|
||||
backgroundColor: RANK_RANGES.map((r) => r.color),
|
||||
borderColor: RANK_RANGES.map((r) => r.color),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom" as const,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const count = context.parsed
|
||||
const total = rankDistribution.value.reduce(
|
||||
(sum, r) => sum + r.count,
|
||||
0,
|
||||
)
|
||||
const percentage =
|
||||
total > 0 ? ((count / total) * 100).toFixed(1) : "0.0"
|
||||
const label = context.label || ""
|
||||
return `${label}: ${count} 道题 (${percentage}%)`
|
||||
},
|
||||
afterLabel: (context: any) => {
|
||||
const index = context.dataIndex
|
||||
const problems = rankDistribution.value[index].problems
|
||||
if (problems.length > 0 && problems.length <= 5) {
|
||||
return problems
|
||||
} else if (problems.length > 5) {
|
||||
return [
|
||||
...problems.slice(0, 3),
|
||||
`... 还有 ${problems.length - 3} 道题`,
|
||||
]
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||