Merge branch 'feat/demo-student-view'
学生视角(演示模式):超管在用户下拉菜单一键把界面伪装成普通学生。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -212,7 +212,7 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
|
|||||||
|
|
||||||
并把第 227 行的结尾 `]` 改为 `])`。
|
并把第 227 行的结尾 `]` 改为 `])`。
|
||||||
|
|
||||||
**这一步是必需的,不是风格偏好**:原来的 `options` 是普通数组,只在 setup 时求值一次。新菜单项的 `label`(「学生视角」/「退出学生视角」)和 `show` 都要跟随状态变化,留在普通数组里永远不会更新。同文件的 `menus`(第 119 行)本来就是 computed,改完两者一致。
|
**这一步是必需的,不是风格偏好**:原来的 `options` 是普通数组,只在 setup 时求值一次。新菜单项的 `label`(「进入演示」/「退出演示」)和 `show` 都要跟随状态变化,留在普通数组里永远不会更新。同文件的 `menus`(第 119 行)本来就是 computed,改完两者一致。
|
||||||
|
|
||||||
模板里 `:options="options"`(第 280 行)不用动,computed 在模板中自动解包。
|
模板里 `:options="options"`(第 280 行)不用动,computed 在模板中自动解包。
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ function handleToggleDemoMode() {
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
label: userStore.demoMode ? "退出学生视角" : "学生视角",
|
label: userStore.demoMode ? "退出演示" : "进入演示",
|
||||||
key: "demo-mode",
|
key: "demo-mode",
|
||||||
show: userStore.canToggleDemoMode,
|
show: userStore.canToggleDemoMode,
|
||||||
icon: renderIcon("fluent-emoji:graduation-cap"),
|
icon: renderIcon("fluent-emoji:graduation-cap"),
|
||||||
@@ -247,7 +247,7 @@ function handleToggleDemoMode() {
|
|||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
文案本身就是状态指示器:看到「退出学生视角」说明当前正处于演示模式。按设计不额外加横幅。
|
文案本身就是状态指示器:看到「退出演示」说明当前正处于演示模式。按设计不额外加横幅。
|
||||||
|
|
||||||
- [ ] **Step 4: 格式化并冒烟构建**
|
- [ ] **Step 4: 格式化并冒烟构建**
|
||||||
|
|
||||||
@@ -265,12 +265,12 @@ Expected: 构建成功,无报错。
|
|||||||
|
|
||||||
用超管账号登录,逐项核对:
|
用超管账号登录,逐项核对:
|
||||||
|
|
||||||
1. 右上角用户名下拉菜单出现「学生视角」,图标正常显示(不是空白方块)
|
1. 右上角用户名下拉菜单出现「进入演示」,图标正常显示(不是空白方块)
|
||||||
2. 点击 →「后台」菜单项消失,下拉菜单文案变为「退出学生视角」
|
2. 点击 →「后台」菜单项消失,下拉菜单文案变为「退出演示」
|
||||||
3. 刷新页面 → 仍是学生界面,菜单仍显示「退出学生视角」
|
3. 刷新页面 → 仍是学生界面,菜单仍显示「退出演示」
|
||||||
4. 地址栏直接输入 `/admin` → 弹回首页
|
4. 地址栏直接输入 `/admin` → 弹回首页
|
||||||
5. 点「退出学生视角」→「后台」入口恢复,文案变回「学生视角」
|
5. 点「退出演示」→「后台」入口恢复,文案变回「进入演示」
|
||||||
6. 进入 `/admin/problem/list`,打开下拉菜单点「学生视角」→ 自动跳回首页
|
6. 进入 `/admin/problem/list`,打开下拉菜单点「进入演示」→ 自动跳回首页
|
||||||
7. 退出登录后重新用超管登录 → 演示模式已重置为关闭状态
|
7. 退出登录后重新用超管登录 → 演示模式已重置为关闭状态
|
||||||
8. 用教师管理员账号登录 → 下拉菜单中**没有**这一项
|
8. 用教师管理员账号登录 → 下拉菜单中**没有**这一项
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function toggleDemoMode() {
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `src/utils/constants.ts` | `STORAGE_KEY` 增加 `DEMO_MODE: "demoMode"` |
|
| `src/utils/constants.ts` | `STORAGE_KEY` 增加 `DEMO_MODE: "demoMode"` |
|
||||||
| `src/shared/store/user.ts` | 新增 `demoMode`、`realIsSuperAdmin`、`canToggleDemoMode`、`toggleDemoMode`;6 个角色 getter 加 `!demoMode.value &&` 前缀;导出新成员 |
|
| `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,自动跟随。
|
**不改**:`src/main.ts` 路由守卫、`src/utils/permissions.ts`、以及所有页面级的 `v-if` 判断。它们读的都是上述 getter,自动跟随。
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ function toggleDemoMode() {
|
|||||||
**入口**:右上角用户头像下拉菜单,与「我的主页」「我的提交」并列。
|
**入口**:右上角用户头像下拉菜单,与「我的主页」「我的提交」并列。
|
||||||
|
|
||||||
- 显示条件:`userStore.canToggleDemoMode`
|
- 显示条件:`userStore.canToggleDemoMode`
|
||||||
- 文案随状态翻转:未开启显示「学生视角」,已开启显示「退出学生视角」
|
- 文案随状态翻转:未开启显示「进入演示」,已开启显示「退出演示」
|
||||||
- 该文案本身就是状态指示器,不额外加横幅或角标
|
- 该文案本身就是状态指示器,不额外加横幅或角标
|
||||||
|
|
||||||
**点击行为**:
|
**点击行为**:
|
||||||
@@ -92,11 +92,11 @@ function toggleDemoMode() {
|
|||||||
|
|
||||||
手工验证(本项目不写测试):
|
手工验证(本项目不写测试):
|
||||||
|
|
||||||
1. 超管登录 → 下拉菜单出现「学生视角」
|
1. 超管登录 → 下拉菜单出现「进入演示」
|
||||||
2. 点击 → 顶栏「后台」消失,菜单文案变为「退出学生视角」
|
2. 点击 → 顶栏「后台」消失,菜单文案变为「退出演示」
|
||||||
3. 停在 `/admin/problem/list` 时点击 → 跳回首页
|
3. 停在 `/admin/problem/list` 时点击 → 跳回首页
|
||||||
4. 地址栏直接输 `/admin` → 弹回首页
|
4. 地址栏直接输 `/admin` → 弹回首页
|
||||||
5. 刷新页面 → 仍是学生界面
|
5. 刷新页面 → 仍是学生界面
|
||||||
6. 点「退出学生视角」→ 后台入口恢复
|
6. 点「退出演示」→ 后台入口恢复
|
||||||
7. 退出登录再登录 → 演示模式已重置为关闭
|
7. 退出登录再登录 → 演示模式已重置为关闭
|
||||||
8. 用教师管理员账号登录 → 菜单中无此项
|
8. 用教师管理员账号登录 → 菜单中无此项
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const hiddenICP = computed(() =>
|
|||||||
["problem", "contest problem"].includes(route.name as string),
|
["problem", "contest problem"].includes(route.name as string),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const copyrightText = `© 2022 - ${currentYear} 判题狗 保留所有权利`
|
const copyrightText = `© 2022 - ${currentYear} 判题狗 保留所有权利`
|
||||||
|
|
||||||
@@ -56,7 +55,6 @@ function goPublicSecurity() {
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.beian--mobile {
|
.beian--mobile {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -112,6 +112,15 @@ async function handleLogout() {
|
|||||||
router.replace("/")
|
router.replace("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleDemoMode() {
|
||||||
|
const entering = !userStore.demoMode
|
||||||
|
userStore.toggleDemoMode()
|
||||||
|
// 进入演示模式时若正停在后台页面,当前界面已经失去权限,必须主动退出去
|
||||||
|
if (entering && route.path.startsWith("/admin")) {
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderIcon(icon: string) {
|
function renderIcon(icon: string) {
|
||||||
return () => h(Icon, { icon, width: 20 })
|
return () => h(Icon, { icon, width: 20 })
|
||||||
}
|
}
|
||||||
@@ -175,7 +184,7 @@ const menus = computed<MenuOption[]>(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const options: Array<DropdownOption | DropdownDividerOption> = [
|
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
|
||||||
{
|
{
|
||||||
label: "我的主页",
|
label: "我的主页",
|
||||||
key: "home",
|
key: "home",
|
||||||
@@ -217,6 +226,13 @@ const options: Array<DropdownOption | DropdownDividerOption> = [
|
|||||||
onClick: () => router.push("/ai-analysis"),
|
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" },
|
{ type: "divider" },
|
||||||
{
|
{
|
||||||
label: "退出",
|
label: "退出",
|
||||||
@@ -224,7 +240,7 @@ const options: Array<DropdownOption | DropdownDividerOption> = [
|
|||||||
icon: renderIcon("streamline-ultimate-color:coffee-cold"),
|
icon: renderIcon("streamline-ultimate-color:coffee-cold"),
|
||||||
props: { onClick: handleLogout },
|
props: { onClick: handleLogout },
|
||||||
},
|
},
|
||||||
]
|
])
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
router.push("/")
|
router.push("/")
|
||||||
|
|||||||
@@ -11,30 +11,49 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
const [isFinished] = useToggle(false)
|
const [isFinished] = useToggle(false)
|
||||||
const user = computed<User | null>(() => profile.value?.user ?? null)
|
const user = computed<User | null>(() => profile.value?.user ?? null)
|
||||||
const isAuthed = computed(() => !!user.value?.email)
|
const isAuthed = computed(() => !!user.value?.email)
|
||||||
|
|
||||||
|
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏
|
||||||
|
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
||||||
|
|
||||||
|
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
|
||||||
|
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
|
||||||
|
const realIsSuperAdmin = computed(
|
||||||
|
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||||
|
)
|
||||||
|
|
||||||
const isAdminRole = computed(
|
const isAdminRole = computed(
|
||||||
() =>
|
() =>
|
||||||
user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
!demoMode.value &&
|
||||||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
(user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||||
|
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
|
||||||
)
|
)
|
||||||
const isStudentAdmin = computed(
|
const isStudentAdmin = computed(
|
||||||
() => user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
|
() => !demoMode.value && user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
|
||||||
)
|
)
|
||||||
const isTeacherAdmin = computed(
|
const isTeacherAdmin = computed(
|
||||||
() => user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
|
() => !demoMode.value && user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
|
||||||
)
|
)
|
||||||
const isTeacherOrAbove = computed(
|
const isTeacherOrAbove = computed(
|
||||||
() =>
|
() =>
|
||||||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
!demoMode.value &&
|
||||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
(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,
|
|
||||||
)
|
)
|
||||||
|
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
|
||||||
const hasProblemPermission = computed(
|
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(() => {
|
const showSubmissions = computed(() => {
|
||||||
let flag = configStore.config.submission_list_show_all
|
let flag = configStore.config.submission_list_show_all
|
||||||
if (isAdminRole.value) flag = true
|
if (isAdminRole.value) flag = true
|
||||||
@@ -51,6 +70,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
|
|
||||||
function clearProfile() {
|
function clearProfile() {
|
||||||
profile.value = null
|
profile.value = null
|
||||||
|
demoMode.value = false
|
||||||
storage.clear()
|
storage.clear()
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -63,6 +83,9 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
isTeacherOrAbove,
|
isTeacherOrAbove,
|
||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
hasProblemPermission,
|
hasProblemPermission,
|
||||||
|
demoMode,
|
||||||
|
canToggleDemoMode,
|
||||||
|
toggleDemoMode,
|
||||||
isAuthed,
|
isAuthed,
|
||||||
showSubmissions,
|
showSubmissions,
|
||||||
getMyProfile,
|
getMyProfile,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export const STORAGE_KEY = {
|
|||||||
LEARN_CURRENT_STEP: "learnStep",
|
LEARN_CURRENT_STEP: "learnStep",
|
||||||
ADMIN_PROBLEM: "adminProblem",
|
ADMIN_PROBLEM: "adminProblem",
|
||||||
ADMIN_PROBLEM_TAGS: "adminProblemTags",
|
ADMIN_PROBLEM_TAGS: "adminProblemTags",
|
||||||
|
DEMO_MODE: "demoMode",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DIFFICULTY = {
|
export const DIFFICULTY = {
|
||||||
|
|||||||
Reference in New Issue
Block a user