Compare commits

..
2 Commits
Author SHA1 Message Date
xuyue 2031e6a434 update
Deploy / deploy (push) Has been cancelled
2026-08-31 07:41:44 -06:00
xuyueandClaude Opus 5 e62f41f6c7 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
2026-08-31 07:36:14 -06:00
11 changed files with 270 additions and 209 deletions
+14 -10
View File
@@ -181,23 +181,27 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: LEADERBOARD_SIZE })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere)
const total = Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE)
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
// 真人不够时数据库自己会少返回。不拿 total 当上限,三段查询就能并发发出去,
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
// 末页可能只剩不足 limit 条,越界页一条不剩 —— 后者直接不发 SQL
const pageLimit = Math.max(0, Math.min(limit, total - offset))
const rows = pageLimit === 0 ? [] : await db
const [totalRow, rows, me] = await Promise.all([
db.select({ value: count() }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere).then(([row]) => row),
pageLimit === 0 ? [] : db
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere).orderBy(...leaderboardOrder)
.limit(pageLimit).offset(offset)
.limit(pageLimit).offset(offset),
myLeaderboardRank(c.get("user")?.id),
])
return success(c, userRankSchema.parse({
results: rows.map(serializeRankRow),
total,
me: await myLeaderboardRank(c.get("user")?.id),
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
me,
}))
})
+9 -3
View File
@@ -5,7 +5,7 @@ import {
classRankItemSchema,
classUserRankSchema,
} from "@oj2/contract"
import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm"
import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
@@ -24,13 +24,19 @@ interface ClassUser {
submissionNumber: number
}
async function loadClassUsers(classNames?: string[]) {
/**
* 入班学生的 AC/提交数。`gradePrefix` 是年级(班号形如 `241` = 24 级 1 班),
* 走 SQL 的 like 而不是拉全表再在内存里 startsWith —— 班级榜每换一次年级就要跑一遍,
* 没必要每次都把全校一千多号人搬进进程。年级在调用处已校验为纯数字,不含 like 通配符。
*/
async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
const filters = [
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
sql`${schema.user.className} is not null`,
]
if (classNames) filters.push(inArray(schema.user.className, classNames))
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
const rows = await db.select({
userId: schema.user.id,
username: schema.user.username,
@@ -72,7 +78,7 @@ function sampleStdDev(values: number[]) {
classroomRoutes.get("/rankings/classes", async (c) => {
const grade = c.req.query("grade")?.trim()
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
const users = (await loadClassUsers()).filter((user) => user.className.startsWith(grade))
const users = await loadClassUsers(undefined, grade)
const groups = new Map<string, ClassUser[]>()
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
const result = [...groups].map(([className, members]) => {
+4
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>
+11 -14
View File
@@ -242,11 +242,13 @@ const columns: DataTableColumn<Rank>[] = [
]
watch(() => query.page, init)
// 改每页条数时,若当前不在第一页,把重新取数交给 page 的 watcher ——
// 这里再自己取一次,就是两个一模一样的请求
watch(
() => query.limit,
() => {
query.page = 1
init()
if (query.page === 1) init()
else query.page = 1
},
)
watch(duration, listActivity)
@@ -265,12 +267,6 @@ async function listActivity() {
}))
}
// 「全服 Top10」就是同一个榜的第一页 —— 上限由服务端定,这里只要前 10 条
async function listRank() {
const res = await getRank(0, 10)
rankChart.value = res.results
}
const options: SelectOption[] = [
{ label: "一周内", value: "weeks:1" },
{ label: "一个月内", value: "months:1" },
@@ -288,8 +284,10 @@ const subOptions = computed<Duration>(() => {
})
onMounted(() => {
init()
listRank()
// 「全服 Top10」就是榜单第一页的前 10 条:挂载时 init() 取的正是 offset=0&limit=10
// 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。
// 图只在挂载时定一次,翻页/改每页条数不该动它。
init().then((results) => (rankChart.value = results.slice(0, 10)))
listActivity()
listClassRank()
listMyClassRank()
@@ -480,13 +478,12 @@ watch(
},
)
// 同上:page 改了自会触发下面那个 watcher,别重复取
watch(
() => myClassQuery.limit,
() => {
myClassQuery.page = 1
if (myClassScope.value === "all") {
listMyClassRank()
}
if (myClassQuery.page !== 1) myClassQuery.page = 1
else if (myClassScope.value === "all") listMyClassRank()
},
)
</script>
@@ -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>
+17 -162
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">
<!-- 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">
{{
@@ -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 }
}
+6 -1
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;"
+12 -2
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,
+22 -3
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()
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,
}
})