From 07bbbce0134e8433b393be79d58993b1dfb3f715 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 26 Jul 2026 07:47:27 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat(user):=20store=20=E5=B1=82=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E6=BC=94=E7=A4=BA=E6=A8=A1=E5=BC=8F=E4=BC=AA=E8=A3=85?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 超管开启后所有角色 getter 降级为普通学生,全站权限判断自动跟随。 realIsSuperAdmin 保留真实身份,用于判断能否切换。 Co-Authored-By: Claude Opus 5 --- src/shared/store/user.ts | 45 ++++++++++++++++++++++++++++++---------- src/utils/constants.ts | 1 + 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/shared/store/user.ts b/src/shared/store/user.ts index 574d0fe..f19c39d 100644 --- a/src/shared/store/user.ts +++ b/src/shared/store/user.ts @@ -11,30 +11,49 @@ export const useUserStore = defineStore("user", () => { const [isFinished] = useToggle(false) const user = computed(() => profile.value?.user ?? null) const isAuthed = computed(() => !!user.value?.email) + + // 演示模式:超管临时把界面伪装成普通学生,方便上课投屏 + const demoMode = ref(storage.get(STORAGE_KEY.DEMO_MODE) ?? false) + + // 不受伪装影响的真实身份,只用于判断能否切换演示模式。 + // 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。 + const realIsSuperAdmin = computed( + () => user.value?.admin_type === USER_TYPE.SUPER_ADMIN, + ) + const isAdminRole = computed( () => - user.value?.admin_type === USER_TYPE.STUDENT_ADMIN || - user.value?.admin_type === USER_TYPE.TEACHER_ADMIN || - user.value?.admin_type === USER_TYPE.SUPER_ADMIN, + !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( - () => user.value?.admin_type === USER_TYPE.STUDENT_ADMIN, + () => !demoMode.value && user.value?.admin_type === USER_TYPE.STUDENT_ADMIN, ) const isTeacherAdmin = computed( - () => user.value?.admin_type === USER_TYPE.TEACHER_ADMIN, + () => !demoMode.value && user.value?.admin_type === USER_TYPE.TEACHER_ADMIN, ) const isTeacherOrAbove = computed( () => - user.value?.admin_type === USER_TYPE.TEACHER_ADMIN || - user.value?.admin_type === USER_TYPE.SUPER_ADMIN, - ) - const isSuperAdmin = computed( - () => user.value?.admin_type === USER_TYPE.SUPER_ADMIN, + !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( - () => user.value?.problem_permission !== PROBLEM_PERMISSION.NONE, + () => + !demoMode.value && + user.value?.problem_permission !== PROBLEM_PERMISSION.NONE, ) + const canToggleDemoMode = computed(() => realIsSuperAdmin.value) + + function toggleDemoMode() { + demoMode.value = !demoMode.value + storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value) + } + const showSubmissions = computed(() => { let flag = configStore.config.submission_list_show_all if (isAdminRole.value) flag = true @@ -51,6 +70,7 @@ export const useUserStore = defineStore("user", () => { function clearProfile() { profile.value = null + demoMode.value = false storage.clear() } return { @@ -63,6 +83,9 @@ export const useUserStore = defineStore("user", () => { isTeacherOrAbove, isSuperAdmin, hasProblemPermission, + demoMode, + canToggleDemoMode, + toggleDemoMode, isAuthed, showSubmissions, getMyProfile, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index cfee973..bccd7c4 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -150,6 +150,7 @@ export const STORAGE_KEY = { LEARN_CURRENT_STEP: "learnStep", ADMIN_PROBLEM: "adminProblem", ADMIN_PROBLEM_TAGS: "adminProblemTags", + DEMO_MODE: "demoMode", } export const DIFFICULTY = { From 8a343edbbb995af6d37c202e19f431c131f69b3a Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 26 Jul 2026 07:48:22 -0600 Subject: [PATCH 2/4] =?UTF-8?q?feat(header):=20=E7=94=A8=E6=88=B7=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E8=8F=9C=E5=8D=95=E5=8A=A0=E5=85=A5=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E8=A7=86=E8=A7=92=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅超管可见。进入演示模式时若停在后台页面则跳回首页。 options 改为 computed,否则菜单文案与显示条件不会随状态更新。 Co-Authored-By: Claude Opus 5 --- src/shared/components/Header.vue | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/shared/components/Header.vue b/src/shared/components/Header.vue index d615d4b..1700a9d 100644 --- a/src/shared/components/Header.vue +++ b/src/shared/components/Header.vue @@ -112,6 +112,15 @@ async function handleLogout() { router.replace("/") } +function handleToggleDemoMode() { + const entering = !userStore.demoMode + userStore.toggleDemoMode() + // 进入演示模式时若正停在后台页面,当前界面已经失去权限,必须主动退出去 + if (entering && route.path.startsWith("/admin")) { + router.push("/") + } +} + function renderIcon(icon: string) { return () => h(Icon, { icon, width: 20 }) } @@ -175,7 +184,7 @@ const menus = computed(() => [ }, ]) -const options: Array = [ +const options = computed>(() => [ { label: "我的主页", key: "home", @@ -217,6 +226,13 @@ const options: Array = [ onClick: () => router.push("/ai-analysis"), }, }, + { + label: userStore.demoMode ? "退出学生视角" : "学生视角", + key: "demo-mode", + show: userStore.canToggleDemoMode, + icon: renderIcon("fluent-emoji:graduation-cap"), + props: { onClick: handleToggleDemoMode }, + }, { type: "divider" }, { label: "退出", @@ -224,7 +240,7 @@ const options: Array = [ icon: renderIcon("streamline-ultimate-color:coffee-cold"), props: { onClick: handleLogout }, }, -] +]) function goHome() { router.push("/") From bd6c5d19f8a04c689a30107436f39e0a8f76e524 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 26 Jul 2026 07:49:22 -0600 Subject: [PATCH 3/4] =?UTF-8?q?style(header):=20=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E9=80=80=E5=87=BA=E6=96=87=E6=A1=88=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=9B=9B=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「退出学生视角」六字,与「学生视角」不齐。 Co-Authored-By: Claude Opus 5 --- src/shared/components/Header.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/components/Header.vue b/src/shared/components/Header.vue index 1700a9d..ed592fc 100644 --- a/src/shared/components/Header.vue +++ b/src/shared/components/Header.vue @@ -227,7 +227,7 @@ const options = computed>(() => [ }, }, { - label: userStore.demoMode ? "退出学生视角" : "学生视角", + label: userStore.demoMode ? "退出演示" : "学生视角", key: "demo-mode", show: userStore.canToggleDemoMode, icon: renderIcon("fluent-emoji:graduation-cap"), From 6766c4dcd5745ef54995e4d587b8e93cea10fd05 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 26 Jul 2026 07:51:11 -0600 Subject: [PATCH 4/4] =?UTF-8?q?style(header):=20=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=8F=9C=E5=8D=95=E6=96=87=E6=A1=88=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E8=BF=9B=E5=85=A5=E6=BC=94=E7=A4=BA=20/=20?= =?UTF-8?q?=E9=80=80=E5=87=BA=E6=BC=94=E7=A4=BA=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两端都是四字,动作对称。spec 与 plan 文档同步更新。 Co-Authored-By: Claude Opus 5 --- .../plans/2026-07-26-demo-student-view.md | 16 ++++++++-------- .../specs/2026-07-26-demo-student-view-design.md | 10 +++++----- src/shared/components/Beian.vue | 2 -- src/shared/components/Header.vue | 2 +- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-07-26-demo-student-view.md b/docs/superpowers/plans/2026-07-26-demo-student-view.md index 9305fe2..d035447 100644 --- a/docs/superpowers/plans/2026-07-26-demo-student-view.md +++ b/docs/superpowers/plans/2026-07-26-demo-student-view.md @@ -212,7 +212,7 @@ const options = computed>(() => [ 并把第 227 行的结尾 `]` 改为 `])`。 -**这一步是必需的,不是风格偏好**:原来的 `options` 是普通数组,只在 setup 时求值一次。新菜单项的 `label`(「学生视角」/「退出学生视角」)和 `show` 都要跟随状态变化,留在普通数组里永远不会更新。同文件的 `menus`(第 119 行)本来就是 computed,改完两者一致。 +**这一步是必需的,不是风格偏好**:原来的 `options` 是普通数组,只在 setup 时求值一次。新菜单项的 `label`(「进入演示」/「退出演示」)和 `show` 都要跟随状态变化,留在普通数组里永远不会更新。同文件的 `menus`(第 119 行)本来就是 computed,改完两者一致。 模板里 `:options="options"`(第 280 行)不用动,computed 在模板中自动解包。 @@ -239,7 +239,7 @@ function handleToggleDemoMode() { ```ts { - label: userStore.demoMode ? "退出学生视角" : "学生视角", + label: userStore.demoMode ? "退出演示" : "进入演示", key: "demo-mode", show: userStore.canToggleDemoMode, icon: renderIcon("fluent-emoji:graduation-cap"), @@ -247,7 +247,7 @@ function handleToggleDemoMode() { }, ``` -文案本身就是状态指示器:看到「退出学生视角」说明当前正处于演示模式。按设计不额外加横幅。 +文案本身就是状态指示器:看到「退出演示」说明当前正处于演示模式。按设计不额外加横幅。 - [ ] **Step 4: 格式化并冒烟构建** @@ -265,12 +265,12 @@ Expected: 构建成功,无报错。 用超管账号登录,逐项核对: -1. 右上角用户名下拉菜单出现「学生视角」,图标正常显示(不是空白方块) -2. 点击 →「后台」菜单项消失,下拉菜单文案变为「退出学生视角」 -3. 刷新页面 → 仍是学生界面,菜单仍显示「退出学生视角」 +1. 右上角用户名下拉菜单出现「进入演示」,图标正常显示(不是空白方块) +2. 点击 →「后台」菜单项消失,下拉菜单文案变为「退出演示」 +3. 刷新页面 → 仍是学生界面,菜单仍显示「退出演示」 4. 地址栏直接输入 `/admin` → 弹回首页 -5. 点「退出学生视角」→「后台」入口恢复,文案变回「学生视角」 -6. 进入 `/admin/problem/list`,打开下拉菜单点「学生视角」→ 自动跳回首页 +5. 点「退出演示」→「后台」入口恢复,文案变回「进入演示」 +6. 进入 `/admin/problem/list`,打开下拉菜单点「进入演示」→ 自动跳回首页 7. 退出登录后重新用超管登录 → 演示模式已重置为关闭状态 8. 用教师管理员账号登录 → 下拉菜单中**没有**这一项 diff --git a/docs/superpowers/specs/2026-07-26-demo-student-view-design.md b/docs/superpowers/specs/2026-07-26-demo-student-view-design.md index 7716215..3367ab6 100644 --- a/docs/superpowers/specs/2026-07-26-demo-student-view-design.md +++ b/docs/superpowers/specs/2026-07-26-demo-student-view-design.md @@ -56,7 +56,7 @@ function toggleDemoMode() { |---|---| | `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/shared/components/Header.vue` | 用户下拉菜单 `options` 增加一项「进入演示 / 退出演示」 | **不改**:`src/main.ts` 路由守卫、`src/utils/permissions.ts`、以及所有页面级的 `v-if` 判断。它们读的都是上述 getter,自动跟随。 @@ -65,7 +65,7 @@ function toggleDemoMode() { **入口**:右上角用户头像下拉菜单,与「我的主页」「我的提交」并列。 - 显示条件:`userStore.canToggleDemoMode` -- 文案随状态翻转:未开启显示「学生视角」,已开启显示「退出学生视角」 +- 文案随状态翻转:未开启显示「进入演示」,已开启显示「退出演示」 - 该文案本身就是状态指示器,不额外加横幅或角标 **点击行为**: @@ -92,11 +92,11 @@ function toggleDemoMode() { 手工验证(本项目不写测试): -1. 超管登录 → 下拉菜单出现「学生视角」 -2. 点击 → 顶栏「后台」消失,菜单文案变为「退出学生视角」 +1. 超管登录 → 下拉菜单出现「进入演示」 +2. 点击 → 顶栏「后台」消失,菜单文案变为「退出演示」 3. 停在 `/admin/problem/list` 时点击 → 跳回首页 4. 地址栏直接输 `/admin` → 弹回首页 5. 刷新页面 → 仍是学生界面 -6. 点「退出学生视角」→ 后台入口恢复 +6. 点「退出演示」→ 后台入口恢复 7. 退出登录再登录 → 演示模式已重置为关闭 8. 用教师管理员账号登录 → 菜单中无此项 diff --git a/src/shared/components/Beian.vue b/src/shared/components/Beian.vue index 1682311..3b1aa0a 100644 --- a/src/shared/components/Beian.vue +++ b/src/shared/components/Beian.vue @@ -29,7 +29,6 @@ const hiddenICP = computed(() => ["problem", "contest problem"].includes(route.name as string), ) - const currentYear = new Date().getFullYear() const copyrightText = `© 2022 - ${currentYear} 判题狗 保留所有权利` @@ -56,7 +55,6 @@ function goPublicSecurity() { margin: 12px 0; } - .beian--mobile { font-size: 12px; max-width: 100%; diff --git a/src/shared/components/Header.vue b/src/shared/components/Header.vue index ed592fc..44ed653 100644 --- a/src/shared/components/Header.vue +++ b/src/shared/components/Header.vue @@ -227,7 +227,7 @@ const options = computed>(() => [ }, }, { - label: userStore.demoMode ? "退出演示" : "学生视角", + label: userStore.demoMode ? "退出演示" : "进入演示", key: "demo-mode", show: userStore.canToggleDemoMode, icon: renderIcon("fluent-emoji:graduation-cap"),