feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
# Submit Formatting Button State Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show `格式化中` on the submit button during automatic formatting, then show `正在提交` continuously while the submission request is pending.
|
||||
|
||||
**Architecture:** Extract the button presentation rules into a small pure TypeScript function so the state priority can be tested without adding a frontend test framework. Keep formatter and submission-request flags local to `SubmitCode.vue`, with `finally` blocks ensuring both flags clear on every outcome.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TypeScript, Node.js built-in test runner, Rsbuild
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Define and test submit button presentation rules
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/submitButtonState.test.ts`
|
||||
- Create: `src/oj/problem/components/submitButtonState.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/submitButtonState.test.ts`:
|
||||
|
||||
```ts
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { getSubmitButtonState } from "../src/oj/problem/components/submitButtonState.ts"
|
||||
|
||||
const idleInput = {
|
||||
isAuthed: true,
|
||||
hasCode: true,
|
||||
isFormatting: false,
|
||||
isSubmitting: false,
|
||||
isJudging: false,
|
||||
isCooldown: false,
|
||||
}
|
||||
|
||||
test("shows a disabled loading state while formatting", () => {
|
||||
assert.deepEqual(
|
||||
getSubmitButtonState({ ...idleInput, isFormatting: true }),
|
||||
{
|
||||
disabled: true,
|
||||
label: "格式化中",
|
||||
icon: "eos-icons:loading",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("shows submitting immediately after formatting", () => {
|
||||
assert.deepEqual(
|
||||
getSubmitButtonState({ ...idleInput, isSubmitting: true }),
|
||||
{
|
||||
disabled: true,
|
||||
label: "正在提交",
|
||||
icon: "eos-icons:loading",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves existing login, judging, cooldown, and idle states", () => {
|
||||
assert.deepEqual(
|
||||
getSubmitButtonState({ ...idleInput, isAuthed: false }),
|
||||
{
|
||||
disabled: true,
|
||||
label: "请先登录",
|
||||
icon: "ph:play-fill",
|
||||
},
|
||||
)
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isJudging: true }), {
|
||||
disabled: true,
|
||||
label: "正在评分",
|
||||
icon: "eos-icons:loading",
|
||||
})
|
||||
assert.deepEqual(getSubmitButtonState({ ...idleInput, isCooldown: true }), {
|
||||
disabled: true,
|
||||
label: "正在冷却",
|
||||
icon: "ph:lightbulb-fill",
|
||||
})
|
||||
assert.deepEqual(getSubmitButtonState(idleInput), {
|
||||
disabled: false,
|
||||
label: "提交代码",
|
||||
icon: "ph:play-fill",
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/submitButtonState.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `submitButtonState.ts`.
|
||||
|
||||
- [ ] **Step 3: Implement the pure state function**
|
||||
|
||||
Create `src/oj/problem/components/submitButtonState.ts`:
|
||||
|
||||
```ts
|
||||
export interface SubmitButtonStateInput {
|
||||
isAuthed: boolean
|
||||
hasCode: boolean
|
||||
isFormatting: boolean
|
||||
isSubmitting: boolean
|
||||
isJudging: boolean
|
||||
isCooldown: boolean
|
||||
}
|
||||
|
||||
export interface SubmitButtonState {
|
||||
disabled: boolean
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export function getSubmitButtonState({
|
||||
isAuthed,
|
||||
hasCode,
|
||||
isFormatting,
|
||||
isSubmitting,
|
||||
isJudging,
|
||||
isCooldown,
|
||||
}: SubmitButtonStateInput): SubmitButtonState {
|
||||
const disabled =
|
||||
!isAuthed ||
|
||||
!hasCode ||
|
||||
isFormatting ||
|
||||
isSubmitting ||
|
||||
isJudging ||
|
||||
isCooldown
|
||||
|
||||
let label = "提交代码"
|
||||
if (!isAuthed) {
|
||||
label = "请先登录"
|
||||
} else if (isFormatting) {
|
||||
label = "格式化中"
|
||||
} else if (isSubmitting) {
|
||||
label = "正在提交"
|
||||
} else if (isJudging) {
|
||||
label = "正在评分"
|
||||
} else if (isCooldown) {
|
||||
label = "正在冷却"
|
||||
}
|
||||
|
||||
const icon =
|
||||
isFormatting || isSubmitting || isJudging
|
||||
? "eos-icons:loading"
|
||||
: isCooldown
|
||||
? "ph:lightbulb-fill"
|
||||
: "ph:play-fill"
|
||||
|
||||
return { disabled, label, icon }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/submitButtonState.test.ts
|
||||
```
|
||||
|
||||
Expected: 3 tests pass.
|
||||
|
||||
### Task 2: Connect formatting and submission request lifecycle to the button
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/oj/problem/components/SubmitCode.vue`
|
||||
|
||||
- [ ] **Step 1: Add local request states and computed presentation**
|
||||
|
||||
Import `getSubmitButtonState`, add `isFormatting` and `isSubmittingRequest` refs, and replace the three existing button computed properties with:
|
||||
|
||||
```ts
|
||||
const buttonState = computed(() =>
|
||||
getSubmitButtonState({
|
||||
isAuthed: userStore.isAuthed,
|
||||
hasCode: codeStore.code.value.trim() !== "",
|
||||
isFormatting: isFormatting.value,
|
||||
isSubmitting: isSubmittingRequest.value || submitting.value,
|
||||
isJudging: judging.value || pending.value,
|
||||
isCooldown: isCooldown.value,
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `buttonState.disabled`, `buttonState.icon`, and `buttonState.label` in the template.
|
||||
|
||||
- [ ] **Step 2: Guard and track the formatting request**
|
||||
|
||||
At the start of `submit`, return when `buttonState.value.disabled` is true. Around `formatCode`, set `isFormatting.value = true` before the request and clear it in `finally`:
|
||||
|
||||
```ts
|
||||
isFormatting.value = true
|
||||
try {
|
||||
const res = await formatCode({
|
||||
code: codeStore.code.value,
|
||||
language: formatLang,
|
||||
})
|
||||
codeStore.setCode(res.data.code)
|
||||
} catch (e: any) {
|
||||
if (e?.error === "format-error") {
|
||||
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
isFormatting.value = false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Track the submission API request**
|
||||
|
||||
Set `isSubmittingRequest.value = true` immediately before `submitCode`, keep the existing success flow inside the `try`, and clear the request state in `finally`:
|
||||
|
||||
```ts
|
||||
isSubmittingRequest.value = true
|
||||
try {
|
||||
const res = await submitCode(data)
|
||||
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
|
||||
|
||||
startCooldown()
|
||||
startMonitoring(res.data.submission_id)
|
||||
showResult.value = true
|
||||
} finally {
|
||||
isSubmittingRequest.value = false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/submitButtonState.test.ts
|
||||
```
|
||||
|
||||
Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Step 5: Run the production build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: Rsbuild exits with status 0.
|
||||
|
||||
- [ ] **Step 6: Check the final diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git diff -- src/oj/problem/components/SubmitCode.vue src/oj/problem/components/submitButtonState.ts tests/submitButtonState.test.ts
|
||||
```
|
||||
|
||||
Expected: no whitespace errors; diff is limited to the button state feature and its test.
|
||||
296
apps/web/docs/superpowers/plans/2026-07-26-demo-student-view.md
Normal file
296
apps/web/docs/superpowers/plans/2026-07-26-demo-student-view.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 学生视角(演示模式)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 超级管理员在右上角下拉菜单点一下,整站界面变成普通学生看到的样子,再点一下恢复。
|
||||
|
||||
**Architecture:** 纯前端伪装。全站 40 多处权限判断读的都是 `shared/store/user.ts` 里的 6 个角色 getter,没有一处直接读 `user.admin_type`。在 store 里加一个 `demoMode` 开关,给每个 getter 加 `!demoMode.value &&` 前缀,全站自动跟随。路由守卫(`src/main.ts`)、权限工具(`src/utils/permissions.ts`)、各页面 `v-if` 零改动。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>` + TypeScript,Pinia setup store,Naive UI(`n-dropdown` / `DropdownOption`),Vite。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-26-demo-student-view-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **不写测试。** 项目根 CLAUDE.md 明确规定 "Do not write new tests",且 ojnext 无测试框架。本计划的验证步骤全部是 `npm run build` 冒烟 + 浏览器手工核对。
|
||||
- **不改后端。** 演示模式是界面伪装,登录态仍是超管,接口权限不变。
|
||||
- 仅超级管理员可见此开关。教师管理员、学生管理员不提供。
|
||||
- 自动导入已配置:`ref` / `computed` / `useRouter` / `useRoute` / Naive UI 组件与类型(`DropdownOption`)均**不需要手写 import**。
|
||||
- 存储 key 常量统一放 `src/utils/constants.ts` 的 `STORAGE_KEY`,读写走 `src/utils/storage.ts` 默认导出(内部做 JSON 序列化)。
|
||||
- 提交前跑 `npm fmt`(Prettier)。
|
||||
- 中文注释、中文 UI 文案,与现有代码一致。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 职责 | 本次改动 |
|
||||
|---|---|---|
|
||||
| `src/utils/constants.ts` | 全局常量 | `STORAGE_KEY` 增加一个键 |
|
||||
| `src/shared/store/user.ts` | 用户身份与角色判断的唯一来源 | 新增 `demoMode` 状态与伪装逻辑(改动主体) |
|
||||
| `src/shared/components/Header.vue` | 顶栏与用户下拉菜单 | 新增菜单项与切换处理函数 |
|
||||
|
||||
不新建文件。`demoMode` 放进已有的 `user` store 而不是单开一个 store —— 它伪装的就是这个 store 的输出,分开会让两个 store 循环依赖。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: store 层伪装开关
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/constants.ts:147-153`
|
||||
- Modify: `src/shared/store/user.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 无(第一个任务)
|
||||
- Produces: `useUserStore()` 新增三个成员,供 Task 2 使用:
|
||||
- `demoMode: boolean` — 当前是否处于演示模式(store 解包后为布尔值)
|
||||
- `canToggleDemoMode: boolean` — 是否显示切换入口(真实超管身份,不受伪装影响)
|
||||
- `toggleDemoMode(): void` — 翻转开关并写入 localStorage
|
||||
|
||||
- [ ] **Step 1: 在 `STORAGE_KEY` 增加常量**
|
||||
|
||||
打开 `src/utils/constants.ts`,把 `STORAGE_KEY` 改成:
|
||||
|
||||
```ts
|
||||
export const STORAGE_KEY = {
|
||||
AUTHED: "authed",
|
||||
LANGUAGE: "problemLanguage",
|
||||
LEARN_CURRENT_STEP: "learnStep",
|
||||
ADMIN_PROBLEM: "adminProblem",
|
||||
ADMIN_PROBLEM_TAGS: "adminProblemTags",
|
||||
DEMO_MODE: "demoMode",
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 user store 加入 `demoMode` 与真实身份 getter**
|
||||
|
||||
打开 `src/shared/store/user.ts`。在 `const isAuthed = ...` 那一行之后、`const isAdminRole = ...` 之前,插入:
|
||||
|
||||
```ts
|
||||
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏
|
||||
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
||||
|
||||
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
|
||||
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
|
||||
const realIsSuperAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
```
|
||||
|
||||
`storage` 和 `STORAGE_KEY` 文件顶部已经 import 过,不需要新增 import。
|
||||
|
||||
- [ ] **Step 3: 给 6 个角色 getter 加上伪装前缀**
|
||||
|
||||
把 `src/shared/store/user.ts` 中原有的 6 个 getter(`isAdminRole`、`isStudentAdmin`、`isTeacherAdmin`、`isTeacherOrAbove`、`isSuperAdmin`、`hasProblemPermission`)整段替换为:
|
||||
|
||||
```ts
|
||||
const isAdminRole = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
(user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
|
||||
)
|
||||
const isStudentAdmin = computed(
|
||||
() => !demoMode.value && user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
|
||||
)
|
||||
const isTeacherAdmin = computed(
|
||||
() => !demoMode.value && user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
|
||||
)
|
||||
const isTeacherOrAbove = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
(user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
|
||||
)
|
||||
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
|
||||
const hasProblemPermission = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
user.value?.problem_permission !== PROBLEM_PERMISSION.NONE,
|
||||
)
|
||||
```
|
||||
|
||||
注意:`isAdminRole` 和 `isTeacherOrAbove` 原本是多个 `||` 连成的表达式,加前缀时**必须给原表达式套一层括号**,否则 `&&` 的优先级会让第一个 `||` 分支逃过伪装。
|
||||
|
||||
- [ ] **Step 4: 加入切换能力与切换函数**
|
||||
|
||||
紧接在 `hasProblemPermission` 之后插入:
|
||||
|
||||
```ts
|
||||
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
||||
|
||||
function toggleDemoMode() {
|
||||
demoMode.value = !demoMode.value
|
||||
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 退出登录时重置内存中的开关**
|
||||
|
||||
`storage.clear()` 会清掉 localStorage 里的标记,但内存中的 ref 还留着,同一次会话里换账号登录会带过去。把 `clearProfile` 改成:
|
||||
|
||||
```ts
|
||||
function clearProfile() {
|
||||
profile.value = null
|
||||
demoMode.value = false
|
||||
storage.clear()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 导出新成员**
|
||||
|
||||
在 store 末尾的 `return { ... }` 里加入三项(放在 `hasProblemPermission` 之后):
|
||||
|
||||
```ts
|
||||
demoMode,
|
||||
canToggleDemoMode,
|
||||
toggleDemoMode,
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 格式化并冒烟构建**
|
||||
|
||||
```bash
|
||||
cd ojnext
|
||||
npm fmt
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: 构建成功,无报错。
|
||||
|
||||
- [ ] **Step 8: 手工验证伪装生效(此时还没有 UI 入口,用 localStorage 模拟)**
|
||||
|
||||
启动 `npm start`,用超管账号登录,然后在浏览器 DevTools Console 执行:
|
||||
|
||||
```js
|
||||
localStorage.setItem("demoMode", "true")
|
||||
location.reload()
|
||||
```
|
||||
|
||||
逐项核对:
|
||||
- 顶栏「后台」菜单项消失
|
||||
- 地址栏直接输入 `/admin` → 被弹回首页
|
||||
- 题目详情页不再出现管理员专属按钮
|
||||
|
||||
再执行 `localStorage.setItem("demoMode", "false"); location.reload()`,确认上述内容全部恢复。
|
||||
|
||||
- [ ] **Step 9: 提交**
|
||||
|
||||
```bash
|
||||
cd ojnext
|
||||
git add src/utils/constants.ts src/shared/store/user.ts
|
||||
git commit -m "feat(user): store 层加入演示模式伪装开关
|
||||
|
||||
超管开启后所有角色 getter 降级为普通学生,全站权限判断自动跟随。
|
||||
realIsSuperAdmin 保留真实身份,用于判断能否切换。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 下拉菜单切换入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/shared/components/Header.vue:109-113`(新增函数)、`:178-227`(`options` 改为 computed 并增加菜单项)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 提供的 `userStore.demoMode`、`userStore.canToggleDemoMode`、`userStore.toggleDemoMode()`
|
||||
- Produces: 无后续任务依赖
|
||||
|
||||
- [ ] **Step 1: 把 `options` 从普通数组改为 computed**
|
||||
|
||||
`src/shared/components/Header.vue` 第 178 行现在是:
|
||||
|
||||
```ts
|
||||
const options: Array<DropdownOption | DropdownDividerOption> = [
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```ts
|
||||
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
|
||||
```
|
||||
|
||||
并把第 227 行的结尾 `]` 改为 `])`。
|
||||
|
||||
**这一步是必需的,不是风格偏好**:原来的 `options` 是普通数组,只在 setup 时求值一次。新菜单项的 `label`(「进入演示」/「退出演示」)和 `show` 都要跟随状态变化,留在普通数组里永远不会更新。同文件的 `menus`(第 119 行)本来就是 computed,改完两者一致。
|
||||
|
||||
模板里 `:options="options"`(第 280 行)不用动,computed 在模板中自动解包。
|
||||
|
||||
- [ ] **Step 2: 加入切换处理函数**
|
||||
|
||||
在 `handleLogout`(第 109-113 行)之后插入:
|
||||
|
||||
```ts
|
||||
function handleToggleDemoMode() {
|
||||
const entering = !userStore.demoMode
|
||||
userStore.toggleDemoMode()
|
||||
// 进入演示模式时若正停在后台页面,当前界面已经失去权限,必须主动退出去
|
||||
if (entering && route.path.startsWith("/admin")) {
|
||||
router.push("/")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`route` 和 `router` 在第 16-17 行已经拿到,`userStore` 在第 12 行已经拿到,无需新增。
|
||||
|
||||
- [ ] **Step 3: 在下拉菜单中加入菜单项**
|
||||
|
||||
在 `options` 数组里、`{ type: "divider" }`(第 220 行)**之前**插入:
|
||||
|
||||
```ts
|
||||
{
|
||||
label: userStore.demoMode ? "退出演示" : "进入演示",
|
||||
key: "demo-mode",
|
||||
show: userStore.canToggleDemoMode,
|
||||
icon: renderIcon("fluent-emoji:graduation-cap"),
|
||||
props: { onClick: handleToggleDemoMode },
|
||||
},
|
||||
```
|
||||
|
||||
文案本身就是状态指示器:看到「退出演示」说明当前正处于演示模式。按设计不额外加横幅。
|
||||
|
||||
- [ ] **Step 4: 格式化并冒烟构建**
|
||||
|
||||
```bash
|
||||
cd ojnext
|
||||
npm fmt
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: 构建成功,无报错。
|
||||
|
||||
- [ ] **Step 5: 手工验证完整流程**
|
||||
|
||||
先清掉 Task 1 遗留的手工标记:DevTools Console 执行 `localStorage.removeItem("demoMode")`,刷新。
|
||||
|
||||
用超管账号登录,逐项核对:
|
||||
|
||||
1. 右上角用户名下拉菜单出现「进入演示」,图标正常显示(不是空白方块)
|
||||
2. 点击 →「后台」菜单项消失,下拉菜单文案变为「退出演示」
|
||||
3. 刷新页面 → 仍是学生界面,菜单仍显示「退出演示」
|
||||
4. 地址栏直接输入 `/admin` → 弹回首页
|
||||
5. 点「退出演示」→「后台」入口恢复,文案变回「进入演示」
|
||||
6. 进入 `/admin/problem/list`,打开下拉菜单点「进入演示」→ 自动跳回首页
|
||||
7. 退出登录后重新用超管登录 → 演示模式已重置为关闭状态
|
||||
8. 用教师管理员账号登录 → 下拉菜单中**没有**这一项
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
cd ojnext
|
||||
git add src/shared/components/Header.vue
|
||||
git commit -m "feat(header): 用户下拉菜单加入学生视角开关
|
||||
|
||||
仅超管可见。进入演示模式时若停在后台页面则跳回首页。
|
||||
options 改为 computed,否则菜单文案与显示条件不会随状态更新。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成后
|
||||
|
||||
两个任务都提交后,整个特性即完成。回读一遍 spec 的「连带影响」章节,确认这些变化在实机上都是预期的:
|
||||
|
||||
- 提交列表的教师筛选与额外列消失,`showSubmissions` 改为跟随站点配置
|
||||
- 比赛失去超管免密码特权
|
||||
- 协同代码编辑(`shared/composables/sync.ts`)的超管特殊颜色与提示一并变为学生行为 —— **已确认不做豁免**
|
||||
@@ -0,0 +1,34 @@
|
||||
# Submit Formatting Button State
|
||||
|
||||
## Goal
|
||||
|
||||
Make the code submission button reflect the automatic formatting request that runs before submission.
|
||||
|
||||
## Behavior
|
||||
|
||||
- For Python3, C, and C++, the button displays `格式化中` while the formatting API request is pending.
|
||||
- During formatting, the button uses the existing loading icon and is disabled to prevent duplicate submissions.
|
||||
- After formatting succeeds, the existing submission flow continues and the button can display `正在提交`.
|
||||
- A formatting error stops submission and clears the formatting state before showing the existing warning.
|
||||
- A formatter server or network failure keeps the existing fallback behavior: clear the formatting state and submit the original code.
|
||||
- Languages without automatic formatting skip this state and submit directly.
|
||||
- Existing button labels and judging/cooldown behavior remain unchanged.
|
||||
|
||||
## Implementation
|
||||
|
||||
Add a component-local `isFormatting` ref in `SubmitCode.vue`.
|
||||
|
||||
- Include it in `submitDisabled`.
|
||||
- Give it priority in `submitLabel`, using `格式化中`.
|
||||
- Include it in the loading-icon condition.
|
||||
- Set it immediately before `formatCode`.
|
||||
- Clear it in a `finally` block so every formatter outcome restores the button state.
|
||||
|
||||
The state remains local because it is transient UI state owned only by the submission button.
|
||||
|
||||
## Verification
|
||||
|
||||
The frontend currently has no automated test suite. Verify with:
|
||||
|
||||
- TypeScript production build.
|
||||
- Manual inspection of the state transitions for successful formatting, formatting errors, formatter infrastructure failures, and languages that do not format.
|
||||
@@ -0,0 +1,28 @@
|
||||
# SQL 题强制至少 2 个测试点 — 设计
|
||||
|
||||
日期:2026-07-05
|
||||
|
||||
## 背景
|
||||
|
||||
题目页的 `sql_display` 用**测试点 1** 的数据生成期望结果展示(截断到 20 行)。如果 SQL 题只有 1 个测试点且结果 ≤20 行,页面展示的期望结果就是完整答案输出,学生可用 `SELECT ... UNION ALL ...`(query 模式)或硬编码 INSERT/UPDATE(modify 模式)对照抄写直接 AC。多测试点时数据不同,硬编码只能过测试点 1。
|
||||
|
||||
## 决定
|
||||
|
||||
SQL 题**强制至少 2 个数据不同的初始化脚本**,前后端双重拦截:
|
||||
|
||||
- **前端** `ojnext/src/admin/problem/components/SQLTestcaseEditor.vue`:
|
||||
- `canUpload` 要求非空脚本数 ≥ 2,不满足时上传按钮禁用;
|
||||
- 上传按钮 tooltip 在脚本不足时显示原因("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")。
|
||||
- **后端** `OnlineJudge/problem/views/admin.py` 的 `TestCaseZipProcessor.process_zip`:
|
||||
- `sql=True` 且测试点数 < 2 时 `raise APIError(...)`,兜底直接调 API 的情况。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 只在**重新上传/保存测试点**时拦截,已有的单测试点老题目不受影响、不回溯校验。
|
||||
- 非 SQL 题(.in/.out 沙箱判题)不受影响。
|
||||
- "数据不同"不做内容级校验(两个脚本内容相同也能过),只保证数量下限,YAGNI。
|
||||
|
||||
## 验证
|
||||
|
||||
前端 `vue-tsc --noEmit`、Prettier 通过;后端 `ruff check` / `ruff format --check` 通过。
|
||||
人工验证:出题页只填 1 个脚本 → 上传按钮禁用且 tooltip 说明原因;填 2 个并预览通过 → 可上传。
|
||||
@@ -0,0 +1,50 @@
|
||||
# SQL 题目表名/字段名自动补全 — 设计
|
||||
|
||||
日期:2026-07-05
|
||||
|
||||
## 目标
|
||||
|
||||
学生在 SQL 题目的代码编辑器里输入时,自动补全列表中除现有的 SQL 关键字/函数外,还出现**当前题目的表名和字段名**(带类型提示),减少抄写表名字段名的负担和拼写错误。
|
||||
|
||||
## 背景
|
||||
|
||||
- SQL 题目详情页已下发 `problem.sql_display`(`SQLDisplay` 类型),其中 `tables: SQLDisplayTable[]` 包含每张表的 `name`、`columns[{name, type}]`。数据在前端齐全,**无需后端改动**。
|
||||
- 编辑器补全入口是 `shared/extensions/autocompletion.ts` 的 `enhanceCompletion(language)`,`CodeEditor.vue` 和 `SyncCodeEditor.vue` 都用它,且都叠加了 `completeAnyWord`。
|
||||
- SQL 静态关键字补全表在 `shared/extensions/sql.ts`。
|
||||
- `shared` 直接 import `oj/store/problem` 已有先例(`FlowchartEditor/index.vue`)。
|
||||
|
||||
## 方案(已选:方案 A)
|
||||
|
||||
在 `enhanceCompletion` 中,当 `language === "SQL"` 时,从 `useProblemStore().problem?.sql_display?.tables` 动态生成补全项,追加到静态关键字列表后:
|
||||
|
||||
- **表名**:`type: "class"`,`detail: "数据表"`,`info` 列出该表全部字段(如 `字段:id INTEGER, name TEXT, score REAL`),`boost` 高于所有关键字(如 110)。
|
||||
- **字段名**:`type: "property"`,`detail` 标注来源表和类型(如 `students 的字段 · TEXT`),`boost` 略低于表名、高于关键字(如 105)。
|
||||
- **同名字段每表一条**,靠 detail 区分来源表。
|
||||
- store 在补全回调内惰性读取(每次按键执行),题目切换后自动反映最新表结构。
|
||||
- 非 SQL 语言、无题目上下文(如 admin/tutorial/learn 页面)或 `sql_display` 为空时,不追加任何项,行为与现状一致。
|
||||
|
||||
### 不做的事(YAGNI)
|
||||
|
||||
- 不改后端;不改题目描述展示(`SQLDataTable` 已展示表结构)。
|
||||
- 管理端出题的 SQL 编辑器(`SQLTestcaseEditor`)不接入。
|
||||
- 不做基于 SQL 语法位置的智能上下文补全(如 FROM 后只补表名)。
|
||||
|
||||
## 改动文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/shared/extensions/autocompletion.ts` | SQL 分支追加由 `sql_display.tables` 生成的动态补全项 |
|
||||
|
||||
(如生成逻辑较长,可拆一个小函数放同文件或 `sql.ts`,保持单一职责。)
|
||||
|
||||
## 错误处理
|
||||
|
||||
- `problem`、`sql_display`、`tables` 任一为空 → 返回纯静态列表(可选链兜底)。
|
||||
- Pinia store 在组件上下文外调用的风险:补全回调在编辑器运行期触发,此时 Pinia 已安装;与 FlowchartEditor 的既有用法一致。
|
||||
|
||||
## 测试
|
||||
|
||||
项目无测试套件(政策:不写新测试)。人工验证:
|
||||
1. 打开一道 SQL 题,编辑器中输入表名/字段名前缀,确认补全项出现且 detail/info 正确。
|
||||
2. 打开非 SQL 题,确认补全行为无变化。
|
||||
3. 协作编辑(SyncCodeEditor)场景同样生效。
|
||||
@@ -0,0 +1,102 @@
|
||||
# 学生视角(演示模式)设计
|
||||
|
||||
日期:2026-07-26
|
||||
范围:仅前端(ojnext)
|
||||
|
||||
## 背景
|
||||
|
||||
超级管理员给学生上课演示时,界面上到处是管理员才可见的入口和按钮(后台菜单、题目编辑、提交列表的额外操作列等)。这些东西对学生是噪音,也容易误点。需要一个一键开关,把界面临时切换成普通学生看到的样子。
|
||||
|
||||
## 目标
|
||||
|
||||
- 超管点一下,全站界面变成普通学生的样子
|
||||
- 再点一下恢复
|
||||
- 刷新页面不丢状态
|
||||
- 改动集中,不散落到几十个页面
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改后端。演示模式是纯界面伪装,登录态仍然是超管,接口权限不变。目的是演示,不是权限隔离。
|
||||
- 不做审计日志、不做时长限制。
|
||||
- 教师管理员、学生管理员不提供此功能。
|
||||
|
||||
## 机制
|
||||
|
||||
全站所有权限判断都读 `shared/store/user.ts` 里的几个 getter,没有任何一处直接读 `user.admin_type`。因此在 store 层加一个开关,就能一次性覆盖全部调用点。
|
||||
|
||||
```ts
|
||||
// shared/store/user.ts
|
||||
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
||||
|
||||
// 不受伪装影响的真实身份,只用于决定是否显示切换入口
|
||||
const realIsSuperAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
|
||||
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
|
||||
const isAdminRole = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
const isStudentAdmin = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
const isTeacherAdmin = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
const isTeacherOrAbove = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
const hasProblemPermission = computed(() => !demoMode.value && (/* 原逻辑 */))
|
||||
|
||||
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
||||
|
||||
function toggleDemoMode() {
|
||||
demoMode.value = !demoMode.value
|
||||
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
|
||||
}
|
||||
```
|
||||
|
||||
`realIsSuperAdmin` 是关键:如果切换入口的显示条件用被伪装后的 `isSuperAdmin`,一进入演示模式入口自己就消失了,退不出来。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/utils/constants.ts` | `STORAGE_KEY` 增加 `DEMO_MODE: "demoMode"` |
|
||||
| `src/shared/store/user.ts` | 新增 `demoMode`、`realIsSuperAdmin`、`canToggleDemoMode`、`toggleDemoMode`;6 个角色 getter 加 `!demoMode.value &&` 前缀;导出新成员 |
|
||||
| `src/shared/components/Header.vue` | 用户下拉菜单 `options` 增加一项「进入演示 / 退出演示」 |
|
||||
|
||||
**不改**:`src/main.ts` 路由守卫、`src/utils/permissions.ts`、以及所有页面级的 `v-if` 判断。它们读的都是上述 getter,自动跟随。
|
||||
|
||||
## 交互
|
||||
|
||||
**入口**:右上角用户头像下拉菜单,与「我的主页」「我的提交」并列。
|
||||
|
||||
- 显示条件:`userStore.canToggleDemoMode`
|
||||
- 文案随状态翻转:未开启显示「进入演示」,已开启显示「退出演示」
|
||||
- 该文案本身就是状态指示器,不额外加横幅或角标
|
||||
|
||||
**点击行为**:
|
||||
|
||||
1. 调用 `toggleDemoMode()`
|
||||
2. 如果是**进入**演示模式,且当前路由属于 `admins` 分支,执行 `router.push("/")`。否则页面会停在一个已失去权限的后台界面上。
|
||||
3. 退出演示模式不需要跳转,留在当前页即可。
|
||||
|
||||
## 连带影响(均为预期行为)
|
||||
|
||||
- **后台入口消失**(`Header.vue:165-175`)。手动输入 `/admin/*` 地址也会被 `main.ts` 守卫弹回首页,因为守卫读的是被伪装后的 getter。
|
||||
- **提交列表**(`submission/list.vue`)的教师专属筛选、额外列隐藏;`showSubmissions` 改为跟随站点配置 `submission_list_show_all`,而不是无条件为 true。
|
||||
- **题目编辑表单**(`problem/components/Form.vue`)的管理员字段隐藏。
|
||||
- **比赛访问**(`oj/store/contest.ts:49`)失去超管免密码特权,需按学生流程输密码。演示时更真实。
|
||||
- **协同代码编辑**(`shared/composables/sync.ts`)的超管特殊颜色与提示一并变为学生行为。**确认不做豁免**——演示场景不涉及协同编辑功能。
|
||||
|
||||
## 持久化与清理
|
||||
|
||||
- 存 `localStorage`,key 为 `demoMode`
|
||||
- store 初始化时从 storage 读取,刷新后保持
|
||||
- 退出登录时 `clearProfile()` 调用 `storage.clear()`,会一并清除,不会残留到下一个登录用户
|
||||
|
||||
## 验证方式
|
||||
|
||||
手工验证(本项目不写测试):
|
||||
|
||||
1. 超管登录 → 下拉菜单出现「进入演示」
|
||||
2. 点击 → 顶栏「后台」消失,菜单文案变为「退出演示」
|
||||
3. 停在 `/admin/problem/list` 时点击 → 跳回首页
|
||||
4. 地址栏直接输 `/admin` → 弹回首页
|
||||
5. 刷新页面 → 仍是学生界面
|
||||
6. 点「退出演示」→ 后台入口恢复
|
||||
7. 退出登录再登录 → 演示模式已重置为关闭
|
||||
8. 用教师管理员账号登录 → 菜单中无此项
|
||||
Reference in New Issue
Block a user