refactor(web): Header 只管顶栏,全局的东西挂回 App

Header 一直兼着「全局挂载点」:课堂求助的提示、新求助 toast、求助列表和
教师端协作弹框都寄生在里面,只因为顶栏是全局的。它其实不是 ——
/admin/* 走的是 admin.vue,没有 Header,老师一进后台这四个消费者全部
卸载:求助照收,提示、角标、弹框一个都不出现,正好是 collab.ts 里写的
「老师可能正在后台改题时收到求助」那个场景。

这些东西跟着连接走,而连接在 App.vue 按登录态开关,所以搬进 CollabHost
挂在同一层。Header 因此不必再是单根组件,default.vue 那个靠 class 传
居中样式的写法也换成外层 div,连带删掉「必须挂在根 n-flex 内部」那段
补丁注释。

顺带:
- 圆环扩散的暗黑切换抽成 useDarkTransition
- 求助的角标/toast/列表不再限桌面端 —— 老师缩窗口也得知道有人在等;
  接单确实要在电脑上写代码,那道闸挪进 HelpRequestList
- 站名改 text 按钮,能 tab 到、回车能按
- logout 的两步收进 userStore.signOut()
- 清掉死代码:handleMenuSelect 只认一个不存在的 key、active 里
  ["user","setting"] 永远不生效的排除、两个 show:false 的菜单项

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
2026-08-31 07:36:14 -06:00
parent cbe6955c26
commit e62f41f6c7
8 changed files with 233 additions and 179 deletions

View File

@@ -7,6 +7,7 @@ import { useConfigUpdate } from "shared/composables/configUpdate"
import { useMaxKB } from "shared/composables/maxkb"
import { useUserStore } from "shared/store/user"
import { useCollabStore } from "shared/store/collab"
import CollabHost from "shared/components/CollabHost.vue"
const isDark = useDark()
const configStore = useConfigStore()
@@ -95,6 +96,9 @@ provide("hljs", hljsInstance)
<n-dialog-provider>
<n-message-provider>
<router-view></router-view>
<!-- 求助提示 / 列表 / 协作弹框和上面那条常驻连接同级
这样切到 /admin另一套布局没有顶栏也照常收得到 -->
<CollabHost />
</n-message-provider>
</n-dialog-provider>
</n-config-provider>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { CollabRequestItem } from "shared/composables/websocket"
import { useCollabStore } from "shared/store/collab"
import CollabModal from "./CollabModal.vue"
import HelpRequestList from "./HelpRequestList.vue"
/**
* 课堂求助的全局界面:一次性提示、新求助 toast、求助列表、教师端协作弹框。
*
* 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走,
* 而连接是全局常驻的App.vue 按登录态开关)—— 挂在顶栏里的时候,老师一进
* /admin 就换成了 admin.vue 布局,顶栏连同这几个消费者一起卸载:求助照收,
* 提示、角标、协作弹框全都不出现,正好错过 collab.ts 里写的那句「老师可能
* 正在后台改题时收到求助」。放在这里才真的全局。
*
* 位置要求n-message-provider 的后代useMessage 需要)。
*/
const collabStore = useCollabStore()
const message = useMessage()
/**
* 一次性提示统一在这里消费。
*
* 学生排着队切去看提交记录,老师这时候取消了他的求助,那条「老师已取消你的
* 求助」挂在题目页上就永远没人消费 —— 教师端的 error 提示(比如「请先退出
* 当前协作」)同理。
*/
watch(
() => collabStore.noticeSeq,
() => {
const text = collabStore.consumeNotice()
if (text) message.info(text)
},
)
/**
* 新求助进来只有角标默默 +1上课走动的时候根本注意不到补一条 toast。
*
* 只在数字**变大**时弹:老师自己接单、拒绝、别的老师接走都会让它变小,那些
* 不该打扰人。断线重连后服务端会重推一份全量列表,队里还有人的话这里会再弹
* 一次 —— 那正好是「你刚断过线,这些人还等着」,留着。
*/
watch(
() => collabStore.pendingCount,
(count, previous) => {
if (count <= previous) return
let latest: CollabRequestItem | null = null
for (const item of collabStore.requests) {
if (item.status !== "pending") continue
if (!latest || item.createdAt > latest.createdAt) latest = item
}
const text = latest
? `${latest.studentName} 求助:${latest.problemTitle}`
: "有新的求助"
// 内容传 render 函数naive 的 content 支持),这样整条 toast 可点:
// 点一下直接开求助列表,省得再去点名字、再点菜单。
const notice = message.info(
() =>
h(
"span",
{
style: { cursor: "pointer" },
onClick: () => {
collabStore.helpPanelOpen = true
notice.destroy()
},
},
`${text} · 点击处理`,
),
{ duration: 5000 },
)
},
)
</script>
<template>
<HelpRequestList
v-if="collabStore.isTeacher"
v-model:show="collabStore.helpPanelOpen"
/>
<CollabModal v-if="collabStore.isTeacher" />
</template>

