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:
@@ -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>
|
||||
|
||||
82
apps/web/src/shared/components/CollabHost.vue
Normal file
82
apps/web/src/shared/components/CollabHost.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
{{
|
||||
|
||||
59
apps/web/src/shared/composables/darkTransition.ts
Normal file
59
apps/web/src/shared/composables/darkTransition.ts
Normal 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 }
|
||||
}
|
||||
@@ -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;"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user