Compare commits
118 Commits
2da0d098b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cf98ed087 | |||
| 235d11ecdd | |||
| 8be96e178e | |||
| ec54fb430e | |||
| 56b5dc2e36 | |||
| 39f9bb9d38 | |||
| 34aff29048 | |||
| d7b57074d3 | |||
| 280e360506 | |||
| 5b8e0f866b | |||
| 9f89be3876 | |||
| 1fb93e6b16 | |||
| b3d0c3fcc7 | |||
| c4d55bb88e | |||
| c91c923fd6 | |||
| 9c6c6e1784 | |||
| a8cc7c855a | |||
| e153064d87 | |||
| 0af91b1e20 | |||
| d16e3dddb8 | |||
| 8a46bae27d | |||
| 0e2cc28235 | |||
| e777a94568 | |||
| 56f1431a10 | |||
| 7b2a4a9094 | |||
| b559c93476 | |||
| 42de79ee60 | |||
| 8cfa90ca16 | |||
| 4b796650ff | |||
| ade6034316 | |||
| a1bea7182f | |||
| 425bab1c15 | |||
| 17dbd7a4a7 | |||
| 8db26ef7a4 | |||
| 508267bb59 | |||
| a4fdd377f2 | |||
| da2334ed3a | |||
| a626160f29 | |||
| bf028b3fb1 | |||
| b7f82adb7c | |||
| 05ce9cbb9e | |||
| 4072abe199 | |||
| 0d7336bce8 | |||
| 4db0dc7e83 | |||
| 56d06eb456 | |||
| b79dd6c721 | |||
| 15d823aa20 | |||
| 4ae25b09c0 | |||
| ea691834b0 | |||
| 344e9734b6 | |||
| 7694db7d41 | |||
| a191bd78d4 | |||
| 842c4796c0 | |||
| 15aaec4384 | |||
| 52ac8d205d | |||
| 30ea5c8191 | |||
| 58c67240b7 | |||
| b4b96b0da3 | |||
| 4ed8014c30 | |||
| 40bd331a1c | |||
| 4ec6b1d738 | |||
| c88753ba78 | |||
| 0932a05850 | |||
| 0ba25dc4e3 | |||
| 7e498a1c1c | |||
| 700559557d | |||
| a9278589c9 | |||
| d31bfd8b8f | |||
| 64a770ff62 | |||
| d7d80e1c1b | |||
| fe7095bd74 | |||
| d81e00ee14 | |||
| 71186f0ca5 | |||
| 8d06fafbae | |||
| dc853cd61f | |||
| ccbb961d64 | |||
| f928e79c1f | |||
| 8db3b15df1 | |||
| 6766c4dcd5 | |||
| bd6c5d19f8 | |||
| 8a343edbbb | |||
| 07bbbce013 | |||
| 5fac75238d | |||
| 3509f340ff | |||
| 297f674bac | |||
| e99f9d5458 | |||
| 63b02ec221 | |||
| 3aa82bb200 | |||
| 4031e2bd3c | |||
| 4e5b2fdb3e | |||
| be1b9a97a0 | |||
| 02c523b32f | |||
| f6d4d9bcb2 | |||
| 527127d9c7 | |||
| e7a621075c | |||
| 081981d115 | |||
| ff5daf8afe | |||
| bda28b8400 | |||
| 740f956fe3 | |||
| ebe2ab762f | |||
| 9144dbb4fc | |||
| 10dcb29704 | |||
| 1cac0d01f8 | |||
| 47281029ec | |||
| ad28c0ba21 | |||
| 0b627b5157 | |||
| aa212427ba | |||
| 14321cc222 | |||
| dd29f04664 | |||
| 5d418a2280 | |||
| b4ac42b736 | |||
| c227c1d506 | |||
| 38500671af | |||
| 5867bbceed | |||
| 71fc5e3246 | |||
| a4bd4fcd13 | |||
| 1d285c8694 | |||
| 4261bae104 |
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: debian
|
- name: debian
|
||||||
@@ -23,9 +24,9 @@ jobs:
|
|||||||
remote_port: 8822
|
remote_port: 8822
|
||||||
target: /root/OJ/data/dist
|
target: /root/OJ/data/dist
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**ojnext** is the frontend for an Online Judge platform. Built with Vue 3 + TypeScript using Rsbuild (Rust-based bundler), Naive UI component library, Pinia for state management, and Vue Router.
|
**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
|
## Commands
|
||||||
|
|
||||||
|
|||||||
296
docs/superpowers/plans/2026-07-26-demo-student-view.md
Normal file
296
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,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)场景同样生效。
|
||||||
102
docs/superpowers/specs/2026-07-26-demo-student-view-design.md
Normal file
102
docs/superpowers/specs/2026-07-26-demo-student-view-design.md
Normal file
@@ -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. 用教师管理员账号登录 → 菜单中无此项
|
||||||
@@ -9,16 +9,9 @@
|
|||||||
<script>
|
<script>
|
||||||
window.localStorage.setItem("maxkbMaskTip", true)
|
window.localStorage.setItem("maxkbMaskTip", true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<% if (process.env.PUBLIC_MAXKB_URL) { %>
|
|
||||||
<script
|
|
||||||
async
|
|
||||||
defer
|
|
||||||
src="<%= process.env.PUBLIC_MAXKB_URL %>"
|
|
||||||
></script>
|
|
||||||
<% } %>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
4828
package-lock.json
generated
4828
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
@@ -1,64 +1,65 @@
|
|||||||
{
|
{
|
||||||
"name": "oj-next",
|
"name": "oj-next",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "rsbuild dev",
|
"start": "vite",
|
||||||
"build": "rsbuild build",
|
"build": "vite build",
|
||||||
"build:staging": "rsbuild build --env-mode=staging",
|
"build:staging": "vite build --mode staging",
|
||||||
"build:test": "rsbuild build --env-mode=test",
|
"build:test": "vite build --mode test",
|
||||||
"fmt": "prettier --write src *.ts"
|
"fmt": "prettier --write src *.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.3",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@vue-flow/background": "^1.3.2",
|
"@vue-flow/background": "^1.3.2",
|
||||||
"@vue-flow/controls": "^1.1.3",
|
"@vue-flow/controls": "^1.1.3",
|
||||||
"@vue-flow/core": "^1.48.2",
|
"@vue-flow/core": "^1.48.2",
|
||||||
"@vue-flow/minimap": "^1.5.4",
|
"@vue-flow/minimap": "^1.5.4",
|
||||||
"@vue-flow/node-resizer": "^1.5.1",
|
"@vue-flow/node-resizer": "^1.5.1",
|
||||||
"@vue-flow/node-toolbar": "^1.1.1",
|
"@vue-flow/node-toolbar": "^1.1.1",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.4.0",
|
||||||
"@vueuse/router": "^14.3.0",
|
"@vueuse/router": "^14.4.0",
|
||||||
"@wangeditor-next/editor": "^5.7.12",
|
"@wangeditor-next/editor": "^6.2.0",
|
||||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
"@wangeditor-next/editor-for-vue": "^6.2.0",
|
||||||
"axios": "^1.18.0",
|
"axios": "^1.19.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"chartjs-chart-wordcloud": "^4.4.5",
|
"chartjs-chart-wordcloud": "^4.4.5",
|
||||||
"client-zip": "^2.5.0",
|
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"copy-text-to-clipboard": "^3.2.2",
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"md-editor-v3": "^6.5.1",
|
"md-editor-v3": "^6.5.6",
|
||||||
"mermaid": "^11.15.0",
|
"mermaid": "^11.16.1",
|
||||||
"mermaid-legacy": "npm:mermaid@^9.1.7",
|
"mermaid-legacy": "npm:mermaid@^9.4.3",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.44.1",
|
||||||
"nanoid": "^5.1.15",
|
"nanoid": "^6.0.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^4.0.2",
|
||||||
"skulpt": "^1.2.0",
|
"skulpt": "^1.2.0",
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.41",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-codemirror": "^6.1.1",
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-router": "^5.1.0",
|
"vue-router": "^5.2.0",
|
||||||
"y-codemirror.next": "^0.3.5",
|
"y-codemirror.next": "^0.3.5",
|
||||||
"y-webrtc": "^10.3.0",
|
"y-webrtc": "^10.3.0",
|
||||||
"yjs": "^13.6.31"
|
"yjs": "^13.6.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/vue": "^5.0.1",
|
"@iconify/vue": "^5.0.1",
|
||||||
"@rsbuild/core": "^1.7.5",
|
|
||||||
"@rsbuild/plugin-vue": "^1.2.9",
|
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.1.2",
|
||||||
|
"@vitejs/plugin-legacy": "^8.2.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"prettier": "^3.8.4",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^7.0.2",
|
||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.1.0",
|
||||||
"unplugin-vue-components": "^32.1.0"
|
"unplugin-vue-components": "^32.1.0",
|
||||||
|
"vite": "^8.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { defineConfig, loadEnv } from "@rsbuild/core"
|
|
||||||
import { pluginVue } from "@rsbuild/plugin-vue"
|
|
||||||
import AutoImport from "unplugin-auto-import/rspack"
|
|
||||||
import Components from "unplugin-vue-components/rspack"
|
|
||||||
import { NaiveUiResolver } from "unplugin-vue-components/resolvers"
|
|
||||||
|
|
||||||
const config: ReturnType<typeof defineConfig> = defineConfig(({ envMode }) => {
|
|
||||||
const { publicVars, rawPublicVars } = loadEnv({
|
|
||||||
cwd: process.cwd(),
|
|
||||||
mode: envMode,
|
|
||||||
})
|
|
||||||
|
|
||||||
const proxyConfig = {
|
|
||||||
target: rawPublicVars["PUBLIC_OJ_URL"],
|
|
||||||
changeOrigin: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsProxyConfig = {
|
|
||||||
target: rawPublicVars["PUBLIC_WS_URL"],
|
|
||||||
ws: true,
|
|
||||||
changeOrigin: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [pluginVue()],
|
|
||||||
tools: {
|
|
||||||
rspack: {
|
|
||||||
plugins: [
|
|
||||||
AutoImport({
|
|
||||||
imports: [
|
|
||||||
"vue",
|
|
||||||
"vue-router",
|
|
||||||
"@vueuse/core",
|
|
||||||
"pinia",
|
|
||||||
{
|
|
||||||
"naive-ui": [
|
|
||||||
"useDialog",
|
|
||||||
"useMessage",
|
|
||||||
"useNotification",
|
|
||||||
"useLoadingBar",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: "naive-ui",
|
|
||||||
imports: [
|
|
||||||
"DataTableColumn",
|
|
||||||
"FormRules",
|
|
||||||
"FormItemRule",
|
|
||||||
"SelectOption",
|
|
||||||
"UploadCustomRequestOptions",
|
|
||||||
"UploadFileInfo",
|
|
||||||
"MenuOption",
|
|
||||||
"DropdownDividerOption",
|
|
||||||
"DropdownOption",
|
|
||||||
],
|
|
||||||
type: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dts: "./src/auto-imports.d.ts",
|
|
||||||
}),
|
|
||||||
Components({
|
|
||||||
resolvers: [NaiveUiResolver()],
|
|
||||||
dts: "./src/components.d.ts",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
html: {
|
|
||||||
template: "./index.html",
|
|
||||||
},
|
|
||||||
source: {
|
|
||||||
entry: {
|
|
||||||
index: "./src/main.ts",
|
|
||||||
},
|
|
||||||
define: publicVars,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
polyfill: "usage",
|
|
||||||
},
|
|
||||||
performance: {
|
|
||||||
chunkSplit: {
|
|
||||||
strategy: "split-by-module",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
utils: "./src/utils",
|
|
||||||
oj: "./src/oj",
|
|
||||||
admin: "./src/admin",
|
|
||||||
shared: "./src/shared",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
"/api": proxyConfig,
|
|
||||||
"/public": proxyConfig,
|
|
||||||
"/ws": wsProxyConfig,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default config
|
|
||||||
18
src/App.vue
18
src/App.vue
@@ -26,14 +26,24 @@ const hljsInstance = ref<any>(null)
|
|||||||
const loadHighlightJS = async () => {
|
const loadHighlightJS = async () => {
|
||||||
if (hljsInstance.value) return hljsInstance.value
|
if (hljsInstance.value) return hljsInstance.value
|
||||||
|
|
||||||
const hljs = (await import("highlight.js/lib/core")).default
|
const [hljs, c, cpp, python, java, javascript, go, sql] = await Promise.all([
|
||||||
const c = (await import("highlight.js/lib/languages/c")).default
|
import("highlight.js/lib/core"),
|
||||||
const cpp = (await import("highlight.js/lib/languages/cpp")).default
|
import("highlight.js/lib/languages/c"),
|
||||||
const python = (await import("highlight.js/lib/languages/python")).default
|
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("c", c)
|
||||||
hljs.registerLanguage("python", python)
|
hljs.registerLanguage("python", python)
|
||||||
hljs.registerLanguage("cpp", cpp)
|
hljs.registerLanguage("cpp", cpp)
|
||||||
|
hljs.registerLanguage("java", java)
|
||||||
|
hljs.registerLanguage("javascript", javascript)
|
||||||
|
hljs.registerLanguage("go", go)
|
||||||
|
hljs.registerLanguage("sql", sql)
|
||||||
|
|
||||||
hljsInstance.value = hljs
|
hljsInstance.value = hljs
|
||||||
return hljs
|
return hljs
|
||||||
|
|||||||
185
src/admin/achievement/components/AchievementModal.vue
Normal file
185
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
src/admin/achievement/list.vue
Normal file
144
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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import TextEditor from "shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import { AnnouncementEdit } from "utils/types"
|
import type { AnnouncementEdit } from "utils/types"
|
||||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch } from "naive-ui"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Announcement } from "utils/types"
|
import type { Announcement } from "utils/types"
|
||||||
import { editAnnouncement, getAnnouncementList } from "../api"
|
import { editAnnouncement, getAnnouncementList } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
|
||||||
|
|||||||
125
src/admin/api.ts
125
src/admin/api.ts
@@ -2,13 +2,16 @@ import http from "utils/http"
|
|||||||
import { toProblemListItem } from "admin/transforms"
|
import { toProblemListItem } from "admin/transforms"
|
||||||
import type {
|
import type {
|
||||||
AdminProblem,
|
AdminProblem,
|
||||||
|
AdminTag,
|
||||||
Announcement,
|
Announcement,
|
||||||
AnnouncementEdit,
|
AnnouncementEdit,
|
||||||
BlankContest,
|
BlankContest,
|
||||||
BlankProblem,
|
BlankProblem,
|
||||||
Contest,
|
Contest,
|
||||||
Exercise,
|
Exercise,
|
||||||
|
ExerciseType,
|
||||||
Server,
|
Server,
|
||||||
|
SQLDisplay,
|
||||||
TestcaseUploadedReturns,
|
TestcaseUploadedReturns,
|
||||||
Tutorial,
|
Tutorial,
|
||||||
User,
|
User,
|
||||||
@@ -29,6 +32,7 @@ export async function getProblemList(
|
|||||||
keyword: string,
|
keyword: string,
|
||||||
author?: string,
|
author?: string,
|
||||||
contestID?: string,
|
contestID?: string,
|
||||||
|
tagId?: number,
|
||||||
) {
|
) {
|
||||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||||
const res = await http.get<{ results: AdminProblem[]; total: number }>(
|
const res = await http.get<{ results: AdminProblem[]; total: number }>(
|
||||||
@@ -41,6 +45,7 @@ export async function getProblemList(
|
|||||||
keyword,
|
keyword,
|
||||||
author,
|
author,
|
||||||
contest_id: contestID,
|
contest_id: contestID,
|
||||||
|
tag_id: tagId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -82,6 +87,35 @@ export function getContestProblem(id: number) {
|
|||||||
return http.get("admin/contest/problem", { params: { id } })
|
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(
|
export function getUserList(
|
||||||
offset = 0,
|
offset = 0,
|
||||||
@@ -132,15 +166,43 @@ export async function uploadImage(file: File): Promise<string> {
|
|||||||
return res.success ? res.file_path : ""
|
return res.success ? res.file_path : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传测试用例
|
// 上传测试用例;SQL 题的压缩包是 1.sql..N.sql(每个文件一个测试点的建表+数据脚本)
|
||||||
export function uploadTestcases(file: File) {
|
export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
|
||||||
const form = new window.FormData()
|
const form = new window.FormData()
|
||||||
form.append("file", file)
|
form.append("file", file)
|
||||||
|
if (options.sql) {
|
||||||
|
form.append("sql", "1")
|
||||||
|
}
|
||||||
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
|
||||||
headers: { "content-type": "multipart/form-data" },
|
headers: { "content-type": "multipart/form-data" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
export function createProblem(problem: BlankProblem) {
|
||||||
return http.post("admin/problem", problem)
|
return http.post("admin/problem", problem)
|
||||||
}
|
}
|
||||||
@@ -225,16 +287,6 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
|
|||||||
return http.post("admin/announcement", announcement)
|
return http.post("admin/announcement", announcement)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCommentList(offset = 0, limit = 10, problem: string) {
|
|
||||||
return http.get("admin/comment", {
|
|
||||||
params: { offset, limit, problem },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteComment(id: number) {
|
|
||||||
return http.delete("admin/comment", { params: { id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTutorialList() {
|
export async function getTutorialList() {
|
||||||
const res = await http.get<Tutorial[]>("admin/tutorial")
|
const res = await http.get<Tutorial[]>("admin/tutorial")
|
||||||
return res.data
|
return res.data
|
||||||
@@ -272,7 +324,7 @@ export async function getAdminExercises(tutorialId: number) {
|
|||||||
|
|
||||||
export async function createExercise(data: {
|
export async function createExercise(data: {
|
||||||
tutorial_id: number
|
tutorial_id: number
|
||||||
type: "mcq" | "sort" | "fill"
|
type: ExerciseType
|
||||||
data: object
|
data: object
|
||||||
order: number
|
order: number
|
||||||
}) {
|
}) {
|
||||||
@@ -282,7 +334,7 @@ export async function createExercise(data: {
|
|||||||
|
|
||||||
export async function updateExercise(data: {
|
export async function updateExercise(data: {
|
||||||
id: number
|
id: number
|
||||||
type: "mcq" | "sort" | "fill"
|
type: ExerciseType
|
||||||
data: object
|
data: object
|
||||||
order: number
|
order: number
|
||||||
}) {
|
}) {
|
||||||
@@ -501,3 +553,48 @@ export function pinAIReport(id: number) {
|
|||||||
export function getPinnedAIReports() {
|
export function getPinnedAIReports() {
|
||||||
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
|
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 } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-flex justify="space-between" class="titleWrapper">
|
|
||||||
<h2 class="title">评论列表(只列出有内容的)</h2>
|
|
||||||
<div>
|
|
||||||
<n-input
|
|
||||||
v-model:value="query.problem"
|
|
||||||
clearable
|
|
||||||
placeholder="输入题目序号"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</n-flex>
|
|
||||||
<n-data-table striped :columns="columns" :data="comments" />
|
|
||||||
<Pagination
|
|
||||||
:total="total"
|
|
||||||
v-model:limit="query.limit"
|
|
||||||
v-model:page="query.page"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { NButton } from "naive-ui"
|
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
import { Comment } from "utils/types"
|
|
||||||
import { getCommentList } from "../api"
|
|
||||||
import CommentActions from "./components/CommentActions.vue"
|
|
||||||
|
|
||||||
const comments = ref<Comment[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
const query = reactive({
|
|
||||||
limit: 10,
|
|
||||||
page: 1,
|
|
||||||
problem: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const columns: DataTableColumn<Comment>[] = [
|
|
||||||
{
|
|
||||||
title: "题目",
|
|
||||||
key: "problem",
|
|
||||||
width: 100,
|
|
||||||
render: (row) =>
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
text: true,
|
|
||||||
type: "info",
|
|
||||||
onClick: () => window.open("/problem/" + row.problem, "_blank"),
|
|
||||||
},
|
|
||||||
() => row.problem,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "提交",
|
|
||||||
key: "submission",
|
|
||||||
width: 200,
|
|
||||||
render: (row) =>
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
text: true,
|
|
||||||
type: "info",
|
|
||||||
onClick: () => window.open("/submission/" + row.submission, "_blank"),
|
|
||||||
},
|
|
||||||
() => row.submission.slice(0, 12),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: "描述评分", key: "description_rating", width: 100 },
|
|
||||||
{ title: "难度评分", key: "difficulty_rating", width: 100 },
|
|
||||||
{ title: "综合评分", key: "comprehensive_rating", width: 100 },
|
|
||||||
{ title: "用户", key: "user.username", width: 150 },
|
|
||||||
{
|
|
||||||
title: "时间",
|
|
||||||
key: "create_time",
|
|
||||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
|
||||||
width: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "内容",
|
|
||||||
key: "content",
|
|
||||||
minWidth: 200,
|
|
||||||
maxWidth: 300,
|
|
||||||
ellipsis: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "选项",
|
|
||||||
key: "action",
|
|
||||||
width: 100,
|
|
||||||
render: (row) =>
|
|
||||||
h(CommentActions, { commentID: row.id, onDeleted: listComments }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
async function listComments() {
|
|
||||||
const offset = (query.page - 1) * query.limit
|
|
||||||
const res = await getCommentList(offset, query.limit, query.problem)
|
|
||||||
comments.value = res.data.results
|
|
||||||
total.value = res.data.total
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(listComments)
|
|
||||||
watch(() => [query.page, query.limit], listComments)
|
|
||||||
watchDebounced(() => query.problem, listComments, {
|
|
||||||
debounce: 500,
|
|
||||||
maxWait: 1000,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.titleWrapper {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { deleteComment } from "admin/api"
|
|
||||||
|
|
||||||
const props = defineProps<{ commentID: number }>()
|
|
||||||
const emit = defineEmits(["deleted"])
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
await deleteComment(props.commentID)
|
|
||||||
message.success("成功删除")
|
|
||||||
emit("deleted")
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<n-popconfirm @positive-click="submit">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button secondary size="small" type="error">删除</n-button>
|
|
||||||
</template>
|
|
||||||
确定删除这条评论吗?
|
|
||||||
</n-popconfirm>
|
|
||||||
</template>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Contest } from "utils/types"
|
import type { Contest } from "utils/types"
|
||||||
import { cloneContest } from "../../api"
|
import { cloneContest } from "../../api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
109
src/admin/problem/components/BatchTagModal.vue
Normal file
109
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>
|
||||||
345
src/admin/problem/components/SQLTestcaseEditor.vue
Normal file
345
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
src/admin/problem/components/TagProblemsModal.vue
Normal file
147
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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { downloadZip } from "client-zip"
|
|
||||||
import type { LANGUAGE, Testcase } from "utils/types"
|
import type { LANGUAGE, Testcase } from "utils/types"
|
||||||
|
import { createZipBlob } from "utils/functions"
|
||||||
import { createTestSubmission } from "utils/judge"
|
import { createTestSubmission } from "utils/judge"
|
||||||
import { uploadTestcases } from "../../api"
|
import { uploadTestcases } from "../../api"
|
||||||
|
|
||||||
@@ -158,15 +158,14 @@ async function run() {
|
|||||||
async function upload() {
|
async function upload() {
|
||||||
isUploading.value = true
|
isUploading.value = true
|
||||||
try {
|
try {
|
||||||
const now = new Date()
|
|
||||||
const data = files.value
|
const data = files.value
|
||||||
.filter((f) => f.in.trim() && f.out && !f.error)
|
.filter((f) => f.in.trim() && f.out && !f.error)
|
||||||
.flatMap((f, i) => [
|
.flatMap((f, i) => [
|
||||||
{ name: `${i + 1}.in`, input: f.in, lastModified: now },
|
{ name: `${i + 1}.in`, content: f.in },
|
||||||
{ name: `${i + 1}.out`, input: f.out, lastModified: now },
|
{ name: `${i + 1}.out`, content: f.out },
|
||||||
])
|
])
|
||||||
|
|
||||||
const blob = await downloadZip(data).blob()
|
const blob = createZipBlob(data)
|
||||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||||
|
|
||||||
const res = await uploadTestcases(file)
|
const res = await uploadTestcases(file)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { getProblemTagList } from "shared/api"
|
import { getProblemTagList } from "shared/api"
|
||||||
import TextEditor from "shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
|
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
|
||||||
|
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
|
||||||
import AstRulesEditor from "./components/AstRulesEditor.vue"
|
import AstRulesEditor from "./components/AstRulesEditor.vue"
|
||||||
import {
|
import {
|
||||||
CODE_TEMPLATES,
|
CODE_TEMPLATES,
|
||||||
@@ -10,7 +11,13 @@ import {
|
|||||||
} from "utils/constants"
|
} from "utils/constants"
|
||||||
import download from "utils/download"
|
import download from "utils/download"
|
||||||
import { unique } from "utils/functions"
|
import { unique } from "utils/functions"
|
||||||
import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
import type {
|
||||||
|
BlankProblem,
|
||||||
|
LANGUAGE,
|
||||||
|
SQLConfig,
|
||||||
|
Tag,
|
||||||
|
Testcase,
|
||||||
|
} from "utils/types"
|
||||||
import {
|
import {
|
||||||
createContestProblem,
|
createContestProblem,
|
||||||
createProblem,
|
createProblem,
|
||||||
@@ -72,16 +79,10 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
|||||||
],
|
],
|
||||||
test_case_id: "",
|
test_case_id: "",
|
||||||
test_case_score: [] as Testcase[],
|
test_case_score: [] as Testcase[],
|
||||||
rule_type: "ACM",
|
|
||||||
hint: "",
|
hint: "",
|
||||||
source: "",
|
source: "",
|
||||||
prompt: "",
|
prompt: "",
|
||||||
answers: [] as { language: LANGUAGE; code: string }[],
|
answers: [] as { language: LANGUAGE; code: string }[],
|
||||||
io_mode: {
|
|
||||||
io_mode: "Standard IO",
|
|
||||||
input: "input.txt",
|
|
||||||
output: "output.txt",
|
|
||||||
},
|
|
||||||
contest_id: "",
|
contest_id: "",
|
||||||
allow_flowchart: false,
|
allow_flowchart: false,
|
||||||
mermaid_code: "",
|
mermaid_code: "",
|
||||||
@@ -89,6 +90,7 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
|||||||
flowchart_hint: "",
|
flowchart_hint: "",
|
||||||
show_flowchart: false,
|
show_flowchart: false,
|
||||||
ast_rules: null as { [key: string]: any[] } | null,
|
ast_rules: null as { [key: string]: any[] } | null,
|
||||||
|
sql_config: null as SQLConfig | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 从服务器来的tag列表
|
// 从服务器来的tag列表
|
||||||
@@ -171,8 +173,40 @@ const languageOptions = [
|
|||||||
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
|
{ 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["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() {
|
async function getProblemDetail() {
|
||||||
if (!props.problemID) {
|
if (!props.problemID) {
|
||||||
syncTagInputsFromProblemTags()
|
syncTagInputsFromProblemTags()
|
||||||
@@ -200,7 +234,6 @@ async function getProblemDetail() {
|
|||||||
problem.value.samples = data.samples
|
problem.value.samples = data.samples
|
||||||
problem.value.test_case_id = data.test_case_id
|
problem.value.test_case_id = data.test_case_id
|
||||||
problem.value.test_case_score = data.test_case_score
|
problem.value.test_case_score = data.test_case_score
|
||||||
problem.value.rule_type = data.rule_type
|
|
||||||
problem.value.hint = data.hint
|
problem.value.hint = data.hint
|
||||||
problem.value.source = data.source
|
problem.value.source = data.source
|
||||||
problem.value.prompt = data.prompt
|
problem.value.prompt = data.prompt
|
||||||
@@ -211,6 +244,7 @@ async function getProblemDetail() {
|
|||||||
problem.value.flowchart_hint = data.flowchart_hint ?? ""
|
problem.value.flowchart_hint = data.flowchart_hint ?? ""
|
||||||
problem.value.flowchart_data = data.flowchart_data
|
problem.value.flowchart_data = data.flowchart_data
|
||||||
problem.value.ast_rules = data.ast_rules ?? null
|
problem.value.ast_rules = data.ast_rules ?? null
|
||||||
|
problem.value.sql_config = data.sql_config ?? null
|
||||||
if (data.answers && data.answers.length) {
|
if (data.answers && data.answers.length) {
|
||||||
problem.value.answers = data.answers
|
problem.value.answers = data.answers
|
||||||
} else {
|
} else {
|
||||||
@@ -219,7 +253,6 @@ async function getProblemDetail() {
|
|||||||
code: "",
|
code: "",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
problem.value.io_mode = data.io_mode
|
|
||||||
if (problem.value.contest_id) {
|
if (problem.value.contest_id) {
|
||||||
problem.value.contest_id = problem.value.contest_id
|
problem.value.contest_id = problem.value.contest_id
|
||||||
}
|
}
|
||||||
@@ -262,7 +295,7 @@ function resetTemplate(language: LANGUAGE) {
|
|||||||
|
|
||||||
async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
||||||
try {
|
try {
|
||||||
const res = await uploadTestcases(file.file!)
|
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
message.error("上传测试用例失败")
|
message.error("上传测试用例失败")
|
||||||
@@ -304,19 +337,20 @@ async function validateProblem() {
|
|||||||
// 题目
|
// 题目
|
||||||
else if (
|
else if (
|
||||||
!problem.value.description ||
|
!problem.value.description ||
|
||||||
!problem.value.input_description ||
|
(!isSQLProblem.value &&
|
||||||
!problem.value.output_description
|
(!problem.value.input_description || !problem.value.output_description))
|
||||||
) {
|
) {
|
||||||
message.error("题目或输入或输出没有填写")
|
message.error("题目或输入或输出没有填写")
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 样例
|
// 样例
|
||||||
else if (problem.value.samples.length == 0) {
|
else if (!isSQLProblem.value && problem.value.samples.length == 0) {
|
||||||
message.error("样例没有填写")
|
message.error("样例没有填写")
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
// 样例是空的
|
// 样例是空的
|
||||||
else if (
|
else if (
|
||||||
|
!isSQLProblem.value &&
|
||||||
problem.value.samples.some(
|
problem.value.samples.some(
|
||||||
(sample) => sample.output === "" || sample.input === "",
|
(sample) => sample.output === "" || sample.input === "",
|
||||||
)
|
)
|
||||||
@@ -332,6 +366,19 @@ async function validateProblem() {
|
|||||||
message.error("编程语言没有选择")
|
message.error("编程语言没有选择")
|
||||||
hasErrors = true
|
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) {
|
else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
|
||||||
if (
|
if (
|
||||||
@@ -379,12 +426,18 @@ function filterAnswers() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterSamplesForSQL() {
|
||||||
|
// SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例
|
||||||
|
if (isSQLProblem.value) problem.value.samples = []
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const hasValidationErrors = await validateProblem()
|
const hasValidationErrors = await validateProblem()
|
||||||
if (hasValidationErrors) return
|
if (hasValidationErrors) return
|
||||||
filterHint()
|
filterHint()
|
||||||
getTemplate()
|
getTemplate()
|
||||||
filterAnswers()
|
filterAnswers()
|
||||||
|
filterSamplesForSQL()
|
||||||
syncProblemTags()
|
syncProblemTags()
|
||||||
const api = {
|
const api = {
|
||||||
"admin problem create": createProblem,
|
"admin problem create": createProblem,
|
||||||
@@ -539,15 +592,16 @@ watch(
|
|||||||
:min-height="300"
|
:min-height="300"
|
||||||
/>
|
/>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
v-if="ready"
|
v-if="ready && !isSQLProblem"
|
||||||
v-model:value="problem.input_description"
|
v-model:value="problem.input_description"
|
||||||
title="输入的描述"
|
title="输入的描述"
|
||||||
/>
|
/>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
v-if="ready"
|
v-if="ready && !isSQLProblem"
|
||||||
v-model:value="problem.output_description"
|
v-model:value="problem.output_description"
|
||||||
title="输出的描述"
|
title="输出的描述"
|
||||||
/>
|
/>
|
||||||
|
<template v-if="!isSQLProblem">
|
||||||
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
|
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
|
||||||
<n-flex justify="space-between" align="center">
|
<n-flex justify="space-between" align="center">
|
||||||
<strong>测试样例 {{ index + 1 }}</strong>
|
<strong>测试样例 {{ index + 1 }}</strong>
|
||||||
@@ -578,6 +632,7 @@ watch(
|
|||||||
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
||||||
添加用例
|
添加用例
|
||||||
</n-button>
|
</n-button>
|
||||||
|
</template>
|
||||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
|
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
|
||||||
<n-form>
|
<n-form>
|
||||||
<n-form-item label="题目的来源(选填)">
|
<n-form-item label="题目的来源(选填)">
|
||||||
@@ -611,7 +666,7 @@ watch(
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</n-checkbox-group>
|
</n-checkbox-group>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item>
|
<n-form-item v-if="!isSQLProblem">
|
||||||
<n-checkbox
|
<n-checkbox
|
||||||
v-model:checked="needTemplate"
|
v-model:checked="needTemplate"
|
||||||
label="预制代码(显示在编辑器中,帮助快速上手)"
|
label="预制代码(显示在编辑器中,帮助快速上手)"
|
||||||
@@ -630,10 +685,37 @@ watch(
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</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-grid :cols="2" x-gap="20">
|
||||||
<n-gi>
|
<n-gi>
|
||||||
<n-form>
|
<n-form>
|
||||||
<n-form-item label="本题参考答案(选填,用于 AI 分析,不会泄露)">
|
<n-form-item
|
||||||
|
:label="
|
||||||
|
isSQLProblem
|
||||||
|
? '标准答案(必填,判题依据:每个测试点会运行它生成期望结果)'
|
||||||
|
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
|
||||||
|
"
|
||||||
|
>
|
||||||
<n-tabs
|
<n-tabs
|
||||||
type="segment"
|
type="segment"
|
||||||
default-value="Python3"
|
default-value="Python3"
|
||||||
@@ -681,7 +763,7 @@ watch(
|
|||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
|
|
||||||
<n-grid :cols="2">
|
<n-grid v-if="!isSQLProblem" :cols="2">
|
||||||
<n-gi :span="1">
|
<n-gi :span="1">
|
||||||
<AstRulesEditor
|
<AstRulesEditor
|
||||||
v-model="problem.ast_rules!"
|
v-model="problem.ast_rules!"
|
||||||
@@ -694,7 +776,7 @@ watch(
|
|||||||
|
|
||||||
<h2 class="title">测试用例区域</h2>
|
<h2 class="title">测试用例区域</h2>
|
||||||
|
|
||||||
<n-flex align="center" style="margin-bottom: 12px">
|
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
|
||||||
<div>
|
<div>
|
||||||
<n-button type="success" @click="showGeneratorModal = true">
|
<n-button type="success" @click="showGeneratorModal = true">
|
||||||
(新)直接生成
|
(新)直接生成
|
||||||
@@ -709,7 +791,7 @@ watch(
|
|||||||
<n-button type="info">(老)手动上传</n-button>
|
<n-button type="info">(老)手动上传</n-button>
|
||||||
</n-upload>
|
</n-upload>
|
||||||
</div>
|
</div>
|
||||||
<n-tooltip placement="right">
|
<n-tooltip placement="right" style="max-width: 320px; white-space: normal">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button text>温馨提醒</n-button>
|
<n-button text>温馨提醒</n-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -717,6 +799,14 @@ watch(
|
|||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
|
<SQLTestcaseEditor
|
||||||
|
v-if="isSQLProblem"
|
||||||
|
:answers="problem.answers"
|
||||||
|
:mode="problem.sql_config?.mode ?? 'query'"
|
||||||
|
:problem-id="problem.id"
|
||||||
|
@uploaded="handleTestcasesGenerated"
|
||||||
|
/>
|
||||||
|
|
||||||
<n-alert
|
<n-alert
|
||||||
class="box"
|
class="box"
|
||||||
v-if="problem.test_case_score.length"
|
v-if="problem.test_case_score.length"
|
||||||
@@ -758,6 +848,7 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
|
<template v-if="!isSQLProblem">
|
||||||
<n-divider />
|
<n-divider />
|
||||||
|
|
||||||
<h2 class="title">流程图区域</h2>
|
<h2 class="title">流程图区域</h2>
|
||||||
@@ -769,7 +860,7 @@ watch(
|
|||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="
|
:disabled="
|
||||||
!problem.answers.filter((a) => a.language === 'Python3')[0].code
|
!problem.answers.filter((a) => a.language === 'Python3')[0]?.code
|
||||||
.length
|
.length
|
||||||
"
|
"
|
||||||
:loading="isAIGenerating"
|
:loading="isAIGenerating"
|
||||||
@@ -800,7 +891,8 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-flex style="margin-bottom: 120px" align="center" justify="end">
|
</template>
|
||||||
|
<n-flex style="margin: 16px 0 120px" align="center" justify="end">
|
||||||
<n-button type="primary" @click="submit">提交</n-button>
|
<n-button type="primary" @click="submit">提交</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NFlex, NSwitch, NTag } from "naive-ui"
|
import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { getTagColor, parseTime } from "utils/functions"
|
import { getTagColor, parseTime } from "utils/functions"
|
||||||
import type { AdminProblemFiltered } from "utils/types"
|
import type { AdminProblemFiltered } from "utils/types"
|
||||||
import { DIFFICULTY } from "utils/constants"
|
import { DIFFICULTY, REACTIONS } from "utils/constants"
|
||||||
import { getProblemList, toggleProblemVisible } from "../api"
|
import { getProblemList, toggleProblemVisible } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
import Modal from "./components/Modal.vue"
|
import Modal from "./components/Modal.vue"
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||||
|
import type { DataTableRowKey } from "naive-ui"
|
||||||
|
import BatchTagModal from "./components/BatchTagModal.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contestID?: string
|
contestID?: string
|
||||||
@@ -36,6 +38,30 @@ const { count, inc } = useCounter(0)
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const problems = ref<AdminProblemFiltered[]>([])
|
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(() => {
|
const nextDisplayID = computed(() => {
|
||||||
if (!isContestProblemList.value) return ""
|
if (!isContestProblemList.value) return ""
|
||||||
if (problems.value.length === 0) return "1"
|
if (problems.value.length === 0) return "1"
|
||||||
@@ -57,7 +83,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
|
|||||||
author: useRouteQuery("author", "").value,
|
author: useRouteQuery("author", "").value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||||
{ title: "ID", key: "id", width: 100 },
|
{ title: "ID", key: "id", width: 100 },
|
||||||
{ title: "显示编号", key: "_id", width: 100 },
|
{ title: "显示编号", key: "_id", width: 100 },
|
||||||
{ title: "标题", key: "title", minWidth: 200 },
|
{ title: "标题", key: "title", minWidth: 200 },
|
||||||
@@ -109,6 +135,21 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
: null,
|
: 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: "username", width: 120 },
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
@@ -141,6 +182,13 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 比赛题目接口不返回 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() {
|
async function listProblems() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
@@ -212,8 +260,20 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
|||||||
>
|
>
|
||||||
年度趋势
|
年度趋势
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="!isContestProblemList"
|
||||||
|
@click="$router.push({ name: 'admin tag list' })"
|
||||||
|
>
|
||||||
|
标签管理
|
||||||
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<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 v-if="isContestProblemList" @click="createContestProblem">
|
||||||
新建比赛题目
|
新建比赛题目
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -238,7 +298,13 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
|||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-data-table striped :columns="columns" :data="problems" />
|
<n-data-table
|
||||||
|
striped
|
||||||
|
:columns="columns"
|
||||||
|
:data="problems"
|
||||||
|
:row-key="rowKey"
|
||||||
|
@update:checked-row-keys="chooseProblems"
|
||||||
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
:total="total"
|
:total="total"
|
||||||
v-model:limit="query.limit"
|
v-model:limit="query.limit"
|
||||||
@@ -250,6 +316,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
|||||||
:next-display-id="nextDisplayID"
|
:next-display-id="nextDisplayID"
|
||||||
@change="listProblems"
|
@change="listProblems"
|
||||||
/>
|
/>
|
||||||
|
<BatchTagModal
|
||||||
|
v-model:show="showBatchTag"
|
||||||
|
:problem-ids="selectedProblemIds"
|
||||||
|
:action="batchTagAction"
|
||||||
|
@done="onBatchTagDone"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
186
src/admin/problem/tags.vue
Normal file
186
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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h } from "vue"
|
import { h } from "vue"
|
||||||
import { ProblemSetBadge } from "utils/types"
|
import type { ProblemSetBadge } from "utils/types"
|
||||||
import { NButton, NImage } from "naive-ui"
|
import { NButton, NImage } from "naive-ui"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ProblemSetBadge } from "utils/types"
|
import type { ProblemSetBadge } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ProblemSetProblem } from "utils/types"
|
import type { ProblemSetProblem } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h } from "vue"
|
import { h } from "vue"
|
||||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||||
import { ProblemSetProblem } from "utils/types"
|
import type { ProblemSetProblem } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
problems: ProblemSetProblem[]
|
problems: ProblemSetProblem[]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { ProblemSet } from "utils/types"
|
import type { ProblemSet } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
problemSet: ProblemSet
|
problemSet: ProblemSet
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { h } from "vue"
|
import { h } from "vue"
|
||||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
import { NDataTable, NButton, NFlex } from "naive-ui"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { ProblemSetProgress } from "utils/types"
|
import type { ProblemSetProgress } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
progress: ProblemSetProgress[]
|
progress: ProblemSetProgress[]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
|
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
|
||||||
import {
|
import type {
|
||||||
ProblemSet,
|
ProblemSet,
|
||||||
ProblemSetProblem,
|
ProblemSetProblem,
|
||||||
ProblemSetBadge,
|
ProblemSetBadge,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CreateProblemSetData, EditProblemSetData } from "utils/types"
|
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
|
||||||
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
|
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NTag } from "naive-ui"
|
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 { parseTime } from "utils/functions"
|
||||||
import { Server } from "utils/types"
|
import type { Server } from "utils/types"
|
||||||
import { useConfigStore } from "shared/store/config"
|
import { useConfigStore } from "shared/store/config"
|
||||||
import { useConfigWebSocket } from "shared/composables/websocket"
|
import { useConfigWebSocket } from "shared/composables/websocket"
|
||||||
import {
|
import {
|
||||||
@@ -140,7 +145,21 @@ async function getWebsiteConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveWebsiteConfig() {
|
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)
|
await editWebsite(websiteConfig)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("保存失败:" + err.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
message.success("网站配置保存成功")
|
message.success("网站配置保存成功")
|
||||||
getWebsiteConfig()
|
getWebsiteConfig()
|
||||||
configStore.getConfig()
|
configStore.getConfig()
|
||||||
@@ -214,7 +233,13 @@ onMounted(() => {
|
|||||||
</n-form>
|
</n-form>
|
||||||
<n-form label-placement="left">
|
<n-form label-placement="left">
|
||||||
<n-form-item label="班级列表">
|
<n-form-item label="班级列表">
|
||||||
|
<n-flex vertical size="small">
|
||||||
<n-dynamic-tags v-model:value="websiteConfig.class_list" />
|
<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-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getRank } from "oj/api"
|
|||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { getACRate } from "utils/functions"
|
import { getACRate } from "utils/functions"
|
||||||
import { Rank } from "utils/types"
|
import type { Rank } from "utils/types"
|
||||||
import { getBaseInfo, randomUser10 } from "../api"
|
import { getBaseInfo, randomUser10 } from "../api"
|
||||||
|
|
||||||
const userCount = ref(0)
|
const userCount = ref(0)
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export function toProblemListItem(result: AdminProblem) {
|
|||||||
has_ast_rules: result.has_ast_rules,
|
has_ast_rules: result.has_ast_rules,
|
||||||
allow_flowchart: result.allow_flowchart,
|
allow_flowchart: result.allow_flowchart,
|
||||||
show_flowchart: result.show_flowchart,
|
show_flowchart: result.show_flowchart,
|
||||||
|
// 比赛题目列表接口不返回这个字段
|
||||||
|
top_reaction: result.top_reaction ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import type {
|
||||||
Exercise,
|
Exercise,
|
||||||
|
ExerciseType,
|
||||||
ExerciseMcqData,
|
ExerciseMcqData,
|
||||||
ExerciseSortData,
|
ExerciseSortData,
|
||||||
ExerciseFillData,
|
ExerciseFillData,
|
||||||
|
ExerciseMatchData,
|
||||||
|
ExercisePredictData,
|
||||||
|
ExerciseDebugData,
|
||||||
|
ExerciseGroupData,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
import {
|
import {
|
||||||
getAdminExercises,
|
getAdminExercises,
|
||||||
@@ -19,7 +24,7 @@ const dialog = useDialog()
|
|||||||
const exercises = ref<Exercise[]>([])
|
const exercises = ref<Exercise[]>([])
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const formType = ref<"mcq" | "sort" | "fill">("mcq")
|
const formType = ref<ExerciseType>("mcq")
|
||||||
const formOrder = ref(0)
|
const formOrder = ref(0)
|
||||||
|
|
||||||
const mcqQuestion = ref("")
|
const mcqQuestion = ref("")
|
||||||
@@ -32,16 +37,34 @@ const sortCode = ref("")
|
|||||||
const fillQuestion = ref("")
|
const fillQuestion = ref("")
|
||||||
const fillCode = 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() {
|
async function load() {
|
||||||
exercises.value = await getAdminExercises(props.tutorialId)
|
exercises.value = await getAdminExercises(props.tutorialId)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
function openCreate() {
|
function resetForms() {
|
||||||
editingId.value = null
|
|
||||||
formType.value = "mcq"
|
|
||||||
formOrder.value = exercises.value.length
|
|
||||||
mcqQuestion.value = ""
|
mcqQuestion.value = ""
|
||||||
mcqOptions.value = ["", ""]
|
mcqOptions.value = ["", ""]
|
||||||
mcqAnswer.value = []
|
mcqAnswer.value = []
|
||||||
@@ -49,6 +72,26 @@ function openCreate() {
|
|||||||
sortCode.value = ""
|
sortCode.value = ""
|
||||||
fillQuestion.value = ""
|
fillQuestion.value = ""
|
||||||
fillCode.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
|
showForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +99,7 @@ function openEdit(ex: Exercise) {
|
|||||||
editingId.value = ex.id
|
editingId.value = ex.id
|
||||||
formType.value = ex.type
|
formType.value = ex.type
|
||||||
formOrder.value = ex.order
|
formOrder.value = ex.order
|
||||||
|
resetForms()
|
||||||
if (ex.type === "mcq") {
|
if (ex.type === "mcq") {
|
||||||
const d = ex.data as ExerciseMcqData
|
const d = ex.data as ExerciseMcqData
|
||||||
mcqQuestion.value = d.question
|
mcqQuestion.value = d.question
|
||||||
@@ -65,10 +109,34 @@ function openEdit(ex: Exercise) {
|
|||||||
const d = ex.data as ExerciseSortData
|
const d = ex.data as ExerciseSortData
|
||||||
sortQuestion.value = d.question
|
sortQuestion.value = d.question
|
||||||
sortCode.value = d.lines.join("\n")
|
sortCode.value = d.lines.join("\n")
|
||||||
} else {
|
} else if (ex.type === "fill") {
|
||||||
const d = ex.data as ExerciseFillData
|
const d = ex.data as ExerciseFillData
|
||||||
fillQuestion.value = d.question
|
fillQuestion.value = d.question
|
||||||
fillCode.value = d.code
|
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
|
showForm.value = true
|
||||||
}
|
}
|
||||||
@@ -79,26 +147,131 @@ function toggleAnswer(i: number) {
|
|||||||
else mcqAnswer.value.splice(idx, 1)
|
else mcqAnswer.value.splice(idx, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
function toggleDebug(i: number) {
|
||||||
if (formType.value === "mcq" && mcqAnswer.value.length === 0) {
|
const idx = debugAnswer.value.indexOf(i)
|
||||||
message.error("请至少勾选一个正确答案")
|
if (idx === -1) debugAnswer.value.push(i)
|
||||||
return
|
else debugAnswer.value.splice(idx, 1)
|
||||||
}
|
}
|
||||||
let data: Record<string, unknown>
|
|
||||||
|
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 (formType.value === "mcq") {
|
||||||
data = {
|
if (mcqAnswer.value.length === 0) {
|
||||||
|
message.error("请至少勾选一个正确答案")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
question: mcqQuestion.value || "下面选项中正确是哪个?",
|
question: mcqQuestion.value || "下面选项中正确是哪个?",
|
||||||
options: mcqOptions.value,
|
options: mcqOptions.value,
|
||||||
answer: mcqAnswer.value,
|
answer: mcqAnswer.value,
|
||||||
}
|
}
|
||||||
} else if (formType.value === "sort") {
|
}
|
||||||
data = {
|
if (formType.value === "sort") {
|
||||||
|
return {
|
||||||
question: sortQuestion.value || "将下列代码行排列为正确顺序",
|
question: sortQuestion.value || "将下列代码行排列为正确顺序",
|
||||||
lines: sortCode.value.split("\n").filter((l) => l.trim() !== ""),
|
lines: sortCode.value.split("\n").filter((l) => l.trim() !== ""),
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
data = { question: fillQuestion.value, code: fillCode.value }
|
|
||||||
}
|
}
|
||||||
|
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 {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
@@ -143,16 +316,35 @@ function copyPlaceholder(id: number) {
|
|||||||
message.success(`已复制 [[exercise:${id}]]`)
|
message.success(`已复制 [[exercise:${id}]]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeName(type: string) {
|
const TYPE_NAMES: Record<ExerciseType, string> = {
|
||||||
if (type === "mcq") return "选择题"
|
mcq: "选择题",
|
||||||
if (type === "sort") return "代码排序"
|
sort: "代码排序",
|
||||||
return "代码填空"
|
fill: "代码填空",
|
||||||
|
match: "连线匹配",
|
||||||
|
predict: "输出预测",
|
||||||
|
debug: "代码找错",
|
||||||
|
group: "归类分组",
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeTagType(type: string): "success" | "info" | "warning" {
|
const TYPE_TAGS: Record<
|
||||||
if (type === "mcq") return "success"
|
ExerciseType,
|
||||||
if (type === "sort") return "info"
|
"success" | "info" | "warning" | "error" | "primary" | "default"
|
||||||
return "warning"
|
> = {
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -208,6 +400,10 @@ function typeTagType(type: string): "success" | "info" | "warning" {
|
|||||||
<n-radio value="mcq">选择题</n-radio>
|
<n-radio value="mcq">选择题</n-radio>
|
||||||
<n-radio value="sort">代码排序</n-radio>
|
<n-radio value="sort">代码排序</n-radio>
|
||||||
<n-radio value="fill">代码填空</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-radio-group>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
@@ -251,7 +447,7 @@ function typeTagType(type: string): "success" | "info" | "warning" {
|
|||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
mcqOptions.splice(i, 1)
|
mcqOptions.splice(i, 1)
|
||||||
mcqAnswer.value = mcqAnswer.value
|
mcqAnswer = mcqAnswer
|
||||||
.filter((a) => a !== i)
|
.filter((a) => a !== i)
|
||||||
.map((a) => (a > i ? a - 1 : a))
|
.map((a) => (a > i ? a - 1 : a))
|
||||||
}
|
}
|
||||||
@@ -287,7 +483,7 @@ function typeTagType(type: string): "success" | "info" | "warning" {
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else-if="formType === 'fill'">
|
||||||
<n-form-item label="题目说明">
|
<n-form-item label="题目说明">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="fillQuestion"
|
v-model:value="fillQuestion"
|
||||||
@@ -306,6 +502,143 @@ function typeTagType(type: string): "success" | "info" | "warning" {
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</template>
|
</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>
|
</n-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||||
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||||
import { Tutorial } from "utils/types"
|
import type { Tutorial } from "utils/types"
|
||||||
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
||||||
import ExerciseManager from "./components/ExerciseManager.vue"
|
import ExerciseManager from "./components/ExerciseManager.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NSwitch } from "naive-ui"
|
import { NSwitch } from "naive-ui"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { Tutorial } from "utils/types"
|
import type { Tutorial } from "utils/types"
|
||||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||||
import { getUserRole } from "utils/functions"
|
import { getUserRole } from "utils/functions"
|
||||||
import { User } from "utils/types"
|
import type { User } from "utils/types"
|
||||||
import TextCopy from "shared/components/TextCopy.vue"
|
import TextCopy from "shared/components/TextCopy.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<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"
|
import { importUsers } from "../api"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -12,6 +18,17 @@ function generateUsers() {
|
|||||||
message.info("请填写相关内容")
|
message.info("请填写相关内容")
|
||||||
return false
|
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}` : ""
|
let className = !!prefix.value ? `ks${prefix.value}` : ""
|
||||||
rawInput.value = rawInput.value.trim()
|
rawInput.value = rawInput.value.trim()
|
||||||
const inputs = rawInput.value.split("\n")
|
const inputs = rawInput.value.split("\n")
|
||||||
@@ -64,9 +81,9 @@ async function submit() {
|
|||||||
style="width: 170px"
|
style="width: 170px"
|
||||||
v-model:value="prefix"
|
v-model:value="prefix"
|
||||||
clearable
|
clearable
|
||||||
:max="9999"
|
:max="CLASS_NAME_MAX_VALUE"
|
||||||
:min="0"
|
:min="0"
|
||||||
placeholder="班级号"
|
:placeholder="`班级号(${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-input
|
<n-input
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { DataTableRowKey, SelectOption } from "naive-ui"
|
|||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { User } from "utils/types"
|
import type { User } from "utils/types"
|
||||||
import {
|
import {
|
||||||
deleteUsers,
|
deleteUsers,
|
||||||
editUser,
|
editUser,
|
||||||
@@ -172,7 +172,6 @@ function createNewUser() {
|
|||||||
problem_permission: "None",
|
problem_permission: "None",
|
||||||
create_time: new Date(),
|
create_time: new Date(),
|
||||||
last_login: new Date(),
|
last_login: new Date(),
|
||||||
two_factor_auth: false,
|
|
||||||
open_api: false,
|
open_api: false,
|
||||||
is_disabled: false,
|
is_disabled: false,
|
||||||
password: "",
|
password: "",
|
||||||
@@ -197,6 +196,9 @@ async function handleEditUser() {
|
|||||||
message.error("密码长度不得小于 6")
|
message.error("密码长度不得小于 6")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// http 拦截器只对 login-required / permission-denied 自动弹提示,
|
||||||
|
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
|
||||||
|
try {
|
||||||
if (create.value) {
|
if (create.value) {
|
||||||
const newUser = [
|
const newUser = [
|
||||||
[
|
[
|
||||||
@@ -209,9 +211,15 @@ async function handleEditUser() {
|
|||||||
await importUsers(newUser)
|
await importUsers(newUser)
|
||||||
listUsers()
|
listUsers()
|
||||||
} else {
|
} else {
|
||||||
const user = Object.assign(userEditing.value, { password: password.value })
|
const user = Object.assign(userEditing.value, {
|
||||||
|
password: password.value,
|
||||||
|
})
|
||||||
await editUser(user)
|
await editUser(user)
|
||||||
}
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error("保存失败:" + err.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
userEditing.value = null
|
userEditing.value = null
|
||||||
password.value = ""
|
password.value = ""
|
||||||
toggleCreate(false)
|
toggleCreate(false)
|
||||||
|
|||||||
10
src/env.d.ts
vendored
10
src/env.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
/// <reference types="@rsbuild/core/types" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_ENV: string
|
readonly PUBLIC_ENV: string
|
||||||
@@ -14,11 +14,3 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Document {
|
|
||||||
startViewTransition?: (callback: () => void | Promise<void>) => {
|
|
||||||
ready: Promise<void>
|
|
||||||
finished: Promise<void>
|
|
||||||
updateCallbackDone: Promise<void>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
27
src/oj/achievement/api.ts
Normal file
27
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
src/oj/achievement/components/AchievementCard.vue
Normal file
134
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
src/oj/achievement/index.vue
Normal file
227
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>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
@click="handleAnalyze"
|
@click="handleAnalyze"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="mingcute:ai-line" />
|
<Icon icon="ph:sparkle" />
|
||||||
</template>
|
</template>
|
||||||
开始分析
|
开始分析
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { NButton, NTooltip } from "naive-ui"
|
import { NButton, NTooltip } from "naive-ui"
|
||||||
import TagTitle from "./TagTitle.vue"
|
import TagTitle from "./TagTitle.vue"
|
||||||
import { FlowchartSummary, SolvedProblem } from "utils/types"
|
import type { FlowchartSummary, SolvedProblem } from "utils/types"
|
||||||
import { useAIStore } from "oj/store/ai"
|
import { useAIStore } from "oj/store/ai"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Pagination from "shared/components/Pagination.vue"
|
|||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { Announcement } from "utils/types"
|
import type { Announcement } from "utils/types"
|
||||||
import TitleWithTag from "./components/TitleWithTag.vue"
|
import TitleWithTag from "./components/TitleWithTag.vue"
|
||||||
|
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { filterResult } from "oj/transforms"
|
|||||||
import type {
|
import type {
|
||||||
Exercise,
|
Exercise,
|
||||||
Problem,
|
Problem,
|
||||||
|
ReactionKey,
|
||||||
|
ReactionState,
|
||||||
Submission,
|
Submission,
|
||||||
SubmissionListPayload,
|
SubmissionListPayload,
|
||||||
SubmitCodePayload,
|
SubmitCodePayload,
|
||||||
@@ -223,22 +225,17 @@ export function getMessageList(offset = 0, limit = 10) {
|
|||||||
return http.get("message", { params: { limit, offset } })
|
return http.get("message", { params: { limit, offset } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createComment(data: {
|
export function getReaction(problemID: number) {
|
||||||
problem_id: number
|
return http.get<ReactionState>("reaction", {
|
||||||
description_rating: number
|
params: { problem_id: problemID },
|
||||||
difficulty_rating: number
|
})
|
||||||
comprehensive_rating: number
|
|
||||||
content: string
|
|
||||||
}) {
|
|
||||||
return http.post("comment", data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getComment(problemID: number) {
|
export function setReaction(problemID: number, type: ReactionKey) {
|
||||||
return http.get("comment", { params: { problem_id: problemID } })
|
return http.post<ReactionState>("reaction", {
|
||||||
}
|
problem_id: problemID,
|
||||||
|
type,
|
||||||
export function getCommentStatistics(problemID: number) {
|
})
|
||||||
return http.get("comment/statistics", { params: { problem_id: problemID } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 这个API有问题
|
// TODO: 这个API有问题
|
||||||
@@ -254,8 +251,8 @@ export function getTutorial(id: number) {
|
|||||||
return http.get("tutorial", { params: { id } })
|
return http.get("tutorial", { params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTutorials() {
|
export function getTutorials(type: "python" | "c") {
|
||||||
return http.get("tutorials")
|
return http.get("tutorials", { params: { type } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAIDetailData(start: string, end: string, username?: string) {
|
export function getAIDetailData(start: string, end: string, username?: string) {
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ const radarChartOptions = {
|
|||||||
style="margin-top: 26px"
|
style="margin-top: 26px"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="mingcute:ai-line" />
|
<Icon icon="ph:sparkle" />
|
||||||
</template>
|
</template>
|
||||||
AI分析
|
AI分析
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -871,7 +871,10 @@ const radarChartOptions = {
|
|||||||
class="stat-avg-ac"
|
class="stat-avg-ac"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon icon="streamline-emojis:chart" width="20" />
|
<Icon
|
||||||
|
icon="streamline-ultimate-color:analytics-pie-2"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-statistic>
|
</n-statistic>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -883,7 +886,10 @@ const radarChartOptions = {
|
|||||||
class="stat-median-ac"
|
class="stat-median-ac"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon icon="streamline-emojis:target" width="20" />
|
<Icon
|
||||||
|
icon="streamline-ultimate-color:cursor-target-1"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-statistic>
|
</n-statistic>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -895,7 +901,10 @@ const radarChartOptions = {
|
|||||||
class="stat-total-submission"
|
class="stat-total-submission"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon icon="streamline-emojis:paper" width="20" />
|
<Icon
|
||||||
|
icon="streamline-ultimate-color:common-file-text"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-statistic>
|
</n-statistic>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -907,7 +916,7 @@ const radarChartOptions = {
|
|||||||
class="stat-ac-rate"
|
class="stat-ac-rate"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon icon="streamline-emojis:check-mark" width="20" />
|
<Icon icon="fluent-emoji:check-mark-button" width="20" />
|
||||||
</template>
|
</template>
|
||||||
</n-statistic>
|
</n-statistic>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ContestRank } from "utils/types"
|
import type { ContestRank } from "utils/types"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rank: ContestRank
|
rank: ContestRank
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { ContestRank } from "utils/types"
|
import type { ContestRank } from "utils/types"
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const passwordFormVisible = computed(
|
|||||||
</n-tag>
|
</n-tag>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="contestStore.isPrivate"
|
v-if="contestStore.isPrivate"
|
||||||
icon="streamline-emojis:locked-with-key"
|
icon="streamline-ultimate-color:shield-lock"
|
||||||
:height="30"
|
:height="30"
|
||||||
></Icon>
|
></Icon>
|
||||||
<h2 class="contestTitle">{{ contestStore.contest.title }}</h2>
|
<h2 class="contestTitle">{{ contestStore.contest.title }}</h2>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ProblemFiltered } from "utils/types"
|
import type { ProblemFiltered } from "utils/types"
|
||||||
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
|
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
@@ -10,7 +10,7 @@ const router = useRouter()
|
|||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
const problemsColumns: DataTableColumn<ProblemFiltered>[] = [
|
const problemsColumns: DataTableColumn<ProblemFiltered>[] = [
|
||||||
{
|
{
|
||||||
title: renderTableTitle("状态", "streamline-emojis:musical-note"),
|
title: renderTableTitle("状态", "streamline-ultimate-color:music-note-1"),
|
||||||
key: "status",
|
key: "status",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (row) => h(ProblemStatus, { status: row.status }),
|
render: (row) => h(ProblemStatus, { status: row.status }),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
|
|||||||
{
|
{
|
||||||
title: renderTableTitle(
|
title: renderTableTitle(
|
||||||
"正确数/总提交",
|
"正确数/总提交",
|
||||||
"streamline-emojis:artist-palette",
|
"streamline-ultimate-color:color-palette",
|
||||||
),
|
),
|
||||||
key: "submission",
|
key: "submission",
|
||||||
width: 140,
|
width: 140,
|
||||||
|
|||||||
121
src/oj/learn/components/ExerciseDebug.vue
Normal file
121
src/oj/learn/components/ExerciseDebug.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Exercise, ExerciseDebugData } from "utils/types"
|
||||||
|
import { highlightLines } from "../composables/useCodeHighlight"
|
||||||
|
import "./exercise-highlight.css"
|
||||||
|
|
||||||
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
|
const data = computed(() => props.exercise.data as ExerciseDebugData)
|
||||||
|
|
||||||
|
const lineHtml = computed(() => highlightLines(data.value.lines, props.lang))
|
||||||
|
const selected = ref<Set<number>>(new Set())
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.exercise.id, reset, { immediate: true })
|
||||||
|
|
||||||
|
const allCorrect = computed(() => {
|
||||||
|
const ans = new Set(data.value.answer)
|
||||||
|
if (selected.value.size !== ans.size) return false
|
||||||
|
for (const i of selected.value) if (!ans.has(i)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const locked = computed(() => submitted.value && allCorrect.value)
|
||||||
|
|
||||||
|
function toggle(i: number) {
|
||||||
|
if (locked.value) return
|
||||||
|
submitted.value = false
|
||||||
|
const s = new Set(selected.value)
|
||||||
|
if (s.has(i)) s.delete(i)
|
||||||
|
else s.add(i)
|
||||||
|
selected.value = s
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineStatus(i: number): "correct" | "wrong" | "selected" | "default" {
|
||||||
|
if (!submitted.value) return selected.value.has(i) ? "selected" : "default"
|
||||||
|
const isAns = data.value.answer.includes(i)
|
||||||
|
const isSel = selected.value.has(i)
|
||||||
|
if (isAns) return isSel ? "correct" : "wrong" // 漏选也标红
|
||||||
|
if (isSel) return "wrong"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineStyle(i: number): Record<string, string> {
|
||||||
|
const status = lineStatus(i)
|
||||||
|
const color =
|
||||||
|
status === "correct"
|
||||||
|
? "#18a058"
|
||||||
|
: status === "wrong"
|
||||||
|
? "#d03050"
|
||||||
|
: status === "selected"
|
||||||
|
? "#2080f0"
|
||||||
|
: "var(--n-border-color)"
|
||||||
|
const plain = color === "var(--n-border-color)"
|
||||||
|
return {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: `1.5px solid ${color}`,
|
||||||
|
background: plain ? "transparent" : color + "14",
|
||||||
|
cursor: locked.value ? "default" : "pointer",
|
||||||
|
fontFamily: "Monaco",
|
||||||
|
userSelect: "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
submitted.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
selected.value = new Set()
|
||||||
|
submitted.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||||
|
<template #header>
|
||||||
|
<n-tag type="info" :bordered="false">练一练 · 代码找错</n-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
|
||||||
|
{{ data.question }}
|
||||||
|
</p>
|
||||||
|
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
|
||||||
|
点击你认为有错误的代码行(可多选)。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<n-space vertical :size="6">
|
||||||
|
<div
|
||||||
|
v-for="(line, idx) in data.lines"
|
||||||
|
:key="idx"
|
||||||
|
:style="lineStyle(idx)"
|
||||||
|
@click="toggle(idx)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0"
|
||||||
|
>
|
||||||
|
{{ idx + 1 }}
|
||||||
|
</span>
|
||||||
|
<span v-html="lineHtml[idx]" style="white-space: pre" />
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-alert
|
||||||
|
v-if="submitted"
|
||||||
|
:type="allCorrect ? 'success' : 'error'"
|
||||||
|
:title="allCorrect ? '找对了!' : '还没找全,红色行是错误所在'"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
>
|
||||||
|
<template v-if="submitted && data.explanation" #default>
|
||||||
|
{{ data.explanation }}
|
||||||
|
</template>
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-space style="margin-top: 12px" :size="8">
|
||||||
|
<n-button type="info" :disabled="locked" @click="submit">提交</n-button>
|
||||||
|
<n-button @click="reset">重置</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import hljs from "highlight.js/lib/core"
|
|
||||||
import python from "highlight.js/lib/languages/python"
|
|
||||||
import c from "highlight.js/lib/languages/c"
|
|
||||||
import type { Exercise, ExerciseFillData } from "utils/types"
|
import type { Exercise, ExerciseFillData } from "utils/types"
|
||||||
|
import { highlight } from "../composables/useCodeHighlight"
|
||||||
hljs.registerLanguage("python", python)
|
import "./exercise-highlight.css"
|
||||||
hljs.registerLanguage("c", c)
|
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
const data = computed(() => props.exercise.data as ExerciseFillData)
|
const data = computed(() => props.exercise.data as ExerciseFillData)
|
||||||
@@ -14,10 +10,6 @@ type CodeSeg = { type: "code"; html: string }
|
|||||||
type BlankSeg = { type: "blank"; answers: string[]; index: number }
|
type BlankSeg = { type: "blank"; answers: string[]; index: number }
|
||||||
type Segment = CodeSeg | BlankSeg
|
type Segment = CodeSeg | BlankSeg
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = computed<Segment[]>(() => {
|
const segments = computed<Segment[]>(() => {
|
||||||
const blanks: string[][] = []
|
const blanks: string[][] = []
|
||||||
const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
|
const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
|
||||||
@@ -25,18 +17,7 @@ const segments = computed<Segment[]>(() => {
|
|||||||
return `____${blanks.length - 1}____`
|
return `____${blanks.length - 1}____`
|
||||||
})
|
})
|
||||||
|
|
||||||
const lang =
|
const highlighted = highlight(markedCode, props.lang)
|
||||||
props.lang === "python" ? "python" : props.lang === "c" ? "c" : null
|
|
||||||
let highlighted: string
|
|
||||||
if (lang) {
|
|
||||||
try {
|
|
||||||
highlighted = hljs.highlight(markedCode, { language: lang }).value
|
|
||||||
} catch {
|
|
||||||
highlighted = escapeHtml(markedCode)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
highlighted = escapeHtml(markedCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = highlighted.split(/____(\d+)____/)
|
const parts = highlighted.split(/____(\d+)____/)
|
||||||
const result: Segment[] = []
|
const result: Segment[] = []
|
||||||
|
|||||||
183
src/oj/learn/components/ExerciseGroup.vue
Normal file
183
src/oj/learn/components/ExerciseGroup.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Exercise, ExerciseGroupData } from "utils/types"
|
||||||
|
import { shuffle } from "../composables/useShuffle"
|
||||||
|
|
||||||
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
|
const data = computed(() => props.exercise.data as ExerciseGroupData)
|
||||||
|
|
||||||
|
const order = ref<number[]>([]) // item 的稳定展示顺序(初始乱序)
|
||||||
|
const placement = ref<number[]>([]) // placement[itemIdx] = 桶下标,-1 表示在池中
|
||||||
|
const dragIdx = ref<number | null>(null)
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
order.value = shuffle(data.value.items.map((_, i) => i))
|
||||||
|
placement.value = Array(data.value.items.length).fill(-1)
|
||||||
|
dragIdx.value = null
|
||||||
|
submitted.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
watch(() => props.exercise.id, init)
|
||||||
|
|
||||||
|
const allPlaced = computed(() => placement.value.every((p) => p !== -1))
|
||||||
|
const allCorrect = computed(() =>
|
||||||
|
placement.value.every((p, i) => p === data.value.answer[i]),
|
||||||
|
)
|
||||||
|
const locked = computed(() => submitted.value && allCorrect.value)
|
||||||
|
|
||||||
|
function onDragStart(i: number) {
|
||||||
|
if (locked.value) return
|
||||||
|
dragIdx.value = i
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropTo(bucket: number) {
|
||||||
|
if (locked.value || dragIdx.value === null) return
|
||||||
|
placement.value[dragIdx.value] = bucket
|
||||||
|
dragIdx.value = null
|
||||||
|
submitted.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolItems = computed(() =>
|
||||||
|
order.value.filter((i) => placement.value[i] === -1),
|
||||||
|
)
|
||||||
|
function itemsIn(bucket: number): number[] {
|
||||||
|
return order.value.filter((i) => placement.value[i] === bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemStatus(i: number): "correct" | "wrong" | "default" {
|
||||||
|
if (!submitted.value || placement.value[i] === -1) return "default"
|
||||||
|
return placement.value[i] === data.value.answer[i] ? "correct" : "wrong"
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipStyle(i: number): Record<string, string> {
|
||||||
|
const status = itemStatus(i)
|
||||||
|
const color =
|
||||||
|
status === "correct"
|
||||||
|
? "#18a058"
|
||||||
|
: status === "wrong"
|
||||||
|
? "#d03050"
|
||||||
|
: "var(--n-border-color)"
|
||||||
|
const plain = color === "var(--n-border-color)"
|
||||||
|
return {
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: `1.5px solid ${color}`,
|
||||||
|
background: plain ? "var(--n-color)" : color + "14",
|
||||||
|
cursor: locked.value ? "default" : "grab",
|
||||||
|
userSelect: "none",
|
||||||
|
fontSize: "15px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
submitted.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||||
|
<template #header>
|
||||||
|
<n-tag type="warning" :bordered="false">练一练 · 归类分组</n-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
|
||||||
|
{{ data.question }}
|
||||||
|
</p>
|
||||||
|
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
|
||||||
|
把下面的项目拖到对应的分组里,可在分组间拖动调整。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
minHeight: '48px',
|
||||||
|
padding: '10px',
|
||||||
|
border: '1.5px dashed var(--n-border-color)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '14px',
|
||||||
|
}"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="dropTo(-1)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="poolItems.length === 0"
|
||||||
|
style="color: var(--n-text-color-3); font-size: 13px"
|
||||||
|
>
|
||||||
|
(已全部归类)
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-for="i in poolItems"
|
||||||
|
:key="i"
|
||||||
|
draggable="true"
|
||||||
|
:style="chipStyle(i)"
|
||||||
|
@dragstart="onDragStart(i)"
|
||||||
|
>
|
||||||
|
{{ data.items[i] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${data.buckets.length}, 1fr)`,
|
||||||
|
gap: '12px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(bucket, b) in data.buckets"
|
||||||
|
:key="b"
|
||||||
|
:style="{
|
||||||
|
minHeight: '88px',
|
||||||
|
padding: '10px',
|
||||||
|
border: '1.5px solid var(--n-border-color)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="dropTo(b)"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ bucket }}
|
||||||
|
</p>
|
||||||
|
<n-space :size="8">
|
||||||
|
<div
|
||||||
|
v-for="i in itemsIn(b)"
|
||||||
|
:key="i"
|
||||||
|
draggable="true"
|
||||||
|
:style="chipStyle(i)"
|
||||||
|
@dragstart="onDragStart(i)"
|
||||||
|
>
|
||||||
|
{{ data.items[i] }}
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-alert
|
||||||
|
v-if="submitted"
|
||||||
|
:type="allCorrect ? 'success' : 'error'"
|
||||||
|
:title="allCorrect ? '归类全部正确!' : '有归类错误,红色项需要调整'"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<n-space style="margin-top: 12px" :size="8">
|
||||||
|
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit">
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
<n-button @click="reset">重置</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
184
src/oj/learn/components/ExerciseMatch.vue
Normal file
184
src/oj/learn/components/ExerciseMatch.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Exercise, ExerciseMatchData } from "utils/types"
|
||||||
|
import { shuffle } from "../composables/useShuffle"
|
||||||
|
|
||||||
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
|
const data = computed(() => props.exercise.data as ExerciseMatchData)
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
"#2080f0",
|
||||||
|
"#18a058",
|
||||||
|
"#f0a020",
|
||||||
|
"#d03050",
|
||||||
|
"#8a2be2",
|
||||||
|
"#0891b2",
|
||||||
|
"#db2777",
|
||||||
|
"#65a30d",
|
||||||
|
]
|
||||||
|
|
||||||
|
const rightOrder = ref<number[]>([]) // 显示顺序里的 right 原始下标
|
||||||
|
const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原始下标
|
||||||
|
const selectedLeft = ref<number | null>(null)
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const n = data.value.right.length
|
||||||
|
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
|
||||||
|
pairs.value = Array(data.value.left.length).fill(null)
|
||||||
|
selectedLeft.value = null
|
||||||
|
submitted.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(init)
|
||||||
|
watch(() => props.exercise.id, init)
|
||||||
|
|
||||||
|
const allPaired = computed(() => pairs.value.every((p) => p !== null))
|
||||||
|
const allCorrect = computed(() =>
|
||||||
|
pairs.value.every((p, i) => p === data.value.answer[i]),
|
||||||
|
)
|
||||||
|
const locked = computed(() => submitted.value && allCorrect.value)
|
||||||
|
|
||||||
|
function leftOf(rightIdx: number): number {
|
||||||
|
return pairs.value.findIndex((p) => p === rightIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeftClick(i: number) {
|
||||||
|
if (locked.value) return
|
||||||
|
submitted.value = false
|
||||||
|
if (pairs.value[i] !== null) {
|
||||||
|
pairs.value[i] = null
|
||||||
|
selectedLeft.value = i
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedLeft.value = selectedLeft.value === i ? null : i
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRightClick(rightIdx: number) {
|
||||||
|
if (locked.value) return
|
||||||
|
submitted.value = false
|
||||||
|
if (selectedLeft.value === null) {
|
||||||
|
const l = leftOf(rightIdx)
|
||||||
|
if (l !== -1) pairs.value[l] = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prev = leftOf(rightIdx)
|
||||||
|
if (prev !== -1) pairs.value[prev] = null
|
||||||
|
pairs.value[selectedLeft.value] = rightIdx
|
||||||
|
selectedLeft.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
submitted.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
function leftColor(i: number): string {
|
||||||
|
if (submitted.value) {
|
||||||
|
if (pairs.value[i] === null) return "#d03050"
|
||||||
|
return pairs.value[i] === data.value.answer[i] ? "#18a058" : "#d03050"
|
||||||
|
}
|
||||||
|
if (selectedLeft.value === i) return "#2080f0"
|
||||||
|
if (pairs.value[i] !== null) return PALETTE[i % PALETTE.length]
|
||||||
|
return "var(--n-border-color)"
|
||||||
|
}
|
||||||
|
|
||||||
|
function rightColor(rightIdx: number): string {
|
||||||
|
const l = leftOf(rightIdx)
|
||||||
|
if (submitted.value) {
|
||||||
|
if (l === -1) return "var(--n-border-color)"
|
||||||
|
return pairs.value[l] === data.value.answer[l] ? "#18a058" : "#d03050"
|
||||||
|
}
|
||||||
|
if (l === -1) return "var(--n-border-color)"
|
||||||
|
return PALETTE[l % PALETTE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemStyle(color: string, selected: boolean): Record<string, string> {
|
||||||
|
const plain = color === "var(--n-border-color)"
|
||||||
|
return {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: `${selected ? "2px" : "1.5px"} solid ${color}`,
|
||||||
|
background: plain ? "transparent" : color + "14",
|
||||||
|
cursor: locked.value ? "default" : "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
fontSize: "15px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotStyle(color: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
width: "10px",
|
||||||
|
height: "10px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
flexShrink: "0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||||
|
<template #header>
|
||||||
|
<n-tag type="primary" :bordered="false">练一练 · 连线匹配</n-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
|
||||||
|
{{ data.question }}
|
||||||
|
</p>
|
||||||
|
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
|
||||||
|
先点左边一项,再点右边一项即可连线;点击已连线的项可取消。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 24px; align-items: flex-start">
|
||||||
|
<n-space vertical :size="8" style="flex: 1">
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in data.left"
|
||||||
|
:key="'l' + i"
|
||||||
|
:style="itemStyle(leftColor(i), selectedLeft === i)"
|
||||||
|
@click="onLeftClick(i)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="pairs[i] !== null && !submitted"
|
||||||
|
:style="dotStyle(PALETTE[i % PALETTE.length])"
|
||||||
|
/>
|
||||||
|
<span>{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-space vertical :size="8" style="flex: 1">
|
||||||
|
<div
|
||||||
|
v-for="rightIdx in rightOrder"
|
||||||
|
:key="'r' + rightIdx"
|
||||||
|
:style="itemStyle(rightColor(rightIdx), false)"
|
||||||
|
@click="onRightClick(rightIdx)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="leftOf(rightIdx) !== -1 && !submitted"
|
||||||
|
:style="dotStyle(PALETTE[leftOf(rightIdx) % PALETTE.length])"
|
||||||
|
/>
|
||||||
|
<span>{{ data.right[rightIdx] }}</span>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-alert
|
||||||
|
v-if="submitted"
|
||||||
|
:type="allCorrect ? 'success' : 'error'"
|
||||||
|
:title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<n-space style="margin-top: 12px" :size="8">
|
||||||
|
<n-button type="primary" :disabled="!allPaired || locked" @click="submit">
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
<n-button @click="reset">重置</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Exercise, ExerciseMcqData } from "utils/types"
|
import type { Exercise, ExerciseMcqData } from "utils/types"
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise }>()
|
const props = defineProps<{ exercise: Exercise }>()
|
||||||
const data = computed(() => props.exercise.data as ExerciseMcqData)
|
const data = computed(() => props.exercise.data as ExerciseMcqData)
|
||||||
|
|||||||
92
src/oj/learn/components/ExercisePredict.vue
Normal file
92
src/oj/learn/components/ExercisePredict.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Exercise, ExercisePredictData } from "utils/types"
|
||||||
|
import { highlight } from "../composables/useCodeHighlight"
|
||||||
|
import "./exercise-highlight.css"
|
||||||
|
|
||||||
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
|
const data = computed(() => props.exercise.data as ExercisePredictData)
|
||||||
|
|
||||||
|
const codeHtml = computed(() => highlight(data.value.code, props.lang))
|
||||||
|
|
||||||
|
const userInput = ref("")
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.exercise.id, reset, { immediate: true })
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.replace(/\s+$/, ""))
|
||||||
|
.join("\n")
|
||||||
|
.replace(/^\n+/, "")
|
||||||
|
.replace(/\n+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCorrect = computed(() =>
|
||||||
|
data.value.answer.some((a) => normalize(a) === normalize(userInput.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
submitted.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
userInput.value = ""
|
||||||
|
submitted.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||||
|
<template #header>
|
||||||
|
<n-tag type="error" :bordered="false">练一练 · 输出预测</n-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
|
||||||
|
{{ data.question }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<pre
|
||||||
|
:style="{
|
||||||
|
fontFamily: 'Monaco',
|
||||||
|
fontSize: '16px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
background: 'var(--n-color)',
|
||||||
|
border: '1px solid var(--n-border-color)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '12px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
margin: 0,
|
||||||
|
}"
|
||||||
|
><code v-html="codeHtml" /></pre>
|
||||||
|
|
||||||
|
<p style="font-weight: 500; margin: 14px 0 8px">这段代码会输出什么?</p>
|
||||||
|
<n-input
|
||||||
|
v-model:value="userInput"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:disabled="submitted && allCorrect"
|
||||||
|
placeholder="在这里输入程序会打印的内容"
|
||||||
|
style="font-family: Monaco"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<n-alert
|
||||||
|
v-if="submitted"
|
||||||
|
:type="allCorrect ? 'success' : 'error'"
|
||||||
|
:title="allCorrect ? '输出正确!' : '输出不正确,再读读代码看看'"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<n-space style="margin-top: 12px" :size="8">
|
||||||
|
<n-button
|
||||||
|
type="error"
|
||||||
|
:disabled="submitted && allCorrect"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</n-button>
|
||||||
|
<n-button @click="reset">重置</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import hljs from "highlight.js/lib/core"
|
import type { Exercise, ExerciseSortData } from "utils/types"
|
||||||
import python from "highlight.js/lib/languages/python"
|
import { shuffle } from "../composables/useShuffle"
|
||||||
import c from "highlight.js/lib/languages/c"
|
import { highlightLines } from "../composables/useCodeHighlight"
|
||||||
import { Exercise, ExerciseSortData } from "utils/types"
|
import "./exercise-highlight.css"
|
||||||
|
|
||||||
hljs.registerLanguage("python", python)
|
|
||||||
hljs.registerLanguage("c", c)
|
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
const data = computed(() => props.exercise.data as ExerciseSortData)
|
const data = computed(() => props.exercise.data as ExerciseSortData)
|
||||||
@@ -15,21 +12,16 @@ type LineItem = { originalIdx: number; text: string }
|
|||||||
const lines = ref<LineItem[]>([])
|
const lines = ref<LineItem[]>([])
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
|
|
||||||
function shuffle(arr: LineItem[]): LineItem[] {
|
|
||||||
const a = [...arr]
|
|
||||||
for (let i = a.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
;[a[i], a[j]] = [a[j], a[i]]
|
|
||||||
}
|
|
||||||
const isCorrect = a.every((item, i) => item.originalIdx === i)
|
|
||||||
if (isCorrect && a.length > 1) [a[0], a[1]] = [a[1], a[0]]
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
lines.value = shuffle(
|
const shuffled = shuffle(
|
||||||
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })),
|
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })),
|
||||||
)
|
)
|
||||||
|
// 打乱后若恰好与原顺序一致,交换前两项,避免一进入就是已解出状态
|
||||||
|
const isCorrect = shuffled.every((item, i) => item.originalIdx === i)
|
||||||
|
if (isCorrect && shuffled.length > 1) {
|
||||||
|
;[shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]]
|
||||||
|
}
|
||||||
|
lines.value = shuffled
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,35 +61,9 @@ function reset() {
|
|||||||
init()
|
init()
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
const lineHtml = computed<string[]>(() =>
|
||||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
highlightLines(data.value.lines, props.lang),
|
||||||
}
|
)
|
||||||
|
|
||||||
const lineHtmlMap = computed<Record<number, string>>(() => {
|
|
||||||
const rawLines = data.value.lines
|
|
||||||
const map: Record<number, string> = {}
|
|
||||||
const lang =
|
|
||||||
props.lang === "python" ? "python" : props.lang === "c" ? "c" : null
|
|
||||||
|
|
||||||
if (lang) {
|
|
||||||
try {
|
|
||||||
const result = hljs.highlight(rawLines.join("\n"), {
|
|
||||||
language: lang,
|
|
||||||
}).value
|
|
||||||
result.split("\n").forEach((html, i) => {
|
|
||||||
map[i] = html
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// fall through
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rawLines.forEach((text, i) => {
|
|
||||||
if (map[i] === undefined) map[i] = escapeHtml(text)
|
|
||||||
})
|
|
||||||
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -143,7 +109,7 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
|||||||
@drop="onDrop(idx)"
|
@drop="onDrop(idx)"
|
||||||
>
|
>
|
||||||
<span style="color: #bbb; cursor: grab">⠿</span>
|
<span style="color: #bbb; cursor: grab">⠿</span>
|
||||||
<span v-html="lineHtmlMap[line.originalIdx]" style="white-space: pre" />
|
<span v-html="lineHtml[line.originalIdx]" style="white-space: pre" />
|
||||||
</div>
|
</div>
|
||||||
</n-space>
|
</n-space>
|
||||||
|
|
||||||
@@ -162,55 +128,3 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
|||||||
</n-space>
|
</n-space>
|
||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.hljs-keyword,
|
|
||||||
.hljs-operator,
|
|
||||||
.hljs-selector-tag {
|
|
||||||
color: #d73a49;
|
|
||||||
}
|
|
||||||
.hljs-string,
|
|
||||||
.hljs-regexp,
|
|
||||||
.hljs-template-literal {
|
|
||||||
color: #032f62;
|
|
||||||
}
|
|
||||||
.hljs-comment,
|
|
||||||
.hljs-quote {
|
|
||||||
color: #6a737d;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.hljs-number,
|
|
||||||
.hljs-literal {
|
|
||||||
color: #005cc5;
|
|
||||||
}
|
|
||||||
.hljs-built_in,
|
|
||||||
.hljs-title.function_,
|
|
||||||
.hljs-class .hljs-title {
|
|
||||||
color: #6f42c1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .hljs-keyword,
|
|
||||||
.dark .hljs-operator,
|
|
||||||
.dark .hljs-selector-tag {
|
|
||||||
color: #c678dd;
|
|
||||||
}
|
|
||||||
.dark .hljs-string,
|
|
||||||
.dark .hljs-regexp,
|
|
||||||
.dark .hljs-template-literal {
|
|
||||||
color: #98c379;
|
|
||||||
}
|
|
||||||
.dark .hljs-comment,
|
|
||||||
.dark .hljs-quote {
|
|
||||||
color: #7f848e;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.dark .hljs-number,
|
|
||||||
.dark .hljs-literal {
|
|
||||||
color: #e5c07b;
|
|
||||||
}
|
|
||||||
.dark .hljs-built_in,
|
|
||||||
.dark .hljs-title.function_,
|
|
||||||
.dark .hljs-class .hljs-title {
|
|
||||||
color: #61afef;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Exercise } from "utils/types"
|
import type { Exercise } from "utils/types"
|
||||||
|
|
||||||
const ExerciseMcq = defineAsyncComponent(() => import("./ExerciseMcq.vue"))
|
const ExerciseMcq = defineAsyncComponent(() => import("./ExerciseMcq.vue"))
|
||||||
const ExerciseSort = defineAsyncComponent(() => import("./ExerciseSort.vue"))
|
const ExerciseSort = defineAsyncComponent(() => import("./ExerciseSort.vue"))
|
||||||
const ExerciseFill = defineAsyncComponent(() => import("./ExerciseFill.vue"))
|
const ExerciseFill = defineAsyncComponent(() => import("./ExerciseFill.vue"))
|
||||||
|
const ExerciseMatch = defineAsyncComponent(() => import("./ExerciseMatch.vue"))
|
||||||
|
const ExercisePredict = defineAsyncComponent(
|
||||||
|
() => import("./ExercisePredict.vue"),
|
||||||
|
)
|
||||||
|
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
|
||||||
|
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
|
||||||
|
|
||||||
defineProps<{ exercise: Exercise; lang?: string }>()
|
defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
</script>
|
</script>
|
||||||
@@ -20,4 +26,16 @@ defineProps<{ exercise: Exercise; lang?: string }>()
|
|||||||
:exercise="exercise"
|
:exercise="exercise"
|
||||||
:lang="lang"
|
:lang="lang"
|
||||||
/>
|
/>
|
||||||
|
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" />
|
||||||
|
<ExercisePredict
|
||||||
|
v-else-if="exercise.type === 'predict'"
|
||||||
|
:exercise="exercise"
|
||||||
|
:lang="lang"
|
||||||
|
/>
|
||||||
|
<ExerciseDebug
|
||||||
|
v-else-if="exercise.type === 'debug'"
|
||||||
|
:exercise="exercise"
|
||||||
|
:lang="lang"
|
||||||
|
/>
|
||||||
|
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
50
src/oj/learn/components/exercise-highlight.css
Normal file
50
src/oj/learn/components/exercise-highlight.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/* 练一练代码高亮配色(明 / 暗),由涉及代码高亮的题型组件统一引入 */
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-operator,
|
||||||
|
.hljs-selector-tag {
|
||||||
|
color: #d73a49;
|
||||||
|
}
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-template-literal {
|
||||||
|
color: #032f62;
|
||||||
|
}
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #6a737d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-literal {
|
||||||
|
color: #005cc5;
|
||||||
|
}
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-title.function_,
|
||||||
|
.hljs-class .hljs-title {
|
||||||
|
color: #6f42c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hljs-keyword,
|
||||||
|
.dark .hljs-operator,
|
||||||
|
.dark .hljs-selector-tag {
|
||||||
|
color: #c678dd;
|
||||||
|
}
|
||||||
|
.dark .hljs-string,
|
||||||
|
.dark .hljs-regexp,
|
||||||
|
.dark .hljs-template-literal {
|
||||||
|
color: #98c379;
|
||||||
|
}
|
||||||
|
.dark .hljs-comment,
|
||||||
|
.dark .hljs-quote {
|
||||||
|
color: #7f848e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.dark .hljs-number,
|
||||||
|
.dark .hljs-literal {
|
||||||
|
color: #e5c07b;
|
||||||
|
}
|
||||||
|
.dark .hljs-built_in,
|
||||||
|
.dark .hljs-title.function_,
|
||||||
|
.dark .hljs-class .hljs-title {
|
||||||
|
color: #61afef;
|
||||||
|
}
|
||||||
41
src/oj/learn/composables/useCodeHighlight.ts
Normal file
41
src/oj/learn/composables/useCodeHighlight.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import hljs from "highlight.js/lib/core"
|
||||||
|
import python from "highlight.js/lib/languages/python"
|
||||||
|
import c from "highlight.js/lib/languages/c"
|
||||||
|
|
||||||
|
hljs.registerLanguage("python", python)
|
||||||
|
hljs.registerLanguage("c", c)
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLang(lang?: string): "python" | "c" | null {
|
||||||
|
return lang === "python" ? "python" : lang === "c" ? "c" : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把整段代码高亮为 HTML;不支持的语言或异常时回退为转义文本
|
||||||
|
export function highlight(code: string, lang?: string): string {
|
||||||
|
const language = normalizeLang(lang)
|
||||||
|
if (language) {
|
||||||
|
try {
|
||||||
|
return hljs.highlight(code, { language }).value
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escapeHtml(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按行高亮:整体高亮后再按行切分,保证跨行 token 着色正确,返回逐行 HTML 数组
|
||||||
|
export function highlightLines(lines: string[], lang?: string): string[] {
|
||||||
|
const language = normalizeLang(lang)
|
||||||
|
if (language) {
|
||||||
|
try {
|
||||||
|
const html = hljs.highlight(lines.join("\n"), { language }).value
|
||||||
|
return html.split("\n")
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.map((line) => escapeHtml(line))
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Exercise } from "utils/types"
|
import type { Exercise } from "utils/types"
|
||||||
|
|
||||||
type Segment =
|
type Segment =
|
||||||
| { type: "md"; content: string }
|
{ type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
|
||||||
| { type: "exercise"; exercise: Exercise }
|
|
||||||
|
|
||||||
export function parseExercises(
|
export function parseExercises(
|
||||||
content: string,
|
content: string,
|
||||||
|
|||||||
9
src/oj/learn/composables/useShuffle.ts
Normal file
9
src/oj/learn/composables/useShuffle.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Fisher–Yates 洗牌,返回新数组,不修改原数组
|
||||||
|
export function shuffle<T>(arr: T[]): T[] {
|
||||||
|
const a = [...arr]
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[a[i], a[j]] = [a[j], a[i]]
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
content-style="height: calc(100% - 44px); padding: 0;"
|
content-style="height: calc(100% - 44px); padding: 0;"
|
||||||
>
|
>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
language="Python3"
|
:language="editorLanguage"
|
||||||
v-model="tutorial.code"
|
v-model="tutorial.code"
|
||||||
height="100%"
|
height="100%"
|
||||||
/>
|
/>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|
||||||
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
|
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
|
||||||
<CodeEditor language="Python3" v-model="tutorial.code" />
|
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
|
|
||||||
@@ -128,16 +128,23 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<n-empty
|
||||||
|
v-if="isEmpty"
|
||||||
|
description="该教程还没有公开"
|
||||||
|
style="margin-top: 80px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
import type { Tutorial, Exercise } from "utils/types"
|
import type { Tutorial, Exercise, LANGUAGE } from "utils/types"
|
||||||
import { getTutorial, getTutorials, getExercises } from "../api"
|
import { getTutorial, getTutorials, getExercises } from "../api"
|
||||||
import { parseExercises } from "./composables/useExerciseParse"
|
import { parseExercises } from "./composables/useExerciseParse"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import { useLearnProgress } from "shared/composables/learnProgress"
|
||||||
|
|
||||||
const ExerciseWidget = defineAsyncComponent(
|
const ExerciseWidget = defineAsyncComponent(
|
||||||
() => import("./components/ExerciseWidget.vue"),
|
() => import("./components/ExerciseWidget.vue"),
|
||||||
@@ -150,21 +157,32 @@ const isDark = useDark()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
const { learnStep } = useLearnProgress()
|
||||||
|
|
||||||
const step = computed(() => {
|
const step = computed(() => {
|
||||||
if (!route.params.step || !route.params.step.length) return 1
|
const value = route.params.step as string | undefined
|
||||||
return parseInt(route.params.step[0])
|
if (!value) return 1
|
||||||
|
return parseInt(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const type = computed<"python" | "c">(() =>
|
||||||
|
route.params.type === "c" ? "c" : "python",
|
||||||
|
)
|
||||||
|
|
||||||
const tutorial = ref<Partial<Tutorial>>({
|
const tutorial = ref<Partial<Tutorial>>({
|
||||||
id: 0,
|
id: 0,
|
||||||
title: "",
|
title: "",
|
||||||
content: "",
|
content: "",
|
||||||
code: "",
|
code: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const editorLanguage = computed<LANGUAGE>(() =>
|
||||||
|
tutorial.value.type === "c" ? "C" : "Python3",
|
||||||
|
)
|
||||||
const titles = ref<{ id: number; title: string }[]>([])
|
const titles = ref<{ id: number; title: string }[]>([])
|
||||||
const exercises = ref<Exercise[]>([])
|
const exercises = ref<Exercise[]>([])
|
||||||
const activeTab = ref("content")
|
const activeTab = ref("content")
|
||||||
|
const isEmpty = ref(false)
|
||||||
|
|
||||||
const segments = computed(() =>
|
const segments = computed(() =>
|
||||||
parseExercises(tutorial.value.content ?? "", exercises.value),
|
parseExercises(tutorial.value.content ?? "", exercises.value),
|
||||||
@@ -175,7 +193,9 @@ const isLastLesson = computed(() => step.value === titles.value.length)
|
|||||||
|
|
||||||
function goToLesson(lessonNumber: number) {
|
function goToLesson(lessonNumber: number) {
|
||||||
activeTab.value = "content"
|
activeTab.value = "content"
|
||||||
router.push("/learn/" + lessonNumber.toString().padStart(2, "0"))
|
router.push(
|
||||||
|
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
function goToPrevLesson() {
|
function goToPrevLesson() {
|
||||||
if (step.value > 1) goToLesson(step.value - 1)
|
if (step.value > 1) goToLesson(step.value - 1)
|
||||||
@@ -185,9 +205,10 @@ function goToNextLesson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const res1 = await getTutorials()
|
const res1 = await getTutorials(type.value)
|
||||||
titles.value = res1.data
|
titles.value = res1.data
|
||||||
if (titles.value.length === 0) return
|
isEmpty.value = titles.value.length === 0
|
||||||
|
if (isEmpty.value) return
|
||||||
const id = titles.value[step.value - 1].id
|
const id = titles.value[step.value - 1].id
|
||||||
const [res2, exs] = await Promise.allSettled([
|
const [res2, exs] = await Promise.allSettled([
|
||||||
getTutorial(id),
|
getTutorial(id),
|
||||||
@@ -195,10 +216,11 @@ async function init() {
|
|||||||
])
|
])
|
||||||
if (res2.status === "fulfilled") tutorial.value = res2.value.data
|
if (res2.status === "fulfilled") tutorial.value = res2.value.data
|
||||||
exercises.value = exs.status === "fulfilled" ? exs.value : []
|
exercises.value = exs.status === "fulfilled" ? exs.value : []
|
||||||
|
learnStep.value[type.value] = step.value
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.params.step,
|
() => [route.params.type, route.params.step],
|
||||||
async () => {
|
async () => {
|
||||||
if (route.name === "learn") init()
|
if (route.name === "learn") init()
|
||||||
},
|
},
|
||||||
@@ -207,9 +229,12 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 桌面端固定高度,让目录/内容/代码三栏各自内部滚动;移动端不限高,交给页面整体滚动 */
|
||||||
|
@media (min-width: 769px) {
|
||||||
.learn-container {
|
.learn-container {
|
||||||
height: calc(100vh - 138px);
|
height: calc(100vh - 138px);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.learn-grid {
|
.learn-grid {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import CodeEditor from "shared/components/CodeEditor.vue"
|
|||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { provideSyncStatus } from "oj/composables/syncStatus"
|
import { provideSyncStatus } from "oj/composables/syncStatus"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
import Form from "./Form.vue"
|
import Form from "./Form.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ import {
|
|||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||||
import IconButton from "shared/components/IconButton.vue"
|
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { NFlex } from "naive-ui"
|
import { NFlex } from "naive-ui"
|
||||||
import SubmitCode from "./SubmitCode.vue"
|
import SubmitCode from "./SubmitCode.vue"
|
||||||
@@ -49,7 +48,7 @@ const codeStore = useCodeStore()
|
|||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
const { problem, languages } = storeToRefs(problemStore)
|
const { problem, languages } = storeToRefs(problemStore)
|
||||||
|
|
||||||
const { isMobile, isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
||||||
const statisticPanel = ref(false)
|
const statisticPanel = ref(false)
|
||||||
@@ -65,12 +64,6 @@ const showSyncFeature = computed(
|
|||||||
!isContestMode.value,
|
!isContestMode.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const menu = computed<DropdownOption[]>(() => [
|
|
||||||
{ label: "去自测猫", key: "goTestCat", show: isMobile.value },
|
|
||||||
{ label: "复制代码", key: "copy" },
|
|
||||||
{ label: "重置代码", key: "reset" },
|
|
||||||
])
|
|
||||||
|
|
||||||
const showGoSubmissionButton = computed(() => {
|
const showGoSubmissionButton = computed(() => {
|
||||||
if (isContestMode.value) return true
|
if (isContestMode.value) return true
|
||||||
else if (userStore.isAdminRole) return true
|
else if (userStore.isAdminRole) return true
|
||||||
@@ -78,6 +71,71 @@ const showGoSubmissionButton = computed(() => {
|
|||||||
else return false
|
else return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const menuOptions = computed<DropdownOption[]>(() => {
|
||||||
|
const options: DropdownOption[] = []
|
||||||
|
// 移动端额外收纳桌面端常驻的两项
|
||||||
|
if (!isDesktop.value) {
|
||||||
|
if (showGoSubmissionButton.value) {
|
||||||
|
options.push({
|
||||||
|
label: "提交信息",
|
||||||
|
key: "submissions",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (userStore.isTeacherOrAbove) {
|
||||||
|
options.push({
|
||||||
|
label: "课堂统计",
|
||||||
|
key: "statistics",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (codeStore.code.language !== "Flowchart") {
|
||||||
|
if (codeStore.code.language !== "SQL") {
|
||||||
|
options.push({
|
||||||
|
label: "去自测猫",
|
||||||
|
key: "testcat",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
label: "复制代码",
|
||||||
|
key: "copy",
|
||||||
|
})
|
||||||
|
options.push({
|
||||||
|
label: "重置代码",
|
||||||
|
key: "reset",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (isDesktop.value && userStore.isSuperAdmin) {
|
||||||
|
options.push({
|
||||||
|
label: "编辑题目",
|
||||||
|
key: "edit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMenuSelect = (key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case "submissions":
|
||||||
|
goSubmissions()
|
||||||
|
break
|
||||||
|
case "statistics":
|
||||||
|
statisticPanel.value = true
|
||||||
|
break
|
||||||
|
case "testcat":
|
||||||
|
goTestCat()
|
||||||
|
break
|
||||||
|
case "copy":
|
||||||
|
copy()
|
||||||
|
break
|
||||||
|
case "reset":
|
||||||
|
reset()
|
||||||
|
break
|
||||||
|
case "edit":
|
||||||
|
goEdit()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const languageOptions: DropdownOption[] = languages.value.map((it) => ({
|
const languageOptions: DropdownOption[] = languages.value.map((it) => ({
|
||||||
label: () =>
|
label: () =>
|
||||||
h(NFlex, { align: "center" }, () => [
|
h(NFlex, { align: "center" }, () => [
|
||||||
@@ -133,15 +191,6 @@ const goEdit = () => {
|
|||||||
window.open(router.resolve(url).href, "_blank")
|
window.open(router.resolve(url).href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMenuSelect = (key: string) => {
|
|
||||||
const actions: Record<string, () => void> = {
|
|
||||||
reset,
|
|
||||||
copy,
|
|
||||||
goTestCat,
|
|
||||||
}
|
|
||||||
actions[key]?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSync = () => {
|
const toggleSync = () => {
|
||||||
syncEnabled.value = !syncEnabled.value
|
syncEnabled.value = !syncEnabled.value
|
||||||
emit("toggleSync", syncEnabled.value)
|
emit("toggleSync", syncEnabled.value)
|
||||||
@@ -155,7 +204,8 @@ defineExpose({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!languages.value.includes(codeStore.code.language)) {
|
if (!languages.value.includes(codeStore.code.language)) {
|
||||||
codeStore.code.language = "Python3"
|
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
|
||||||
|
codeStore.code.language = languages.value[0] ?? "Python3"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -175,7 +225,7 @@ onMounted(() => {
|
|||||||
<SubmitCode v-else />
|
<SubmitCode v-else />
|
||||||
|
|
||||||
<n-button
|
<n-button
|
||||||
v-if="showGoSubmissionButton"
|
v-if="isDesktop && showGoSubmissionButton"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
@click="goSubmissions"
|
@click="goSubmissions"
|
||||||
>
|
>
|
||||||
@@ -183,47 +233,31 @@ onMounted(() => {
|
|||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<n-button
|
<n-button
|
||||||
v-if="userStore.isTeacherOrAbove"
|
v-if="isDesktop && userStore.isTeacherOrAbove"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
@click="statisticPanel = true"
|
@click="statisticPanel = true"
|
||||||
>
|
>
|
||||||
{{ isDesktop ? "统计信息" : "统计" }}
|
课堂统计
|
||||||
</n-button>
|
|
||||||
|
|
||||||
<n-button
|
|
||||||
v-if="isDesktop && codeStore.code.language !== 'Flowchart'"
|
|
||||||
@click="goTestCat"
|
|
||||||
>
|
|
||||||
自测猫
|
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
|
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上提交信息 / 课堂统计 -->
|
||||||
<n-dropdown
|
<n-dropdown
|
||||||
v-if="codeStore.code.language !== 'Flowchart'"
|
v-if="menuOptions.length"
|
||||||
size="large"
|
trigger="click"
|
||||||
:options="menu"
|
:options="menuOptions"
|
||||||
@select="handleMenuSelect"
|
@select="handleMenuSelect"
|
||||||
>
|
>
|
||||||
<n-button :size="buttonSize">操作</n-button>
|
<n-button :size="buttonSize">更多操作</n-button>
|
||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
|
|
||||||
<IconButton
|
|
||||||
v-if="isDesktop && userStore.isSuperAdmin"
|
|
||||||
icon="streamline-ultimate-color:file-code-edit"
|
|
||||||
tip="编辑题目"
|
|
||||||
@click="goEdit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="showSyncFeature">
|
<template v-if="showSyncFeature">
|
||||||
<IconButton
|
<n-button
|
||||||
:icon="
|
:size="buttonSize"
|
||||||
syncEnabled
|
|
||||||
? 'streamline-ultimate-color:flash-off'
|
|
||||||
: 'streamline-ultimate-color:monitor-flash'
|
|
||||||
"
|
|
||||||
:tip="syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF"
|
|
||||||
:type="syncEnabled ? 'warning' : 'default'"
|
:type="syncEnabled ? 'warning' : 'default'"
|
||||||
@click="toggleSync"
|
@click="toggleSync"
|
||||||
/>
|
>
|
||||||
|
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
|
||||||
|
</n-button>
|
||||||
|
|
||||||
<!-- 同步状态标签 -->
|
<!-- 同步状态标签 -->
|
||||||
<template v-if="isConnected">
|
<template v-if="isConnected">
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-alert type="error" v-if="!userStore.isAuthed" title="请先登录" />
|
|
||||||
<div v-else>
|
|
||||||
<n-alert
|
|
||||||
v-if="problem?.my_status !== 0"
|
|
||||||
class="title"
|
|
||||||
type="error"
|
|
||||||
title="请先完成该题"
|
|
||||||
></n-alert>
|
|
||||||
<div v-else>
|
|
||||||
<n-alert class="title" type="success" :title="title"></n-alert>
|
|
||||||
<n-form>
|
|
||||||
<n-form-item
|
|
||||||
label-width="220"
|
|
||||||
label-align="left"
|
|
||||||
label="题目是否描述清楚"
|
|
||||||
label-placement="left"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
v-if="hasCommented"
|
|
||||||
icon="noto:star"
|
|
||||||
:width="24"
|
|
||||||
v-for="(_, i) in description_rating"
|
|
||||||
:key="i"
|
|
||||||
/>
|
|
||||||
<n-rate v-else size="large" v-model:value="description_rating" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item
|
|
||||||
label-width="220"
|
|
||||||
label-align="left"
|
|
||||||
:label="
|
|
||||||
'难度是否匹配(此题是' + DIFFICULTY[problem.difficulty] + '的)'
|
|
||||||
"
|
|
||||||
label-placement="left"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
v-if="hasCommented"
|
|
||||||
icon="noto:star"
|
|
||||||
:width="24"
|
|
||||||
v-for="(_, i) in difficulty_rating"
|
|
||||||
:key="i"
|
|
||||||
/>
|
|
||||||
<n-rate v-else size="large" v-model:value="difficulty_rating" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item
|
|
||||||
label-width="220"
|
|
||||||
label-align="left"
|
|
||||||
label="综合评分"
|
|
||||||
label-placement="left"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
v-if="hasCommented"
|
|
||||||
icon="noto:star"
|
|
||||||
:width="24"
|
|
||||||
v-for="(_, i) in difficulty_rating"
|
|
||||||
:key="i"
|
|
||||||
/>
|
|
||||||
<n-rate v-else size="large" v-model:value="comprehensive_rating" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item
|
|
||||||
v-if="!hasCommented"
|
|
||||||
label="对这道题的评价(可选,注意文明用语)"
|
|
||||||
>
|
|
||||||
<n-input v-model:value="content" type="textarea" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="hasCommented && content" label="你对这道题的评价:">
|
|
||||||
{{ content }}
|
|
||||||
</n-form-item>
|
|
||||||
<n-button
|
|
||||||
v-if="hasCommented && showStatistics"
|
|
||||||
type="primary"
|
|
||||||
@click="getComments"
|
|
||||||
>
|
|
||||||
查看统计
|
|
||||||
</n-button>
|
|
||||||
<n-button v-if="!hasCommented" type="primary" @click="submit">
|
|
||||||
提交
|
|
||||||
</n-button>
|
|
||||||
</n-form>
|
|
||||||
<div v-if="showStatistics">
|
|
||||||
<n-descriptions
|
|
||||||
class="list"
|
|
||||||
v-if="count"
|
|
||||||
:column="4"
|
|
||||||
bordered
|
|
||||||
label-placement="left"
|
|
||||||
>
|
|
||||||
<n-descriptions-item label="评论">
|
|
||||||
{{ count }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="描述">
|
|
||||||
{{ rating.description }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="难度">
|
|
||||||
{{ rating.difficulty }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="综合">
|
|
||||||
{{ rating.comprehensive }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
<n-empty class="list" v-if="show && count === 0">
|
|
||||||
暂无记录,快去评论吧
|
|
||||||
</n-empty>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { storeToRefs } from "pinia"
|
|
||||||
import { useProblemStore } from "oj/store/problem"
|
|
||||||
import { DIFFICULTY } from "utils/constants"
|
|
||||||
import { createComment, getComment, getCommentStatistics } from "oj/api"
|
|
||||||
import { useUserStore } from "shared/store/user"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
showStatistics?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const { showStatistics = true } = defineProps<Props>()
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const problemStore = useProblemStore()
|
|
||||||
const { problem } = storeToRefs(problemStore)
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const content = ref("")
|
|
||||||
const description_rating = ref(0)
|
|
||||||
const difficulty_rating = ref(0)
|
|
||||||
const comprehensive_rating = ref(0)
|
|
||||||
|
|
||||||
const [show, toggleShow] = useToggle()
|
|
||||||
const [hasCommented, toggleHasCommented] = useToggle()
|
|
||||||
const count = ref(0)
|
|
||||||
const rating = reactive({
|
|
||||||
description: 0,
|
|
||||||
difficulty: 0,
|
|
||||||
comprehensive: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = computed(() => {
|
|
||||||
if (hasCommented.value) return "这道题你已经打过分了,你的评分如下:"
|
|
||||||
return "你已经完成了这道题,请给这道题打分吧!"
|
|
||||||
})
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (
|
|
||||||
description_rating.value === 0 ||
|
|
||||||
difficulty_rating.value === 0 ||
|
|
||||||
comprehensive_rating.value === 0
|
|
||||||
) {
|
|
||||||
message.error("请完成打分")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = {
|
|
||||||
problem_id: problem.value!.id,
|
|
||||||
content: content.value,
|
|
||||||
description_rating: description_rating.value,
|
|
||||||
difficulty_rating: difficulty_rating.value,
|
|
||||||
comprehensive_rating: comprehensive_rating.value,
|
|
||||||
}
|
|
||||||
await createComment(data)
|
|
||||||
toggleHasCommented(true)
|
|
||||||
message.success("提交成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getComments() {
|
|
||||||
const res = await getCommentStatistics(problem.value!.id)
|
|
||||||
toggleShow(true)
|
|
||||||
if (!res.data) return
|
|
||||||
count.value = res.data.count
|
|
||||||
rating.description = res.data.rating.description
|
|
||||||
rating.difficulty = res.data.rating.difficulty
|
|
||||||
rating.comprehensive = res.data.rating.comprehensive
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMyComment() {
|
|
||||||
const res = await getComment(problem.value!.id)
|
|
||||||
if (!res.data) return
|
|
||||||
content.value = res.data.content
|
|
||||||
description_rating.value = res.data.description_rating
|
|
||||||
difficulty_rating.value = res.data.difficulty_rating
|
|
||||||
comprehensive_rating.value = res.data.comprehensive_rating
|
|
||||||
toggleHasCommented(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(getMyComment)
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.title {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.list {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -12,6 +12,7 @@ import { useDark } from "@vueuse/core"
|
|||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
import { getSimilarProblems } from "oj/api"
|
import { getSimilarProblems } from "oj/api"
|
||||||
|
import SQLDataTable from "./SQLDataTable.vue"
|
||||||
|
|
||||||
type Sample = Problem["samples"][number] & {
|
type Sample = Problem["samples"][number] & {
|
||||||
id: number
|
id: number
|
||||||
@@ -30,6 +31,18 @@ const { problem } = storeToRefs(problemStore)
|
|||||||
|
|
||||||
const problemSetId = computed(() => route.params.problemSetId)
|
const problemSetId = computed(() => route.params.problemSetId)
|
||||||
|
|
||||||
|
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
|
||||||
|
const isSQL = computed(() => !!problem.value?.sql_config)
|
||||||
|
const sqlDisplay = computed(() => problem.value?.sql_display ?? null)
|
||||||
|
const sqlExpectedQuery = computed(() => {
|
||||||
|
const exp = sqlDisplay.value?.expected
|
||||||
|
return exp && "columns" in exp ? exp : null
|
||||||
|
})
|
||||||
|
const sqlChangedTables = computed(() => {
|
||||||
|
const exp = sqlDisplay.value?.expected
|
||||||
|
return exp && "changed_tables" in exp ? exp.changed_tables : []
|
||||||
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 相似题目推荐
|
// 相似题目推荐
|
||||||
@@ -259,7 +272,7 @@ function type(status: ProblemStatus) {
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
<p class="title" :style="style">
|
<p class="title" :style="style">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<Icon icon="streamline-emojis:sparkles"></Icon>
|
<Icon icon="streamline-ultimate-color:checklist"></Icon>
|
||||||
描述
|
描述
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</p>
|
</p>
|
||||||
@@ -269,9 +282,10 @@ function type(status: ProblemStatus) {
|
|||||||
:theme="isDark ? 'dark' : 'light'"
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<template v-if="!isSQL">
|
||||||
<p class="title" :style="style">
|
<p class="title" :style="style">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<Icon icon="streamline-emojis:four-leaf-clover"></Icon>
|
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
|
||||||
输入
|
输入
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</p>
|
</p>
|
||||||
@@ -283,7 +297,7 @@ function type(status: ProblemStatus) {
|
|||||||
|
|
||||||
<p class="title" :style="style">
|
<p class="title" :style="style">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<Icon icon="streamline-emojis:herb"></Icon>
|
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
|
||||||
输出
|
输出
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</p>
|
</p>
|
||||||
@@ -292,11 +306,60 @@ function type(status: ProblemStatus) {
|
|||||||
:model-value="problem.output_description"
|
:model-value="problem.output_description"
|
||||||
:theme="isDark ? 'dark' : 'light'"
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="isSQL && sqlDisplay">
|
||||||
|
<p class="title" :style="style">
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon icon="devicon:sqlite"></Icon>
|
||||||
|
数据表
|
||||||
|
</n-flex>
|
||||||
|
</p>
|
||||||
|
<div v-for="t in sqlDisplay.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="title" :style="style">
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
||||||
|
期望结果
|
||||||
|
</n-flex>
|
||||||
|
</p>
|
||||||
|
<template v-if="sqlExpectedQuery">
|
||||||
|
<SQLDataTable
|
||||||
|
:columns="sqlExpectedQuery.columns"
|
||||||
|
:rows="sqlExpectedQuery.rows"
|
||||||
|
:total-rows="sqlExpectedQuery.total_rows"
|
||||||
|
:truncated="sqlExpectedQuery.truncated"
|
||||||
|
/>
|
||||||
|
<p v-if="!problem.sql_config?.order_sensitive" class="sqlNote">
|
||||||
|
结果顺序不限
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<div v-for="t in sqlChangedTables" :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>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="problem.hint">
|
<div v-if="problem.hint">
|
||||||
<p class="title" :style="style">
|
<p class="title" :style="style">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<Icon icon="streamline-emojis:seedling"></Icon>
|
<Icon icon="streamline-emojis:man-tipping-hand-1"></Icon>
|
||||||
提示
|
提示
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</p>
|
</p>
|
||||||
@@ -311,7 +374,7 @@ function type(status: ProblemStatus) {
|
|||||||
<div v-if="astRulesForDisplay.length > 0">
|
<div v-if="astRulesForDisplay.length > 0">
|
||||||
<p class="title" :style="style">
|
<p class="title" :style="style">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<Icon icon="streamline-emojis:open-book"></Icon>
|
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
||||||
要求
|
要求
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</p>
|
</p>
|
||||||
@@ -334,9 +397,15 @@ function type(status: ProblemStatus) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!isSQL">
|
||||||
<div v-for="(sample, index) of samples" :key="index">
|
<div v-for="(sample, index) of samples" :key="index">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<p class="title" :style="style">例子 {{ index + 1 }}</p>
|
<p class="title" :style="style">
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon icon="streamline-emojis:microscope"></Icon>
|
||||||
|
例子 {{ index + 1 }}
|
||||||
|
</n-flex>
|
||||||
|
</p>
|
||||||
<n-button
|
<n-button
|
||||||
size="small"
|
size="small"
|
||||||
:type="type(sample.status)"
|
:type="type(sample.status)"
|
||||||
@@ -373,9 +442,15 @@ function type(status: ProblemStatus) {
|
|||||||
</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
</n-descriptions>
|
</n-descriptions>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="problem.source">
|
<div v-if="problem.source">
|
||||||
<p class="title" :style="style">来源</p>
|
<p class="title" :style="style">
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon>
|
||||||
|
来源
|
||||||
|
</n-flex>
|
||||||
|
</p>
|
||||||
<MdPreview
|
<MdPreview
|
||||||
preview-theme="vuepress"
|
preview-theme="vuepress"
|
||||||
:model-value="problem.source"
|
:model-value="problem.source"
|
||||||
@@ -388,7 +463,7 @@ function type(status: ProblemStatus) {
|
|||||||
<n-divider />
|
<n-divider />
|
||||||
<p class="title" :style="style">
|
<p class="title" :style="style">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<Icon icon="fluent-emoji-flat:light-bulb"></Icon>
|
<Icon icon="streamline-ultimate-color:like"></Icon>
|
||||||
相似题目推荐
|
相似题目推荐
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</p>
|
</p>
|
||||||
@@ -460,4 +535,16 @@ function type(status: ProblemStatus) {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sqlTableName {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-family: Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sqlNote {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const data = computed(() => {
|
|||||||
const numbers = computed(() => {
|
const numbers = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
icon: "streamline-emojis:scroll",
|
icon: "streamline-ultimate-color:checklist",
|
||||||
title: problem.value?.submission_number ?? 0,
|
title: problem.value?.submission_number ?? 0,
|
||||||
content: "总提交",
|
content: "总提交",
|
||||||
int: true,
|
int: true,
|
||||||
@@ -63,7 +63,7 @@ const numbers = computed(() => {
|
|||||||
suffix: "",
|
suffix: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "emojione:chart-increasing",
|
icon: "fluent-emoji:chart-increasing",
|
||||||
title: getACRateNumber(
|
title: getACRateNumber(
|
||||||
problem.value?.accepted_number ?? 0,
|
problem.value?.accepted_number ?? 0,
|
||||||
problem.value?.submission_number ?? 0,
|
problem.value?.submission_number ?? 0,
|
||||||
|
|||||||
698
src/oj/problem/components/ProblemReaction.vue
Normal file
698
src/oj/problem/components/ProblemReaction.vue
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { storeToRefs } from "pinia"
|
||||||
|
import type { CSSProperties } from "vue"
|
||||||
|
import { getReaction, setReaction } from "oj/api"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
import { REACTIONS } from "utils/constants"
|
||||||
|
import type { ReactionCounts, ReactionKey } from "utils/types"
|
||||||
|
|
||||||
|
const emit = defineEmits<{ submitted: [] }>()
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
const { problem } = storeToRefs(problemStore)
|
||||||
|
const message = useMessage()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
|
const mine = ref<ReactionKey | null>(null)
|
||||||
|
const counts = ref<ReactionCounts | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
// 正在提交的 key,用来锁住整组并只在当前选项显示进度。
|
||||||
|
const submitting = ref<ReactionKey | null>(null)
|
||||||
|
const activeIndex = ref<number | null>(null)
|
||||||
|
const keyboardActive = ref(false)
|
||||||
|
const wheelRef = ref<HTMLElement | null>(null)
|
||||||
|
let loadSequence = 0
|
||||||
|
|
||||||
|
const wheelGeometry = {
|
||||||
|
startAngle: -90,
|
||||||
|
contentRadius: 35,
|
||||||
|
pushRadius: 4,
|
||||||
|
outerRadius: 50,
|
||||||
|
arcPointCount: 9,
|
||||||
|
hitInnerRadius: 0.18,
|
||||||
|
hitOuterRadius: 0.49,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const sliceAngle = 360 / REACTIONS.length
|
||||||
|
|
||||||
|
function pointOnCircle(angle: number, radius: number) {
|
||||||
|
const radians = (angle * Math.PI) / 180
|
||||||
|
return {
|
||||||
|
x: 50 + Math.cos(radians) * radius,
|
||||||
|
y: 50 + Math.sin(radians) * radius,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWheelItemStyle(index: number): CSSProperties {
|
||||||
|
const centerAngle = wheelGeometry.startAngle + index * sliceAngle
|
||||||
|
const startAngle = centerAngle - sliceAngle / 2
|
||||||
|
const endAngle = centerAngle + sliceAngle / 2
|
||||||
|
const dividerAngle = index * sliceAngle - sliceAngle / 2
|
||||||
|
const position = pointOnCircle(centerAngle, wheelGeometry.contentRadius)
|
||||||
|
const push = pointOnCircle(centerAngle, wheelGeometry.pushRadius)
|
||||||
|
const arcPoints = Array.from(
|
||||||
|
{ length: wheelGeometry.arcPointCount },
|
||||||
|
(_, pointIndex) => {
|
||||||
|
const progress = pointIndex / (wheelGeometry.arcPointCount - 1)
|
||||||
|
const angle = startAngle + (endAngle - startAngle) * progress
|
||||||
|
const point = pointOnCircle(angle, wheelGeometry.outerRadius)
|
||||||
|
return `${point.x.toFixed(3)}% ${point.y.toFixed(3)}%`
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"--segment-path": `polygon(50% 50%, ${arcPoints.join(", ")})`,
|
||||||
|
"--content-x": `${position.x}%`,
|
||||||
|
"--content-y": `${position.y}%`,
|
||||||
|
"--push-x": `${push.x - 50}px`,
|
||||||
|
"--push-y": `${push.y - 50}px`,
|
||||||
|
"--divider-angle": `${dividerAngle}deg`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wheelItems = REACTIONS.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
index,
|
||||||
|
style: getWheelItemStyle(index),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const solved = computed(() => problem.value?.my_status === 0)
|
||||||
|
const locked = computed(() => mine.value !== null)
|
||||||
|
const canInteract = computed(
|
||||||
|
() =>
|
||||||
|
userStore.isAuthed &&
|
||||||
|
!!problem.value &&
|
||||||
|
solved.value &&
|
||||||
|
!locked.value &&
|
||||||
|
!loading.value &&
|
||||||
|
!submitting.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const wheelCenter = computed(() => {
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
icon: "ph:spinner-gap-bold",
|
||||||
|
eyebrow: "正在读取",
|
||||||
|
label: "题目点评",
|
||||||
|
spinning: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitting.value) {
|
||||||
|
const item = REACTIONS.find((reaction) => reaction.key === submitting.value)
|
||||||
|
return {
|
||||||
|
icon: "svg-spinners:180-ring-with-bg",
|
||||||
|
eyebrow: "正在记录",
|
||||||
|
label: item?.label ?? "提交点评",
|
||||||
|
spinning: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mine.value) {
|
||||||
|
const item = REACTIONS.find((reaction) => reaction.key === mine.value)
|
||||||
|
return {
|
||||||
|
icon: "ph:check-bold",
|
||||||
|
eyebrow: "你的选择",
|
||||||
|
label: item?.label ?? "已提交",
|
||||||
|
spinning: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userStore.isAuthed) {
|
||||||
|
return {
|
||||||
|
icon: "ph:user-circle-dashed",
|
||||||
|
eyebrow: "登录后开放",
|
||||||
|
label: "登录后点评",
|
||||||
|
spinning: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeIndex.value !== null) {
|
||||||
|
const item = wheelItems[activeIndex.value]
|
||||||
|
const count = counts.value?.[item.key]
|
||||||
|
return {
|
||||||
|
icon: item.icon,
|
||||||
|
eyebrow: count === undefined ? "选择这项" : `${count} 人选择`,
|
||||||
|
label: item.label,
|
||||||
|
spinning: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!solved.value) {
|
||||||
|
return {
|
||||||
|
icon: "ph:lock-simple-bold",
|
||||||
|
eyebrow: "完成后开放",
|
||||||
|
label: "通关后点评",
|
||||||
|
spinning: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: "ph:cursor-click-bold",
|
||||||
|
eyebrow: "移动到扇区",
|
||||||
|
label: "选择点评",
|
||||||
|
spinning: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const reactionStyle = computed(() => ({
|
||||||
|
"--reaction-accent": theme.value.primaryColor,
|
||||||
|
"--reaction-card": theme.value.cardColor,
|
||||||
|
"--reaction-border": theme.value.borderColor,
|
||||||
|
"--reaction-text": theme.value.textColor1,
|
||||||
|
"--reaction-text-faint": theme.value.textColor3,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function optionAriaLabel(key: ReactionKey, label: string) {
|
||||||
|
const count = counts.value?.[key]
|
||||||
|
const countText = count === undefined ? "" : `,${count} 人选择`
|
||||||
|
const selectedText = mine.value === key ? ",你的选择" : ""
|
||||||
|
return `${label}${countText}${selectedText}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPointerIndex(event: PointerEvent | MouseEvent) {
|
||||||
|
const wheel = wheelRef.value
|
||||||
|
if (!wheel) return null
|
||||||
|
|
||||||
|
const bounds = wheel.getBoundingClientRect()
|
||||||
|
const x = event.clientX - (bounds.left + bounds.width / 2)
|
||||||
|
const y = event.clientY - (bounds.top + bounds.height / 2)
|
||||||
|
const distance = Math.hypot(x, y)
|
||||||
|
|
||||||
|
if (
|
||||||
|
distance < bounds.width * wheelGeometry.hitInnerRadius ||
|
||||||
|
distance > bounds.width * wheelGeometry.hitOuterRadius
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const angle = (Math.atan2(y, x) * 180) / Math.PI
|
||||||
|
const rawIndex = Math.round((angle - wheelGeometry.startAngle) / sliceAngle)
|
||||||
|
return (
|
||||||
|
((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(index: number, fromKeyboard = false) {
|
||||||
|
if (!canInteract.value) return
|
||||||
|
keyboardActive.value = fromKeyboard
|
||||||
|
activeIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
if (locked.value) return
|
||||||
|
activeIndex.value = null
|
||||||
|
keyboardActive.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheelPointerMove(event: PointerEvent) {
|
||||||
|
if (!canInteract.value) return
|
||||||
|
keyboardActive.value = false
|
||||||
|
activeIndex.value = getPointerIndex(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheelClick(event: MouseEvent) {
|
||||||
|
if (!canInteract.value) return
|
||||||
|
const target = event.target
|
||||||
|
if (target instanceof Element && target.closest(".reaction-option")) return
|
||||||
|
|
||||||
|
const index = getPointerIndex(event)
|
||||||
|
if (index !== null) pick(wheelItems[index].key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pick(key: ReactionKey) {
|
||||||
|
if (
|
||||||
|
!problem.value ||
|
||||||
|
!solved.value ||
|
||||||
|
locked.value ||
|
||||||
|
loading.value ||
|
||||||
|
submitting.value
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
activeIndex.value = null
|
||||||
|
keyboardActive.value = false
|
||||||
|
submitting.value = key
|
||||||
|
try {
|
||||||
|
const res = await setReaction(problem.value.id, key)
|
||||||
|
mine.value = res.data.mine
|
||||||
|
counts.value = res.data.counts
|
||||||
|
emit("submitted")
|
||||||
|
} catch {
|
||||||
|
message.error("提交失败,请重试")
|
||||||
|
} finally {
|
||||||
|
submitting.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(problemId: number) {
|
||||||
|
const sequence = ++loadSequence
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getReaction(problemId)
|
||||||
|
if (sequence !== loadSequence) return
|
||||||
|
mine.value = res.data.mine
|
||||||
|
counts.value = res.data.counts
|
||||||
|
} catch {
|
||||||
|
if (sequence === loadSequence) message.error("暂时无法读取题目点评")
|
||||||
|
} finally {
|
||||||
|
if (sequence === loadSequence) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => userStore.isAuthed, () => problem.value?.id],
|
||||||
|
([isAuthed, problemId]) => {
|
||||||
|
mine.value = null
|
||||||
|
counts.value = null
|
||||||
|
submitting.value = null
|
||||||
|
activeIndex.value = null
|
||||||
|
keyboardActive.value = false
|
||||||
|
|
||||||
|
if (!isAuthed || problemId === undefined) {
|
||||||
|
loadSequence += 1
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
load(problemId)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="reaction-panel" :style="reactionStyle" aria-label="题目点评">
|
||||||
|
<div class="wheel-stage">
|
||||||
|
<div
|
||||||
|
ref="wheelRef"
|
||||||
|
class="reaction-wheel"
|
||||||
|
:class="{
|
||||||
|
'has-selection': locked,
|
||||||
|
'is-disabled': !canInteract,
|
||||||
|
'is-keyboard-active': keyboardActive,
|
||||||
|
}"
|
||||||
|
role="group"
|
||||||
|
aria-label="选择一项题目点评,点击后立即提交"
|
||||||
|
:aria-busy="loading || !!submitting"
|
||||||
|
@pointermove="onWheelPointerMove"
|
||||||
|
@pointerleave="clearPreview"
|
||||||
|
@click="onWheelClick"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="item in wheelItems"
|
||||||
|
:key="`${item.key}-face`"
|
||||||
|
class="segment-face"
|
||||||
|
:class="{
|
||||||
|
'is-active': activeIndex === item.index,
|
||||||
|
'is-selected': mine === item.key,
|
||||||
|
'is-submitting': submitting === item.key,
|
||||||
|
}"
|
||||||
|
:style="item.style"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-for="item in wheelItems"
|
||||||
|
:key="`${item.key}-divider`"
|
||||||
|
class="segment-divider"
|
||||||
|
:style="item.style"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="item in wheelItems"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="reaction-option"
|
||||||
|
:class="{
|
||||||
|
'is-active': activeIndex === item.index,
|
||||||
|
'is-selected': mine === item.key,
|
||||||
|
'is-submitting': submitting === item.key,
|
||||||
|
'is-muted': locked && mine !== item.key,
|
||||||
|
'is-unavailable': !userStore.isAuthed || !solved || loading,
|
||||||
|
}"
|
||||||
|
:style="item.style"
|
||||||
|
:disabled="!canInteract"
|
||||||
|
:aria-pressed="mine === item.key"
|
||||||
|
:aria-label="optionAriaLabel(item.key, item.label)"
|
||||||
|
@focus="preview(item.index, true)"
|
||||||
|
@blur="clearPreview"
|
||||||
|
@click.stop="pick(item.key)"
|
||||||
|
>
|
||||||
|
<span class="option-content">
|
||||||
|
<span class="option-icon" aria-hidden="true">
|
||||||
|
<Icon
|
||||||
|
:icon="
|
||||||
|
submitting === item.key
|
||||||
|
? 'svg-spinners:180-ring-with-bg'
|
||||||
|
: item.icon
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="option-label">{{ item.label }}</span>
|
||||||
|
<span v-if="counts" class="option-count">
|
||||||
|
{{ counts[item.key] }} 人
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="wheel-core" aria-hidden="true">
|
||||||
|
<div class="core-content">
|
||||||
|
<Icon
|
||||||
|
class="core-icon"
|
||||||
|
:class="{ 'is-spinning': wheelCenter.spinning }"
|
||||||
|
:icon="wheelCenter.icon"
|
||||||
|
/>
|
||||||
|
<span class="core-eyebrow">{{ wheelCenter.eyebrow }}</span>
|
||||||
|
<strong class="core-label">{{ wheelCenter.label }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reaction-panel {
|
||||||
|
width: min(100%, 720px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
container-type: inline-size;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(12px, 3vw, 24px);
|
||||||
|
color: var(--reaction-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-stage {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-wheel {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 400px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--reaction-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--reaction-card);
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--reaction-text) 4%, var(--reaction-card));
|
||||||
|
clip-path: var(--segment-path);
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
transform-origin: center;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
transform 140ms cubic-bezier(0, 0, 0.2, 1),
|
||||||
|
background-color 140ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-face:is(.is-active, .is-submitting) {
|
||||||
|
z-index: 2;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--reaction-accent) 10%,
|
||||||
|
var(--reaction-card)
|
||||||
|
);
|
||||||
|
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7))
|
||||||
|
scale(1.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-face.is-selected {
|
||||||
|
z-index: 3;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--reaction-accent) 18%,
|
||||||
|
var(--reaction-card)
|
||||||
|
);
|
||||||
|
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-divider {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 8;
|
||||||
|
top: 1px;
|
||||||
|
left: calc(50% - 1px);
|
||||||
|
width: 2px;
|
||||||
|
height: calc(50% - 1px);
|
||||||
|
background: var(--reaction-border);
|
||||||
|
transform: rotate(var(--divider-angle));
|
||||||
|
transform-origin: 50% 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9;
|
||||||
|
top: var(--content-y);
|
||||||
|
left: var(--content-x);
|
||||||
|
display: flex;
|
||||||
|
width: clamp(66px, 20%, 80px);
|
||||||
|
min-height: clamp(52px, 16%, 64px);
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--reaction-text);
|
||||||
|
font: inherit;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
touch-action: manipulation;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option.is-unavailable {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option.is-muted {
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option:is(.is-selected, .is-submitting) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--reaction-accent) 55%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
transition: transform 140ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option:active:not(:disabled) .option-content {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option:is(.is-active, .is-submitting) .option-content {
|
||||||
|
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
|
||||||
|
scale(1.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
|
||||||
|
.option-content {
|
||||||
|
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
|
||||||
|
scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option.is-selected .option-content {
|
||||||
|
transform: translate(var(--push-x), var(--push-y)) scale(1.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-icon svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-count {
|
||||||
|
color: var(--reaction-text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option.is-selected .option-count {
|
||||||
|
color: var(--reaction-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-core {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: grid;
|
||||||
|
width: 31%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
place-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--reaction-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--reaction-card);
|
||||||
|
color: var(--reaction-text);
|
||||||
|
text-align: center;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-content {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-icon {
|
||||||
|
width: clamp(20px, 5cqi, 26px);
|
||||||
|
height: clamp(20px, 5cqi, 26px);
|
||||||
|
font-size: clamp(20px, 5cqi, 26px);
|
||||||
|
color: var(--reaction-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-eyebrow {
|
||||||
|
color: var(--reaction-text-faint);
|
||||||
|
font-size: clamp(9px, 2.3cqi, 11px);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-label {
|
||||||
|
font-size: clamp(12px, 3cqi, 15px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-wheel.is-keyboard-active :is(.segment-face, .option-content) {
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-spinning {
|
||||||
|
animation: reaction-spin 850ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 440px) {
|
||||||
|
.wheel-stage {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option {
|
||||||
|
width: clamp(58px, 20%, 70px);
|
||||||
|
min-height: clamp(48px, 16%, 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-face:is(.is-active, .is-submitting) {
|
||||||
|
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5))
|
||||||
|
scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-face.is-selected {
|
||||||
|
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
|
||||||
|
scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option:is(.is-active, .is-submitting) .option-content {
|
||||||
|
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
|
||||||
|
scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
|
||||||
|
.option-content {
|
||||||
|
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
|
||||||
|
scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-option.is-selected .option-content {
|
||||||
|
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
|
||||||
|
scale(1.17);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-icon svg {
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-count {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-core {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.segment-face,
|
||||||
|
.option-content,
|
||||||
|
.option-icon,
|
||||||
|
.option-label,
|
||||||
|
.option-count {
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-spinning {
|
||||||
|
animation-duration: 1.8s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reaction-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,8 +16,8 @@ const color = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-icon v-if="showIcon" :color="color">
|
<n-icon v-if="showIcon" :color="color">
|
||||||
<Icon icon="ep:select" v-if="status === 'passed'"></Icon>
|
<Icon icon="ph:check-bold" v-if="status === 'passed'"></Icon>
|
||||||
<Icon icon="ep:semi-select" v-if="status === 'failed'"></Icon>
|
<Icon icon="ph:minus-bold" v-if="status === 'failed'"></Icon>
|
||||||
</n-icon>
|
</n-icon>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function showCodePanel(id: string, problem: string) {
|
|||||||
|
|
||||||
const columns: DataTableColumn<Submission>[] = [
|
const columns: DataTableColumn<Submission>[] = [
|
||||||
{
|
{
|
||||||
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
|
||||||
key: "create_time",
|
key: "create_time",
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||||
@@ -77,10 +77,7 @@ const columns: DataTableColumn<Submission>[] = [
|
|||||||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle(
|
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
|
||||||
"语言",
|
|
||||||
"streamline-emojis:globe-showing-europe-africa",
|
|
||||||
),
|
|
||||||
key: "language",
|
key: "language",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
||||||
|
|||||||
60
src/oj/problem/components/SQLDataTable.vue
Normal file
60
src/oj/problem/components/SQLDataTable.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SQLDisplayColumn } from "utils/types"
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
columns: SQLDisplayColumn[]
|
||||||
|
rows: (string | number | null)[][]
|
||||||
|
totalRows?: number
|
||||||
|
truncated?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-table class="sqlTable" size="small" :single-line="false">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="(col, i) in columns" :key="i">
|
||||||
|
{{ col.name }}
|
||||||
|
<span v-if="col.type" class="colType">{{ col.type }}</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="rows.length === 0">
|
||||||
|
<td :colspan="columns.length" class="nullCell">(空表)</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="(row, i) in rows" :key="i">
|
||||||
|
<td v-for="(v, j) in row" :key="j" :class="{ nullCell: v === null }">
|
||||||
|
{{ v === null ? "NULL" : v }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</n-table>
|
||||||
|
<p v-if="truncated" class="truncNote">
|
||||||
|
共 {{ totalRows }} 行,仅展示前 {{ rows.length }} 行
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sqlTable {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colType {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.55;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nullCell {
|
||||||
|
opacity: 0.45;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncNote {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -35,10 +35,12 @@ const msg = computed(() => {
|
|||||||
let msg = ""
|
let msg = ""
|
||||||
const result = props.submission.result
|
const result = props.submission.result
|
||||||
|
|
||||||
// 编译错误或运行时错误时给出提示
|
// 编译错误或运行时错误时给出提示;
|
||||||
|
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
|
||||||
if (
|
if (
|
||||||
result === SubmissionStatus.compile_error ||
|
(result === SubmissionStatus.compile_error ||
|
||||||
result === SubmissionStatus.runtime_error
|
result === SubmissionStatus.runtime_error) &&
|
||||||
|
props.submission.language !== "SQL"
|
||||||
) {
|
) {
|
||||||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||||||
}
|
}
|
||||||
@@ -175,7 +177,7 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
|||||||
<n-icon
|
<n-icon
|
||||||
:color="rule.passed ? theme.successColor : theme.errorColor"
|
:color="rule.passed ? theme.successColor : theme.errorColor"
|
||||||
>
|
>
|
||||||
<Icon :icon="rule.passed ? 'ep:select' : 'ep:close-bold'" />
|
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
|
||||||
</n-icon>
|
</n-icon>
|
||||||
<span>{{ rule.description }}</span>
|
<span>{{ rule.description }}</span>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
|
|||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import {
|
import {
|
||||||
formatCode,
|
formatCode,
|
||||||
getComment,
|
getReaction,
|
||||||
submitCode,
|
submitCode,
|
||||||
updateProblemSetProgress,
|
updateProblemSetProgress,
|
||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
@@ -17,11 +17,14 @@ import SubmissionResult from "./SubmissionResult.vue"
|
|||||||
import { getSubmitButtonState } from "./submitButtonState"
|
import { getSubmitButtonState } from "./submitButtonState"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
|
import {
|
||||||
|
checkPythonSyntax,
|
||||||
|
prefetchPythonSyntaxChecker,
|
||||||
|
} from "oj/problem/utils/pythonSyntaxCheck"
|
||||||
|
|
||||||
// ==================== 异步组件 ====================
|
// ==================== 异步组件 ====================
|
||||||
const ProblemComment = defineAsyncComponent(
|
const ProblemReaction = defineAsyncComponent(
|
||||||
() => import("./ProblemComment.vue"),
|
() => import("./ProblemReaction.vue"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== 基础状态 ====================
|
// ==================== 基础状态 ====================
|
||||||
@@ -37,6 +40,10 @@ const router = useRouter()
|
|||||||
const [commentPanel] = useToggle()
|
const [commentPanel] = useToggle()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
function closeCommentPanel() {
|
||||||
|
commentPanel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
// ==================== 烟花效果 ====================
|
// ==================== 烟花效果 ====================
|
||||||
@@ -50,6 +57,16 @@ const showResult = ref(false)
|
|||||||
const isFormatting = ref(false)
|
const isFormatting = ref(false)
|
||||||
const isSubmittingRequest = ref(false)
|
const isSubmittingRequest = ref(false)
|
||||||
|
|
||||||
|
// ==================== Python 语法检测器预取 ====================
|
||||||
|
// 选中 Python3 时就把 Skulpt 拉下来,避免点提交时才开始下载
|
||||||
|
watch(
|
||||||
|
() => codeStore.code.language,
|
||||||
|
(language) => {
|
||||||
|
if (language === "Python3") prefetchPythonSyntaxChecker()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// ==================== 提交冷却 ====================
|
// ==================== 提交冷却 ====================
|
||||||
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
||||||
controls: true,
|
controls: true,
|
||||||
@@ -59,8 +76,8 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
|||||||
// ==================== AC后显示评论框 ====================
|
// ==================== AC后显示评论框 ====================
|
||||||
const { start: showCommentPanelDelayed } = useTimeoutFn(
|
const { start: showCommentPanelDelayed } = useTimeoutFn(
|
||||||
async () => {
|
async () => {
|
||||||
const res = await getComment(problem.value!.id)
|
const res = await getReaction(problem.value!.id)
|
||||||
if (!res.data) {
|
if (res.data.mine === null) {
|
||||||
commentPanel.value = true
|
commentPanel.value = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,16 +116,16 @@ async function submit() {
|
|||||||
|
|
||||||
// 0. Python3 语法检测
|
// 0. Python3 语法检测
|
||||||
if (codeStore.code.language === "Python3") {
|
if (codeStore.code.language === "Python3") {
|
||||||
const syntaxError = checkPythonSyntax(codeStore.code.value)
|
const syntaxError = await checkPythonSyntax(codeStore.code.value)
|
||||||
if (syntaxError) {
|
if (syntaxError) {
|
||||||
message.warning(`第 ${syntaxError.line} 行存在语法错误,请修正后再提交`)
|
message.warning(`第 ${syntaxError.line} 行存在语法错误,请修正后再提交`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0.5 提交前自动格式化(Python3 用 ruff,C/C++ 用 clang-format)
|
// 0.5 提交前自动格式化(Python3 用 ruff,C/C++ 用 clang-format,SQL 用 sqlparse)
|
||||||
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
|
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
|
||||||
if (["python", "c", "cpp"].includes(formatLang)) {
|
if (["python", "c", "cpp", "sql"].includes(formatLang)) {
|
||||||
isFormatting.value = true
|
isFormatting.value = true
|
||||||
try {
|
try {
|
||||||
const res = await formatCode({
|
const res = await formatCode({
|
||||||
@@ -246,11 +263,13 @@ watch(
|
|||||||
<!-- 评价弹窗 -->
|
<!-- 评价弹窗 -->
|
||||||
<n-modal
|
<n-modal
|
||||||
preset="card"
|
preset="card"
|
||||||
title="恭喜你成功提交,请对该题进行评价(一星差评,五星好评)"
|
title="恭喜你成功提交,说说你对这道题的感受吧"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
|
:closable="false"
|
||||||
|
:close-on-esc="false"
|
||||||
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
|
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
|
||||||
v-model:show="commentPanel"
|
v-model:show="commentPanel"
|
||||||
>
|
>
|
||||||
<ProblemComment :showStatistics="false" />
|
<ProblemReaction @submitted="closeCommentPanel" />
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import confetti from "canvas-confetti"
|
import confetti from "canvas-confetti"
|
||||||
|
import { getCurrentScope, onScopeDispose } from "vue"
|
||||||
|
|
||||||
// 表情图标取自 @iconify 的 streamline-emojis 图标集,与项目内 <Icon icon="streamline-emojis:..."> 用法保持一致
|
// 表情图标取自 @iconify 的 streamline-emojis 图标集,与项目内 <Icon icon="streamline-emojis:..."> 用法保持一致
|
||||||
const EMOJI_SVGS = [
|
const EMOJI_SVGS = [
|
||||||
@@ -67,6 +68,31 @@ export function useFireworks() {
|
|||||||
// 提前预加载表情包位图,避免首次触发效果8时出现解码延迟
|
// 提前预加载表情包位图,避免首次触发效果8时出现解码延迟
|
||||||
loadEmojiShapes()
|
loadEmojiShapes()
|
||||||
|
|
||||||
|
// 动画期间创建的定时器,组件卸载时统一清理,避免离开页面后动画继续
|
||||||
|
const timers = new Set<ReturnType<typeof setTimeout>>()
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
function stopTimer(id: ReturnType<typeof setTimeout>) {
|
||||||
|
clearInterval(id)
|
||||||
|
timers.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runLater(fn: () => void, ms: number) {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
timers.delete(id)
|
||||||
|
fn()
|
||||||
|
}, ms)
|
||||||
|
timers.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getCurrentScope()) {
|
||||||
|
onScopeDispose(() => {
|
||||||
|
disposed = true
|
||||||
|
timers.forEach((id) => clearInterval(id))
|
||||||
|
timers.clear()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 触发随机烟花效果
|
* 触发随机烟花效果
|
||||||
*/
|
*/
|
||||||
@@ -80,12 +106,11 @@ export function useFireworks() {
|
|||||||
startVelocity: 30,
|
startVelocity: 30,
|
||||||
spread: 360,
|
spread: 360,
|
||||||
ticks: 60,
|
ticks: 60,
|
||||||
zIndex: 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const interval: any = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const timeLeft = animationEnd - Date.now()
|
const timeLeft = animationEnd - Date.now()
|
||||||
if (timeLeft <= 0) return clearInterval(interval)
|
if (timeLeft <= 0) return stopTimer(interval)
|
||||||
|
|
||||||
const particleCount = 50 * (timeLeft / duration)
|
const particleCount = 50 * (timeLeft / duration)
|
||||||
confetti({
|
confetti({
|
||||||
@@ -101,30 +126,31 @@ export function useFireworks() {
|
|||||||
colors: ["#ff6b6b", "#ffd93d", "#6bcf7f", "#4ecdc4", "#a29bfe"],
|
colors: ["#ff6b6b", "#ffd93d", "#6bcf7f", "#4ecdc4", "#a29bfe"],
|
||||||
})
|
})
|
||||||
}, 250)
|
}, 250)
|
||||||
|
timers.add(interval)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 效果2: 星星雨
|
// 效果2: 星星雨
|
||||||
() => {
|
() => {
|
||||||
const count = 10
|
const animationEnd = Date.now() + 2000
|
||||||
const defaults = {
|
|
||||||
origin: { y: 0.7 },
|
const interval = setInterval(() => {
|
||||||
shapes: ["star"],
|
if (Date.now() >= animationEnd) return stopTimer(interval)
|
||||||
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
|
|
||||||
}
|
|
||||||
|
|
||||||
function fire(particleRatio: number, opts: any) {
|
|
||||||
confetti({
|
confetti({
|
||||||
...defaults,
|
particleCount: 12,
|
||||||
...opts,
|
angle: 270,
|
||||||
particleCount: Math.floor(200 * particleRatio),
|
spread: 100,
|
||||||
|
startVelocity: 12,
|
||||||
|
gravity: 0.6,
|
||||||
|
decay: 0.96,
|
||||||
|
ticks: 200,
|
||||||
|
scalar: 1.2,
|
||||||
|
shapes: ["star"],
|
||||||
|
origin: { x: Math.random(), y: 0 },
|
||||||
|
colors: ["#FFD700", "#FFA500", "#FFFF00", "#FF69B4", "#00CED1"],
|
||||||
})
|
})
|
||||||
}
|
}, 150)
|
||||||
|
timers.add(interval)
|
||||||
fire(0.25, { spread: 26, startVelocity: 55 })
|
|
||||||
fire(0.2, { spread: 60 })
|
|
||||||
fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8 })
|
|
||||||
fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 })
|
|
||||||
fire(0.1, { spread: 120, startVelocity: 45 })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 效果3: 爆炸波浪
|
// 效果3: 爆炸波浪
|
||||||
@@ -134,7 +160,7 @@ export function useFireworks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
setTimeout(() => {
|
runLater(() => {
|
||||||
confetti({
|
confetti({
|
||||||
angle: randomInRange(55, 125),
|
angle: randomInRange(55, 125),
|
||||||
spread: randomInRange(50, 70),
|
spread: randomInRange(50, 70),
|
||||||
@@ -150,7 +176,14 @@ export function useFireworks() {
|
|||||||
() => {
|
() => {
|
||||||
const end = Date.now() + 2000
|
const end = Date.now() + 2000
|
||||||
|
|
||||||
const colors = ["#bb0000", "#ffffff"]
|
const colors = [
|
||||||
|
"#ff595e",
|
||||||
|
"#ff924c",
|
||||||
|
"#ffca3a",
|
||||||
|
"#8ac926",
|
||||||
|
"#1982c4",
|
||||||
|
"#6a4c93",
|
||||||
|
]
|
||||||
|
|
||||||
const frame = () => {
|
const frame = () => {
|
||||||
confetti({
|
confetti({
|
||||||
@@ -168,7 +201,7 @@ export function useFireworks() {
|
|||||||
colors: colors,
|
colors: colors,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (Date.now() < end) {
|
if (Date.now() < end && !disposed) {
|
||||||
requestAnimationFrame(frame)
|
requestAnimationFrame(frame)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,9 +214,9 @@ export function useFireworks() {
|
|||||||
const duration = 2500
|
const duration = 2500
|
||||||
const animationEnd = Date.now() + duration
|
const animationEnd = Date.now() + duration
|
||||||
|
|
||||||
const interval: any = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const timeLeft = animationEnd - Date.now()
|
const timeLeft = animationEnd - Date.now()
|
||||||
if (timeLeft <= 0) return clearInterval(interval)
|
if (timeLeft <= 0) return stopTimer(interval)
|
||||||
|
|
||||||
const particleCount = 50
|
const particleCount = 50
|
||||||
confetti({
|
confetti({
|
||||||
@@ -205,6 +238,7 @@ export function useFireworks() {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
}, 200)
|
}, 200)
|
||||||
|
timers.add(interval)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 效果6: 炮竹齐鸣
|
// 效果6: 炮竹齐鸣
|
||||||
@@ -252,29 +286,32 @@ export function useFireworks() {
|
|||||||
|
|
||||||
// 效果7: 螺旋上升
|
// 效果7: 螺旋上升
|
||||||
() => {
|
() => {
|
||||||
const defaults = {
|
const steps = 16
|
||||||
spread: 360,
|
const colors = ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"]
|
||||||
ticks: 100,
|
|
||||||
gravity: 0,
|
for (let step = 0; step < steps; step++) {
|
||||||
decay: 0.94,
|
runLater(() => {
|
||||||
startVelocity: 30,
|
const progress = step / (steps - 1)
|
||||||
}
|
const phase = step * (Math.PI / 4)
|
||||||
|
|
||||||
function shoot() {
|
|
||||||
confetti({
|
confetti({
|
||||||
...defaults,
|
particleCount: 7,
|
||||||
particleCount: 50,
|
angle: 90 + Math.cos(phase) * 18,
|
||||||
scalar: 1.2,
|
spread: 25,
|
||||||
|
startVelocity: 16,
|
||||||
|
gravity: 0.25,
|
||||||
|
decay: 0.94,
|
||||||
|
ticks: 100,
|
||||||
|
scalar: 0.9,
|
||||||
shapes: ["circle", "square"],
|
shapes: ["circle", "square"],
|
||||||
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"],
|
origin: {
|
||||||
|
x: 0.5 + Math.sin(phase) * 0.14,
|
||||||
|
y: 0.85 - progress * 0.55,
|
||||||
|
},
|
||||||
|
colors,
|
||||||
})
|
})
|
||||||
|
}, step * 65)
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(shoot, 0)
|
|
||||||
setTimeout(shoot, 100)
|
|
||||||
setTimeout(shoot, 200)
|
|
||||||
setTimeout(shoot, 300)
|
|
||||||
setTimeout(shoot, 400)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 效果8: 表情包烟花
|
// 效果8: 表情包烟花
|
||||||
@@ -282,6 +319,7 @@ export function useFireworks() {
|
|||||||
loadEmojiShapes().then((emojiShapes) => {
|
loadEmojiShapes().then((emojiShapes) => {
|
||||||
confetti({
|
confetti({
|
||||||
shapes: emojiShapes,
|
shapes: emojiShapes,
|
||||||
|
flat: true,
|
||||||
scalar: 6,
|
scalar: 6,
|
||||||
particleCount: 18,
|
particleCount: 18,
|
||||||
spread: 360,
|
spread: 360,
|
||||||
@@ -298,7 +336,7 @@ export function useFireworks() {
|
|||||||
const colors = ["#ff9a9e", "#fad0c4", "#fbc2eb", "#a18cd1", "#fad961"]
|
const colors = ["#ff9a9e", "#fad0c4", "#fbc2eb", "#a18cd1", "#fad961"]
|
||||||
|
|
||||||
for (let angle = 0; angle < 360; angle += 30) {
|
for (let angle = 0; angle < 360; angle += 30) {
|
||||||
setTimeout(
|
runLater(
|
||||||
() => {
|
() => {
|
||||||
confetti({
|
confetti({
|
||||||
particleCount: 15,
|
particleCount: 15,
|
||||||
@@ -316,16 +354,28 @@ export function useFireworks() {
|
|||||||
|
|
||||||
// 效果10: 礼花瀑布
|
// 效果10: 礼花瀑布
|
||||||
() => {
|
() => {
|
||||||
|
const animationEnd = Date.now() + 1800
|
||||||
|
const colors = ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"]
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (Date.now() >= animationEnd) return stopTimer(interval)
|
||||||
|
|
||||||
|
for (let column = 0; column < 6; column++) {
|
||||||
confetti({
|
confetti({
|
||||||
particleCount: 150,
|
particleCount: 2,
|
||||||
spread: 180,
|
angle: 270,
|
||||||
startVelocity: 0,
|
spread: 30,
|
||||||
gravity: 1.2,
|
startVelocity: 5,
|
||||||
decay: 0.95,
|
gravity: 1,
|
||||||
ticks: 300,
|
decay: 0.96,
|
||||||
origin: { x: 0.5, y: 0 },
|
ticks: 240,
|
||||||
colors: ["#f6d365", "#fda085", "#fbc2eb", "#a6c1ee", "#84fab0"],
|
scalar: 1.1,
|
||||||
|
origin: { x: (column + Math.random()) / 6, y: 0 },
|
||||||
|
colors,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}, 120)
|
||||||
|
timers.add(interval)
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { ref, computed, watch, onUnmounted } from "vue"
|
|||||||
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
||||||
import { getSubmission } from "oj/api"
|
import { getSubmission } from "oj/api"
|
||||||
import { SubmissionStatus } from "utils/constants"
|
import { SubmissionStatus } from "utils/constants"
|
||||||
import type { Submission } from "utils/types"
|
import type { PendingAchievement, Submission } from "utils/types"
|
||||||
|
import { useAchievementStore } from "shared/store/achievement"
|
||||||
import {
|
import {
|
||||||
useSubmissionWebSocket,
|
useSubmissionWebSocket,
|
||||||
type SubmissionUpdate,
|
type SubmissionUpdate,
|
||||||
@@ -45,6 +46,20 @@ export function useSubmissionMonitor() {
|
|||||||
|
|
||||||
// ==================== WebSocket 处理 ====================
|
// ==================== WebSocket 处理 ====================
|
||||||
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
||||||
|
// push_to_user 复用了 submission_update 这个 channel handler,
|
||||||
|
// 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流
|
||||||
|
const frame = data as unknown as {
|
||||||
|
type: string
|
||||||
|
achievements?: PendingAchievement[]
|
||||||
|
}
|
||||||
|
if (frame.type === "achievement_unlocked") {
|
||||||
|
useAchievementStore().enqueue(frame.achievements ?? [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.type !== "submission_update") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
|
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
|
||||||
|
|
||||||
if (data.submission_id !== submissionId.value) {
|
if (data.submission_id !== submissionId.value) {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ const ProblemInfo = defineAsyncComponent(
|
|||||||
const ProblemSubmission = defineAsyncComponent(
|
const ProblemSubmission = defineAsyncComponent(
|
||||||
() => import("./components/ProblemSubmission.vue"),
|
() => import("./components/ProblemSubmission.vue"),
|
||||||
)
|
)
|
||||||
const ProblemComment = defineAsyncComponent(
|
const ProblemReaction = defineAsyncComponent(
|
||||||
() => import("./components/ProblemComment.vue"),
|
() => import("./components/ProblemReaction.vue"),
|
||||||
)
|
)
|
||||||
const ProblemFlowchart = defineAsyncComponent(
|
const ProblemFlowchart = defineAsyncComponent(
|
||||||
() => import("./components/ProblemFlowchart.vue"),
|
() => import("./components/ProblemFlowchart.vue"),
|
||||||
@@ -130,6 +130,16 @@ onBeforeUnmount(() => {
|
|||||||
watch(isMobile, (value) => {
|
watch(isMobile, (value) => {
|
||||||
if (value) screenModeStore.resetScreenMode()
|
if (value) screenModeStore.resetScreenMode()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// SQL 题不支持"自测"模式(外部代码运行器无法执行 SQL),切到该屏时自动跳到下一模式
|
||||||
|
watch(
|
||||||
|
() => screenModeStore.isCodeOnlyMode,
|
||||||
|
(codeOnly) => {
|
||||||
|
if (codeOnly && problem.value?.languages.includes("SQL")) {
|
||||||
|
screenModeStore.switchScreenMode()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -164,7 +174,7 @@ watch(isMobile, (value) => {
|
|||||||
tab="题目点评"
|
tab="题目点评"
|
||||||
:disabled="!!problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemComment />
|
<ProblemReaction />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
v-if="myFlowchartStore.showing"
|
v-if="myFlowchartStore.showing"
|
||||||
@@ -216,7 +226,7 @@ watch(isMobile, (value) => {
|
|||||||
tab="题目点评"
|
tab="题目点评"
|
||||||
:disabled="!!problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemComment />
|
<ProblemReaction />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
v-if="myFlowchartStore.showing"
|
v-if="myFlowchartStore.showing"
|
||||||
@@ -256,7 +266,7 @@ watch(isMobile, (value) => {
|
|||||||
tab="点评"
|
tab="点评"
|
||||||
:disabled="!!problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemComment />
|
<ProblemReaction />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
v-if="myFlowchartStore.showing"
|
v-if="myFlowchartStore.showing"
|
||||||
|
|||||||
@@ -160,12 +160,18 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
|
|||||||
render: (row) => h(ProblemStatus, { status: row.status }),
|
render: (row) => h(ProblemStatus, { status: row.status }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("编号", "streamline-emojis:game-dice"),
|
title: renderTableTitle(
|
||||||
|
"编号",
|
||||||
|
"streamline-ultimate-color:board-game-dice-1",
|
||||||
|
),
|
||||||
key: "_id",
|
key: "_id",
|
||||||
width: 100,
|
width: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("题目", "streamline-emojis:watermelon-2"),
|
title: renderTableTitle(
|
||||||
|
"题目",
|
||||||
|
"streamline-ultimate-color:fruit-watermelon",
|
||||||
|
),
|
||||||
key: "title",
|
key: "title",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
render: (row) => h(ProblemListTitle, { problem: row }),
|
render: (row) => h(ProblemListTitle, { problem: row }),
|
||||||
@@ -178,7 +184,7 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
|
|||||||
h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty),
|
h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("标签", "streamline-emojis:paperclip"),
|
title: renderTableTitle("标签", "streamline-ultimate-color:attachment"),
|
||||||
key: "tags",
|
key: "tags",
|
||||||
width: 260,
|
width: 260,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
@@ -190,7 +196,7 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
|
|||||||
width: 130,
|
width: 130,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("提交数", "streamline-emojis:writing-hand-2"),
|
title: renderTableTitle("提交数", "streamline-ultimate-color:paper-write"),
|
||||||
key: "submission",
|
key: "submission",
|
||||||
align: "center",
|
align: "center",
|
||||||
width: 100,
|
width: 100,
|
||||||
|
|||||||
@@ -1,15 +1,47 @@
|
|||||||
// @ts-ignore - skulpt has no type definitions
|
|
||||||
import Sk from "skulpt"
|
|
||||||
|
|
||||||
export interface PythonSyntaxError {
|
export interface PythonSyntaxError {
|
||||||
line: number
|
line: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let skulptPromise: Promise<any> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按需加载 Skulpt(约 233KB gzip),只在提交 Python3 代码时才下载。
|
||||||
|
* 结果缓存,同一页面只加载一次。
|
||||||
|
*/
|
||||||
|
function loadSkulpt(): Promise<any> {
|
||||||
|
if (!skulptPromise) {
|
||||||
|
// @ts-ignore - skulpt has no type definitions
|
||||||
|
skulptPromise = import("skulpt").then((m) => m.default ?? m)
|
||||||
|
}
|
||||||
|
return skulptPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提前把 Skulpt 拉下来,避免点提交时才开始下载。
|
||||||
|
* 失败不影响功能,提交时会再试一次。
|
||||||
|
*/
|
||||||
|
export function prefetchPythonSyntaxChecker() {
|
||||||
|
loadSkulpt().catch(() => {
|
||||||
|
skulptPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用 Skulpt 检测 Python 代码中的语法错误。
|
* 用 Skulpt 检测 Python 代码中的语法错误。
|
||||||
* 只编译不执行,不受 input() 等 IO 调用影响。
|
* 只编译不执行,不受 input() 等 IO 调用影响。
|
||||||
|
* 加载失败时返回 null(放行提交),交给后端判题兜底。
|
||||||
*/
|
*/
|
||||||
export function checkPythonSyntax(code: string): PythonSyntaxError | null {
|
export async function checkPythonSyntax(
|
||||||
|
code: string,
|
||||||
|
): Promise<PythonSyntaxError | null> {
|
||||||
|
let Sk: any
|
||||||
|
try {
|
||||||
|
Sk = await loadSkulpt()
|
||||||
|
} catch {
|
||||||
|
skulptPromise = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
Sk.configure({ output: () => {} })
|
Sk.configure({ output: () => {} })
|
||||||
try {
|
try {
|
||||||
Sk.compile(code, "prog.py", "exec")
|
Sk.compile(code, "prog.py", "exec")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
|
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
|
||||||
import UserBadge from "shared/components/UserBadge.vue"
|
import UserBadge from "shared/components/UserBadge.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ function handleJoin() {
|
|||||||
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
|
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
|
||||||
<n-tooltip trigger="hover" v-if="problemSet.description">
|
<n-tooltip trigger="hover" v-if="problemSet.description">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<Icon width="20" icon="emojione:information" />
|
<Icon width="20" icon="fluent-emoji:information" />
|
||||||
</template>
|
</template>
|
||||||
{{ problemSet.description }}
|
{{ problemSet.description }}
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
@@ -99,7 +99,7 @@ function handleJoin() {
|
|||||||
</n-button>
|
</n-button>
|
||||||
<n-tag v-else type="success" size="large">
|
<n-tag v-else type="success" size="large">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="material-symbols:check-circle" />
|
<Icon icon="ph:check-circle-fill" />
|
||||||
</template>
|
</template>
|
||||||
已加入
|
已加入
|
||||||
</n-tag>
|
</n-tag>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { ProblemSetProblem } from "utils/types"
|
import type { ProblemSetProblem } from "utils/types"
|
||||||
import { DIFFICULTY } from "utils/constants"
|
import { DIFFICULTY } from "utils/constants"
|
||||||
import { getTagColor } from "utils/functions"
|
import { getTagColor } from "utils/functions"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
@@ -40,7 +40,7 @@ function handleProblemClick(problemId: string) {
|
|||||||
<Icon
|
<Icon
|
||||||
style="margin-right: 10px"
|
style="margin-right: 10px"
|
||||||
width="48"
|
width="48"
|
||||||
icon="noto:check-mark-button"
|
icon="fluent-emoji:check-mark-button"
|
||||||
v-if="problemSetProblem.is_completed"
|
v-if="problemSetProblem.is_completed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { h, computed, ref, onMounted, watch } from "vue"
|
import { h, computed, ref, onMounted, watch } from "vue"
|
||||||
import { watchDebounced } from "@vueuse/core"
|
import { watchDebounced } from "@vueuse/core"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { ProblemSetProgress } from "utils/types"
|
import type { ProblemSetProgress } from "utils/types"
|
||||||
import { getProblemSetUserProgress } from "../../api"
|
import { getProblemSetUserProgress } from "../../api"
|
||||||
import { NFlex, NTag } from "naive-ui"
|
import { NFlex, NTag } from "naive-ui"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
joinProblemSet,
|
joinProblemSet,
|
||||||
getUserBadges,
|
getUserBadges,
|
||||||
} from "../api"
|
} from "../api"
|
||||||
import {
|
import type {
|
||||||
ProblemSet,
|
ProblemSet,
|
||||||
ProblemSetProblem,
|
ProblemSetProblem,
|
||||||
UserBadge as UserBadgeType,
|
UserBadge as UserBadgeType,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Colors,
|
Colors,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import { ChartType } from "utils/constants"
|
import { ChartType } from "utils/constants"
|
||||||
import { Rank } from "utils/types"
|
import type { Rank } from "utils/types"
|
||||||
|
|
||||||
// 仅注册柱状图所需的 Chart.js 组件
|
// 仅注册柱状图所需的 Chart.js 组件
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ async function analyzeSingleClassWithAI() {
|
|||||||
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
|
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
|
||||||
},
|
},
|
||||||
onMessage(payload) {
|
onMessage(payload) {
|
||||||
const parsed = payload as { type?: string; content?: string; message?: string }
|
const parsed = payload as {
|
||||||
|
type?: string
|
||||||
|
content?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
if (parsed.type === "delta" && parsed.content) {
|
if (parsed.type === "delta" && parsed.content) {
|
||||||
if (!hasStarted) {
|
if (!hasStarted) {
|
||||||
hasStarted = true
|
hasStarted = true
|
||||||
@@ -194,8 +198,9 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
"streamline-emojis:smiling-face-with-sunglasses",
|
"streamline-emojis:smiling-face-with-sunglasses",
|
||||||
),
|
),
|
||||||
key: "username",
|
key: "username",
|
||||||
width: 200,
|
width: 240,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
|
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||||||
h(
|
h(
|
||||||
NButton,
|
NButton,
|
||||||
{
|
{
|
||||||
@@ -205,6 +210,18 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
},
|
},
|
||||||
() => row.user.username,
|
() => row.user.username,
|
||||||
),
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
size: "tiny",
|
||||||
|
title: "查看成就",
|
||||||
|
onClick: () =>
|
||||||
|
router.push("/achievement?name=" + row.user.username),
|
||||||
|
},
|
||||||
|
() => "🏆",
|
||||||
|
),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle(
|
title: renderTableTitle(
|
||||||
@@ -221,13 +238,16 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("提交数", "streamline-emojis:rocket"),
|
title: renderTableTitle(
|
||||||
|
"提交数",
|
||||||
|
"streamline-ultimate-color:space-rocket-earth",
|
||||||
|
),
|
||||||
key: "submission_number",
|
key: "submission_number",
|
||||||
width: 120,
|
width: 120,
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("正确率", "streamline-emojis:wrapped-gift-1"),
|
title: renderTableTitle("正确率", "streamline-ultimate-color:gift-box-1"),
|
||||||
key: "rate",
|
key: "rate",
|
||||||
width: 120,
|
width: 120,
|
||||||
align: "center",
|
align: "center",
|
||||||
@@ -371,8 +391,9 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
|
|||||||
{
|
{
|
||||||
title: "用户名",
|
title: "用户名",
|
||||||
key: "username",
|
key: "username",
|
||||||
width: 200,
|
width: 240,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
|
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||||||
h(
|
h(
|
||||||
NButton,
|
NButton,
|
||||||
{
|
{
|
||||||
@@ -397,6 +418,17 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
|
|||||||
)
|
)
|
||||||
: row.username,
|
: row.username,
|
||||||
),
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
size: "tiny",
|
||||||
|
title: "查看成就",
|
||||||
|
onClick: () => router.push("/achievement?name=" + row.username),
|
||||||
|
},
|
||||||
|
() => "🏆",
|
||||||
|
),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "已解决",
|
title: "已解决",
|
||||||
@@ -606,7 +638,10 @@ watch(
|
|||||||
class="stat-avg-ac"
|
class="stat-avg-ac"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon icon="streamline-emojis:chart" width="20" />
|
<Icon
|
||||||
|
icon="streamline-ultimate-color:analytics-pie-2"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-statistic>
|
</n-statistic>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -618,7 +653,10 @@ watch(
|
|||||||
class="stat-median-ac"
|
class="stat-median-ac"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon icon="streamline-emojis:target" width="20" />
|
<Icon
|
||||||
|
icon="streamline-ultimate-color:cursor-target-1"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-statistic>
|
</n-statistic>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -630,7 +668,10 @@ watch(
|
|||||||
class="stat-total-submission"
|
class="stat-total-submission"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon icon="streamline-emojis:paper" width="20" />
|
<Icon
|
||||||
|
icon="streamline-ultimate-color:common-file-text"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</n-statistic>
|
</n-statistic>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -642,7 +683,7 @@ watch(
|
|||||||
class="stat-ac-rate"
|
class="stat-ac-rate"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon icon="streamline-emojis:check-mark" width="20" />
|
<Icon icon="fluent-emoji:check-mark-button" width="20" />
|
||||||
</template>
|
</template>
|
||||||
</n-statistic>
|
</n-statistic>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -736,7 +777,12 @@ watch(
|
|||||||
</n-space>
|
</n-space>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-flex justify="center" align="center" :size="12" style="margin-top: 12px">
|
<n-flex
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
:size="12"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
>
|
||||||
<n-tag type="success" size="large">
|
<n-tag type="success" size="large">
|
||||||
综合分: {{ classDetailData.composite_score.toFixed(1) }}
|
综合分: {{ classDetailData.composite_score.toFixed(1) }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
@@ -747,7 +793,7 @@ watch(
|
|||||||
@click="analyzeSingleClassWithAI"
|
@click="analyzeSingleClassWithAI"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="mingcute:ai-line" />
|
<Icon icon="ph:sparkle" />
|
||||||
</template>
|
</template>
|
||||||
AI分析
|
AI分析
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -769,7 +815,10 @@ watch(
|
|||||||
>
|
>
|
||||||
<n-spin :show="classDetailAiLoading" :delay="50">
|
<n-spin :show="classDetailAiLoading" :delay="50">
|
||||||
<div style="min-height: 200px">
|
<div style="min-height: 200px">
|
||||||
<MdPreview v-if="classDetailAiContent" :model-value="classDetailAiContent" />
|
<MdPreview
|
||||||
|
v-if="classDetailAiContent"
|
||||||
|
:model-value="classDetailAiContent"
|
||||||
|
/>
|
||||||
<n-flex
|
<n-flex
|
||||||
v-else-if="!classDetailAiLoading"
|
v-else-if="!classDetailAiLoading"
|
||||||
align="center"
|
align="center"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DetailsData, DurationData } from "utils/types"
|
import type { DetailsData, DurationData } from "utils/types"
|
||||||
import { consumeJSONEventStream } from "utils/stream"
|
import { consumeJSONEventStream } from "utils/stream"
|
||||||
import {
|
import {
|
||||||
getAIDetailData,
|
getAIDetailData,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
import { STORAGE_KEY } from "utils/constants"
|
import { STORAGE_KEY } from "utils/constants"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import { Code, LANGUAGE } from "utils/types"
|
import type { Code, LANGUAGE } from "utils/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代码编辑器状态管理 Store
|
* 代码编辑器状态管理 Store
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { formatISO, getTime, parseISO } from "date-fns"
|
|||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { ContestStatus, ContestType } from "utils/constants"
|
import { ContestStatus, ContestType } from "utils/constants"
|
||||||
import { duration } from "utils/functions"
|
import { duration } from "utils/functions"
|
||||||
import { Contest, Problem } from "utils/types"
|
import type { Contest, Problem } from "utils/types"
|
||||||
import {
|
import {
|
||||||
checkContestPassword,
|
checkContestPassword,
|
||||||
getContest,
|
getContest,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { USERNAME_CLASS_RE } from "utils/constants"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "题目" | "用户"
|
type: "题目" | "用户"
|
||||||
@@ -10,12 +11,14 @@ const props = defineProps<Props>()
|
|||||||
|
|
||||||
const emits = defineEmits(["click", "search", "filterClass"])
|
const emits = defineEmits(["click", "search", "filterClass"])
|
||||||
|
|
||||||
|
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
|
||||||
|
// 也显示出按钮,点了却什么都不发生
|
||||||
const showFilterClass = computed(() => {
|
const showFilterClass = computed(() => {
|
||||||
return props.type === "用户" && props.username?.startsWith("ks")
|
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
|
||||||
})
|
})
|
||||||
|
|
||||||
function filterClass() {
|
function filterClass() {
|
||||||
const match = props.username!.match(/^ks\d{3,4}/)
|
const match = props.username!.match(USERNAME_CLASS_RE)
|
||||||
const classname = match ? match[0] : ""
|
const classname = match ? match[0] : ""
|
||||||
if (!classname) return
|
if (!classname) return
|
||||||
emits("filterClass", classname)
|
emits("filterClass", classname)
|
||||||
@@ -40,7 +43,7 @@ function filterClass() {
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button text @click="filterClass">
|
<n-button text @click="filterClass">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="openmoji:filter"></Icon>
|
<Icon icon="ph:funnel"></Icon>
|
||||||
</template>
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
@click="showLargeImage = true"
|
@click="showLargeImage = true"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="mdi:fullscreen" />
|
<Icon icon="ph:corners-out" />
|
||||||
</template>
|
</template>
|
||||||
查看大图
|
查看大图
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<div v-if="showLargeImage" class="fullscreen-toolbar">
|
<div v-if="showLargeImage" class="fullscreen-toolbar">
|
||||||
<n-button secondary round @click="showLargeImage = false">
|
<n-button secondary round @click="showLargeImage = false">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon icon="mdi:fullscreen-exit" />
|
<Icon icon="ph:corners-in" />
|
||||||
</template>
|
</template>
|
||||||
退出大图
|
退出大图
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { FlowchartSubmission } from "utils/types"
|
import type { FlowchartSubmission } from "utils/types"
|
||||||
import { useMermaid } from "shared/composables/useMermaid"
|
import { useMermaid } from "shared/composables/useMermaid"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</n-text>
|
</n-text>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Grade } from "utils/types"
|
import type { Grade } from "utils/types"
|
||||||
defineProps<{
|
defineProps<{
|
||||||
score: number
|
score: number
|
||||||
grade: Grade
|
grade: Grade
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
submissionTimeFormat,
|
submissionTimeFormat,
|
||||||
utoa,
|
utoa,
|
||||||
} from "utils/functions"
|
} from "utils/functions"
|
||||||
import { Submission } from "utils/types"
|
import type { Submission } from "utils/types"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
@@ -127,7 +127,13 @@ onMounted(init)
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-flex :vertical="isDesktop" justify="center">
|
<n-flex :vertical="isDesktop" justify="center">
|
||||||
<n-button secondary @click="copyToCat">复制到自测猫</n-button>
|
<n-button
|
||||||
|
v-if="submission.language !== 'SQL'"
|
||||||
|
secondary
|
||||||
|
@click="copyToCat"
|
||||||
|
>
|
||||||
|
复制到自测猫
|
||||||
|
</n-button>
|
||||||
<n-button secondary @click="copyToProblem">复制回到题目</n-button>
|
<n-button secondary @click="copyToProblem">复制回到题目</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ watch(
|
|||||||
const columns = computed(() => {
|
const columns = computed(() => {
|
||||||
const res: DataTableColumn<SubmissionListItem>[] = [
|
const res: DataTableColumn<SubmissionListItem>[] = [
|
||||||
{
|
{
|
||||||
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
|
||||||
key: "create_time",
|
key: "create_time",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||||
@@ -267,10 +267,7 @@ const columns = computed(() => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle(
|
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
|
||||||
"语言",
|
|
||||||
"streamline-emojis:globe-showing-europe-africa",
|
|
||||||
),
|
|
||||||
key: "language",
|
key: "language",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
||||||
@@ -319,7 +316,7 @@ const columns = computed(() => {
|
|||||||
const flowchartColumns = computed(() => {
|
const flowchartColumns = computed(() => {
|
||||||
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||||
{
|
{
|
||||||
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
|
||||||
key: "create_time",
|
key: "create_time",
|
||||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||||
},
|
},
|
||||||
@@ -347,7 +344,10 @@ const flowchartColumns = computed(() => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: renderTableTitle("评分", "streamline-emojis:bar-chart"),
|
title: renderTableTitle(
|
||||||
|
"评分",
|
||||||
|
"streamline-ultimate-color:analytics-bars-3d",
|
||||||
|
),
|
||||||
key: "ai_score",
|
key: "ai_score",
|
||||||
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
|
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user