View File

@@ -2,37 +2,17 @@
import { Icon } from "@iconify/vue"
import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useDarkTransition } from "shared/composables/darkTransition"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useAuthModalStore } from "shared/store/authModal"
import { useCollabStore } from "shared/store/collab"
import { useScreenModeStore } from "shared/store/screenMode"
import type { CollabRequestItem } from "shared/composables/websocket"
import { logout } from "../api"
import CollabModal from "./CollabModal.vue"
import HelpRequestList from "./HelpRequestList.vue"
import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user"
import { trickOrTreat } from "utils/functions"
const userStore = useUserStore()
const configStore = useConfigStore()
const collabStore = useCollabStore()
const message = useMessage()
/**
* 课堂求助的一次性提示,统一在这里弹。
*
* 原来挂在题目页的 Form.vue 上:学生排着队切去看提交记录,老师这时候取消了
* 他的求助,那条「老师已取消你的求助」就永远没人消费。顶栏是全局的,放这儿
* 才收得全 —— 教师端的 error 提示(比如「请先退出当前协作」)同理。
*/
watch(
() => collabStore.noticeSeq,
() => {
const text = collabStore.consumeNotice()
if (text) message.info(text)
},
)
const authStore = useAuthModalStore()
const screenModeStore = useScreenModeStore()
const route = useRoute()
@@ -40,106 +20,17 @@ const router = useRouter()
const { isMobile, isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const { isDark, toggleDark } = useDarkTransition()
/**
* 课堂求助的入口收进姓名下拉里,顶栏只留姓名按钮上的角标 —— 老师不用展开
* 菜单也能看见有没有人举手。桌面端限定,教师端接单后要在弹框里替学生写代码
* 求助的入口收进姓名下拉里,顶栏只留姓名按钮上的角标 —— 老师不用展开菜单
* 也能看见有没有人举手。窄屏同样给:接单后要在弹框里替学生写代码,那件事
* 确实只有桌面端好使,但「有没有人在等」是宽度多少都得知道的。
*/
const showHelpRequests = ref(false)
const hasHelpEntry = computed(
() => isDesktop.value && userStore.isTeacherOrAbove,
)
const pendingHelpCount = computed(() =>
hasHelpEntry.value ? collabStore.pendingCount : 0,
collabStore.isTeacher ? collabStore.pendingCount : 0,
)
/**
* 新求助进来只有角标默默 +1上课走动的时候根本注意不到补一条 toast。
*
* 只在数字**变大**时弹:老师自己接单、拒绝、别的老师接走都会让它变小,那些
* 不该打扰人。断线重连后服务端会重推一份全量列表,队里还有人的话这里会再弹
* 一次 —— 那正好是「你刚断过线,这些人还等着」,留着。
*/
watch(
() => pendingHelpCount.value,
(count, previous) => {
if (count <= previous) return
let latest: CollabRequestItem | null = null
for (const item of collabStore.requests) {
if (item.status !== "pending") continue
if (!latest || item.createdAt > latest.createdAt) latest = item
}
const text = latest
? `${latest.studentName} 求助:${latest.problemTitle}`
: "有新的求助"
// 内容传 render 函数naive 的 content 支持),这样整条 toast 可点:
// 点一下直接开求助列表,省得再去点名字、再点菜单。
const notice = message.info(
() =>
h(
"span",
{
style: { cursor: "pointer" },
onClick: () => {
showHelpRequests.value = true
notice.destroy()
},
},
`${text} · 点击处理`,
),
{ duration: 5000 },
)
},
)
const isDark = useDark()
/**
* 圆环从哪儿开始扩散。正常点击就用指针落点键盘触发Enter / 空格)时浏览器给的
* clientX/clientY 是 0照用会让圆环从屏幕左上角冒出来——那种情况退回按钮自己的中心。
* `event.detail` 是点击次数,键盘触发时为 0用它区分最省事。
*/
function revealOrigin(event: MouseEvent) {
if (event.detail > 0) return { x: event.clientX, y: event.clientY }
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
}
function toggleDark(event: MouseEvent) {
if (!document.startViewTransition) {
// 机房那批 Chrome 低于 94没有 View Transitions直接切、不做动画。
isDark.value = !isDark.value
return
}
const { x, y } = revealOrigin(event)
// 半径要取到**最远**那个角的距离。用 hypot(x, y) 只覆盖到左上角,
// 点在偏左上时右下角会有一块旧画面等圆环扩过去,看着像是没刷新。
const radius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
)
document
.startViewTransition(() => {
isDark.value = !isDark.value
})
.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
],
},
{
duration: 400,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
)
})
.catch(() => {})
}
// 从 store 中获取屏幕模式状态
const { screenMode } = storeToRefs(screenModeStore)
@@ -193,14 +84,12 @@ const titleTags = computed(() =>
[envVersion.value, userStore.demoMode ? "演示中" : ""].filter(Boolean),
)
const active = computed(() => {
const path = route.path.split("/")[1] || "problem"
return !["user", "setting"].includes(path) ? path : ""
})
// 一级路径就是菜单 key对不上的页面/user、/setting、/achievement 等)
// 自然没有一项亮着
const active = computed(() => route.path.split("/")[1] || "problem")
async function handleLogout() {
await logout()
userStore.clearProfile()
await userStore.signOut()
router.replace("/")
}
@@ -271,12 +160,6 @@ const menus = computed<MenuOption[]>(() => [
key: "rank",
icon: renderIcon("fluent-emoji:trophy"),
},
{
label: () => h(RouterLink, { to: "/class/pk" }, { default: () => "班级" }),
show: false,
key: "class",
icon: renderIcon("fluent-emoji:crossed-swords"),
},
{
label: () =>
h(RouterLink, { to: "/announcement" }, { default: () => "公告" }),
@@ -302,10 +185,10 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
? `课堂求助(${pendingHelpCount.value}`
: "课堂求助",
key: "help",
show: hasHelpEntry.value,
show: collabStore.isTeacher,
icon: renderIcon("streamline-emojis:raising-hands-2"),
props: {
onClick: () => (showHelpRequests.value = true),
onClick: () => (collabStore.helpPanelOpen = true),
},
},
{
@@ -316,15 +199,6 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
onClick: () => router.push("/user"),
},
},
{
label: "我的消息",
key: "message",
show: false,
icon: renderIcon("streamline-emojis:herb"),
props: {
onClick: () => router.push("/message"),
},
},
{
label: "我的提交",
key: "status",
@@ -368,39 +242,30 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
function goHome() {
router.push("/")
}
function handleMenuSelect(key: string) {
if (key === "dont-click") {
trickOrTreat()
}
}
</script>
<template>
<n-flex justify="space-between" align="center">
<n-flex align="center">
<n-flex align="center" class="title" @click="goHome">
<Icon icon="streamline-emojis:dog" :height="30"></Icon>
<div>{{ configStore.config?.websiteName }}</div>
<div v-if="titleTags.length">({{ titleTags.join(" · ") }})</div>
</n-flex>
<!-- text 按钮而不是带 @click div站名要能 tab 回车能按 -->
<n-button text class="title" @click="goHome">
<n-flex align="center">
<Icon icon="streamline-emojis:dog" :height="30"></Icon>
<div>{{ configStore.config?.websiteName }}</div>
<div v-if="titleTags.length">({{ titleTags.join(" · ") }})</div>
</n-flex>
</n-button>
<div>
<n-menu
v-if="isDesktop"
mode="horizontal"
:options="menus"
:value="active"
@update:value="handleMenuSelect"
/>
</div>
</n-flex>
<n-flex align="center">
<n-dropdown
v-if="isMobile"
:options="menus"
size="large"
@select="handleMenuSelect"
>
<n-dropdown v-if="isMobile" :options="menus" size="large">
<n-button>
<Icon icon="fluent-emoji:artist-palette" height="20"></Icon>
<span style="padding-left: 8px">菜单</span>
@@ -450,21 +315,11 @@ function handleMenuSelect(key: string) {
</template>
</n-button>
</n-flex>
<!--
挂在根 n-flex 内部而不是同级Header.vue 一旦变成多根 fragment
default.vue 里 `<Header class="header" />` 那个 class 就没有任何单一
根节点可以落地Vue 会报 "Extraneous non-props attributes" 警告并把它
整个丢弃header 行随之丢掉 `max-width: 2000px` 那条居中样式。
n-modal 默认 teleport 到 body塞在这里不影响它的实际渲染位置。
-->
<HelpRequestList v-if="hasHelpEntry" v-model:show="showHelpRequests" />
<CollabModal v-if="userStore.isTeacherOrAbove" />
</n-flex>
</template>
<style scoped>
.title {
font-size: 18px;
cursor: pointer;
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useCollabStore } from "shared/store/collab"
/** 由顶栏的姓名下拉菜单打开 */
@@ -7,6 +8,10 @@ const show = defineModel<boolean>("show", { default: false })
const collabStore = useCollabStore()
// 接单之后要在弹框里替学生写代码,那个编辑器窄屏上没法用 —— 所以窄屏只让看
// 「谁在等」角标、toast、这张列表照常给接单留到桌面端
const { isDesktop } = useBreakpoints()
// 等待时长要每秒走一格,所以自己转一个 now。
// 只在弹框开着时转 —— 这个组件跟着顶栏常驻,关着的时候没人看这个数。
const now = ref(Date.now())
@@ -40,7 +45,7 @@ const waited = (createdAt: number) => {
const handleAccept = (studentId: number, status: string) => {
// 已被别的老师接走的不能点
if (status === "active") return
if (status === "active" || !isDesktop.value) return
collabStore.accept(studentId)
// 接单后马上要弹 CollabModal这个列表得让位
show.value = false
@@ -55,7 +60,19 @@ const handleAccept = (studentId: number, status: string) => {
:style="{ width: '420px' }"
>
<div style="max-height: 60vh; overflow: auto">
<n-empty v-if="collabStore.groupedRequests.length === 0" description="暂无求助" />
<n-alert
v-if="!isDesktop"
type="info"
:bordered="false"
style="margin-bottom: 8px"
>
接单要在电脑上打开
</n-alert>
<n-empty
v-if="collabStore.groupedRequests.length === 0"
description="暂无求助"
/>
<div v-for="group in collabStore.groupedRequests" :key="group.problemId">
<!-- 同题多人是个教学信号该停下来全班讲而不是挨个救 -->
@@ -77,14 +94,17 @@ const handleAccept = (studentId: number, status: string) => {
padding: '6px 8px',
borderRadius: '4px',
opacity: item.status === 'active' ? 0.5 : 1,
cursor: item.status === 'active' ? 'default' : 'pointer',
cursor:
item.status === 'active' || !isDesktop ? 'default' : 'pointer',
}"
@click="handleAccept(item.studentId, item.status)"
>
<n-flex vertical :size="2">
<n-text>
{{ item.studentName }}
<n-text depth="3" v-if="item.className">{{ item.className }}</n-text>
<n-text depth="3" v-if="item.className"
>{{ item.className }}</n-text
>
</n-text>
<n-text depth="3" style="font-size: 12px">
{{

View File

@@ -0,0 +1,59 @@
import { useDark } from "@vueuse/core"
/**
* 圆环从哪儿开始扩散。正常点击就用指针落点键盘触发Enter / 空格)时浏览器给的
* clientX/clientY 是 0照用会让圆环从屏幕左上角冒出来——那种情况退回按钮自己的中心。
* `event.detail` 是点击次数,键盘触发时为 0用它区分最省事。
*/
function revealOrigin(event: MouseEvent) {
if (event.detail > 0) return { x: event.clientX, y: event.clientY }
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
}
/**
* 暗黑模式切换 + 圆环扩散过渡。
*
* 从 Header 里搬出来的:这套动画细节和「顶栏该放什么」没有关系,
* 哪个页面想再放一个主题开关都能直接用。
*/
export function useDarkTransition() {
const isDark = useDark()
function toggleDark(event: MouseEvent) {
if (!document.startViewTransition) {
// 机房那批 Chrome 低于 94没有 View Transitions直接切、不做动画。
isDark.value = !isDark.value
return
}
const { x, y } = revealOrigin(event)
// 半径要取到**最远**那个角的距离。用 hypot(x, y) 只覆盖到左上角,
// 点在偏左上时右下角会有一块旧画面等圆环扩过去,看着像是没刷新。
const radius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
)
document
.startViewTransition(() => {
isDark.value = !isDark.value
})
.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
],
},
{
duration: 400,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
)
})
.catch(() => {})
}
return { isDark, toggleDark }
}

View File

@@ -26,7 +26,12 @@ watch(
<template>
<n-layout position="absolute">
<n-layout-header bordered style="padding: 8px">
<Header class="header" />
<!-- 居中限宽套在外面别用 class 传给 Header那样 Header 就永远只能有
一个根节点多一个根就是 "Extraneous non-props attributes" 警告
加样式静默丢失 -->
<div class="header">
<Header />
</div>
</n-layout-header>
<n-layout-content
content-style="padding: 16px; overflow-x: initial; max-width: 2000px; margin: 0 auto;"

View File

@@ -35,8 +35,13 @@ export const useCollabStore = defineStore("collab", () => {
const teacherName = ref("")
/** 双方当前房间。null 表示不在协作中 */
const room = ref<RoomInfo | null>(null)
/** 一次性提示,由 Header 统一消费后清空 */
/** 一次性提示,由 CollabHost 统一消费后清空 */
const notice = ref("")
/**
* 求助列表弹框开着没有。放在 store 里而不是组件内部:打开它的入口(顶栏的
* 姓名下拉、新求助 toast和弹框本身已经不在同一棵子树里了。
*/
const helpPanelOpen = ref(false)
/**
* 提示序号,每次设置都自增。
*
@@ -57,7 +62,10 @@ export const useCollabStore = defineStore("collab", () => {
/** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */
const groupedRequests = computed(() => {
const groups = new Map<string, { problemId: string; problemTitle: string; items: CollabRequestItem[] }>()
const groups = new Map<
string,
{ problemId: string; problemTitle: string; items: CollabRequestItem[] }
>()
for (const item of requests.value) {
const group = groups.get(item.problemId)
if (group) group.items.push(item)
@@ -155,6 +163,7 @@ export const useCollabStore = defineStore("collab", () => {
teacherName.value = ""
room.value = null
notice.value = ""
helpPanelOpen.value = false
}
function requestHelp(problemId: string, language: LANGUAGE) {
@@ -211,6 +220,7 @@ export const useCollabStore = defineStore("collab", () => {
room,
notice,
noticeSeq,
helpPanelOpen,
isTeacher: computed(() => userStore.isTeacherOrAbove),
connect,
disconnect,

View File

@@ -1,7 +1,7 @@
import { PROBLEM_PERMISSION, STORAGE_KEY, USER_TYPE } from "utils/constants"
import storage from "utils/storage"
import type { Profile, SessionUser } from "utils/types"
import { getProfile } from "../api"
import { getProfile, logout } from "../api"
import { useConfigStore } from "./config"
export const useUserStore = defineStore("user", () => {
@@ -60,12 +60,23 @@ export const useUserStore = defineStore("user", () => {
return flag
})
async function getMyProfile() {
// 同一时刻只发一份 /profile。App.vue 挂载时要拉、路由守卫要拉、页面自己也可能
// 要拉(子组件的 onMounted 比 App.vue 的先跑),不去重就是同一个请求发好几遍,
// 页面里等它的那些请求还得跟着排队。只合并「正在飞的那一次」,不缓存结果 ——
// 登录后和改完设置仍然要能重新拉一份。
let inflight: Promise<void> | null = null
function getMyProfile() {
if (inflight) return inflight
isFinished.value = false
const res = await getProfile()
profile.value = res
isFinished.value = true
storage.set(STORAGE_KEY.AUTHED, !!user.value?.email)
inflight = getProfile()
.then((res) => {
profile.value = res
isFinished.value = true
storage.set(STORAGE_KEY.AUTHED, !!user.value?.email)
})
.finally(() => (inflight = null))
return inflight
}
function clearProfile() {
@@ -73,6 +84,13 @@ export const useUserStore = defineStore("user", () => {
demoMode.value = false
storage.clear()
}
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
// 的 cookie下次进站又被 /profile 认回来。跳转留给调用方store 里不碰路由。
async function signOut() {
await logout()
clearProfile()
}
return {
profile,
isFinished,
@@ -90,5 +108,6 @@ export const useUserStore = defineStore("user", () => {
showSubmissions,
getMyProfile,
clearProfile,
signOut,
}
})