feat(顶栏): 顶栏重排——点题号框就能看到这节课的题,老师的「N 人举手」挪上顶栏、不再一条条弹提示
Deploy / deploy (push) Canceled after 0s

- 导航改成 题目 / 题单 / 比赛 / 排名 / 自学 / 更多,当前页浅绿底块;提交、公告收进「更多」
- 题号框点开先列出这节课的题(做对没有、下一道),输入时按题号或标题筛,回车照旧直达;
  只在今天有课、不在题目页时出现
- 后台入口、暗色开关收进个人菜单;老师顶栏只留课堂看板
- 「N 人举手」前台、后台顶栏都有,放最末尾:没人等不显示,人数变多时闪一下;
  新求助的 toast 删掉(一节课能弹十几次,投屏时还弹在全班面前)
- 1280 以下题号框变窄、课堂看板只留图标、名字最宽 120;1024 以下允许折行

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 09:24:02 +08:00
1 parent 5b43015be4
commit 56ef4a999c
5 files changed
+629 -212

No files matched your search

+9 -43
View File
@@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CollabRequestItem } from "shared/composables/websocket"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
import HelpRequestList from "./HelpRequestList.vue" import HelpRequestList from "./HelpRequestList.vue"
/** /**
* 课堂求助的全局界面:一次性提示、新求助 toast、求助列表。 * 课堂求助的全局界面:一次性提示、求助列表。
*
* 新求助**不弹 toast**:原来每来一个就弹一条,一节课能弹十几次,讲台电脑投着屏时还弹在
* 全班面前。现在靠顶栏(前台、后台都有)的 HelpButton:没人等不显示,冒出来就是提醒,
* 人数再变多时闪一下。
* *
* 教师端的协作**没有弹框**:接单会跳到那道题的页面,在页面自带的编辑器里协作 * 教师端的协作**没有弹框**:接单会跳到那道题的页面,在页面自带的编辑器里协作
* (见 HelpRequestList 的 handleAccept、ProblemEditor 的 collabHere)。 * (见 HelpRequestList 的 handleAccept、ProblemEditor 的 collabHere)。
* 原来这里还异步挂一个 CollabModal,那个弹框按一下 Esc 就关、协作跟着结束。 * 原来这里还异步挂一个 CollabModal,那个弹框按一下 Esc 就关、协作跟着结束。
* *
* 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走, * 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走,
* 而连接是全局常驻的(App.vue 按登录态开关)—— 挂在顶栏里的时候,老师一进 * 而连接是全局常驻的(App.vue 按登录态开关)—— 挂在前台顶栏里的时候,老师一进
* /admin 就换成了 admin.vue 布局,顶栏连同这几个消费者一起卸载:求助照收, * /admin 就换成了 admin.vue 布局,顶栏连同这几个消费者一起卸载,正好错过
* 提示、角标、协作界面全都不出现,正好错过 collab.ts 里写的那句「老师可能 * collab.ts 里写的那句「老师可能正在后台改题时收到求助」。放在这里才真的全局;
* 正在后台改题时收到求助」。放在这里才真的全局。 * 后台那条顶栏自己也挂了一个 HelpButton。
* *
* 位置要求:n-message-provider 的后代(useMessage 需要)。 * 位置要求:n-message-provider 的后代(useMessage 需要)。
*/ */
@@ -35,43 +38,6 @@ watch(
if (text) message.info(text) 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> </script>
<template> <template>
+228 -169
View File
@@ -1,15 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { NSwitch, useThemeVars } from "naive-ui"
import { RouterLink } from "vue-router" import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useDarkTransition } from "shared/composables/darkTransition" import { useDarkTransition } from "shared/composables/darkTransition"
import { useLearnProgress } from "shared/composables/learnProgress" import { useLearnProgress } from "shared/composables/learnProgress"
import { useProblemJump } from "shared/composables/problemJump"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "../store/config" import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user" import { useUserStore } from "../store/user"
import { useThemeVars } from "naive-ui" import HelpButton from "./HelpButton.vue"
import ProblemJumpBox from "./ProblemJumpBox.vue"
const userStore = useUserStore() const userStore = useUserStore()
const configStore = useConfigStore() const configStore = useConfigStore()
@@ -24,23 +25,17 @@ const { isDark, toggleDark } = useDarkTransition()
const theme = useThemeVars() const theme = useThemeVars()
/** /**
* 求助的入口收进姓名下拉里,顶栏只留姓名按钮上的角标 —— 老师不用展开菜单 * 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂看板」只留图标。
* 也能看见有没有人举手。窄屏同样给:接单之后要在弹框里替学生写代码,那件事 * 机房的两种屏是 1280 和 1366,都走完整那版 —— 老师也会用机房的电脑。
* 确实只有桌面端好使,但「有没有人在等」是宽度多少都得知道的。
*/ */
const pendingHelpCount = computed(() => (collabStore.isTeacher ? collabStore.pendingCount : 0)) const compact = useMediaQuery("(max-width: 1279px)")
// 再窄(769–1023,很少见)一行放不下老师那版,允许折行,只保证不坏
const narrow = useMediaQuery("(max-width: 1023px)")
/** const tone = computed(() => ({
* 顶栏的题号框:老师报完题号,学生在哪一页都能直接敲,不用先回首页或题目列表。 activeBg: isDark.value ? "rgba(99, 226, 183, 0.14)" : "#e7f5ed",
* 只给桌面端 —— 窄屏顶栏已经挤满了,手机上从首页的搜索框走同一套逻辑。 activeText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
*/ }))
const { jump, jumping } = useProblemJump()
const jumpKeyword = ref("")
async function handleJump() {
await jump(jumpKeyword.value)
jumpKeyword.value = ""
}
/** /**
* 站名后面的小标签:环境名、演示中。 * 站名后面的小标签:环境名、演示中。
@@ -79,7 +74,7 @@ function handleToggleDemoMode() {
} }
function renderIcon(icon: string) { function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 20 }) return () => h(Icon, { icon, width: 18 })
} }
function learnLink(type: "python" | "c") { function learnLink(type: "python" | "c") {
@@ -87,154 +82,161 @@ function learnLink(type: "python" | "c") {
} }
/** /**
* 顶栏导航的唯一一张表:桌面端渲染成一排纯文字链接,窄屏收进「菜单」下拉。 * 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。
* `key` 就是一级路径,用来判定高亮(见上面的 active)。 * `key` 就是一级路径,用来判定高亮(见上面的 active)。
* *
* 桌面端不带图标、不用 n-menu:n-menu 横排每项固定占 100px,管理员 8 项在 * 一级只放天天用的(题目、题单)和课上会被点名去看的(比赛、排名)。提交、公告收进
* 1280 宽的机房屏上直接把顶栏挤成两行。图标只留给下拉菜单。 * 「更多」:全站的提交列表学生很少翻,自己的在个人菜单里;公告一年没几条。
* 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。
*/ */
interface NavLink { interface NavLink {
key: string key: string
label: string label: string
to: string to: string
icon: string
show?: boolean show?: boolean
} }
const navLinks = computed<NavLink[]>(() => const navLinks: NavLink[] = [
{ key: "problem", label: "题目", to: "/problem" },
{ key: "problemset", label: "题单", to: "/problemset" },
{ key: "contest", label: "比赛", to: "/contest" },
{ key: "rank", label: "排名", to: "/rank" },
]
const moreLinks = computed<NavLink[]>(() =>
[ [
{ key: "problem", label: "题目", to: "/problem", icon: "fluent-emoji:memo" }, { key: "submission", label: "提交", to: "/submission", show: userStore.showSubmissions },
{ key: "problemset", label: "题单", to: "/problemset", icon: "fluent-emoji:clipboard" }, { key: "announcement", label: "公告", to: "/announcement" },
{
key: "submission",
label: "提交",
to: "/submission",
icon: "fluent-emoji:inbox-tray",
show: userStore.showSubmissions,
},
{ key: "contest", label: "比赛", to: "/contest", icon: "fluent-emoji:chequered-flag" },
{ key: "rank", label: "排名", to: "/rank", icon: "fluent-emoji:trophy" },
{ key: "announcement", label: "公告", to: "/announcement", icon: "fluent-emoji:loudspeaker" },
].filter((link) => link.show !== false), ].filter((link) => link.show !== false),
) )
const moreActive = computed(() => moreLinks.value.some((link) => link.key === active.value))
const learnOptions: DropdownOption[] = [ const learnOptions: DropdownOption[] = [
{ label: "Python", key: "learn-python" }, { label: "Python", key: "learn-python", icon: renderIcon("ph:book-open") },
{ label: "C语言", key: "learn-c" }, { label: "C 语言", key: "learn-c", icon: renderIcon("ph:book-open") },
] ]
// 「后台」不和学生导航排在一起:桌面端放到右边那组的开头、弱化成灰字, const moreOptions = computed<DropdownOption[]>(() =>
// 老师和学生看到的顶栏前半截就是同一排 moreLinks.value.map((link) => ({
const adminPath = computed(() => (userStore.isSuperAdmin ? "/admin" : "/admin/problem/list"))
const mobileMenus = computed<DropdownOption[]>(() => [
{
label: "自学",
key: "learn",
icon: renderIcon("fluent-emoji:books"),
children: learnOptions,
},
...navLinks.value.map((link) => ({
label: link.label, label: link.label,
key: link.key, key: link.key,
icon: renderIcon(link.icon), icon: renderIcon(link.key === "submission" ? "ph:tray" : "ph:megaphone"),
})), })),
{ )
label: "后台",
key: "admin", const mobileMenus = computed<DropdownOption[]>(() => [
icon: renderIcon("fluent-emoji:gear"), ...navLinks.map((link) => ({ label: link.label, key: link.key })),
show: userStore.isAdminRole, { label: "自学", key: "learn", children: learnOptions },
}, ...moreLinks.value.map((link) => ({ label: link.label, key: link.key })),
]) ])
function handleNavSelect(key: string) { function handleNavSelect(key: string) {
if (key === "learn-python") router.push(learnLink("python")) if (key === "learn-python") router.push(learnLink("python"))
else if (key === "learn-c") router.push(learnLink("c")) else if (key === "learn-c") router.push(learnLink("c"))
else if (key === "admin") router.push(adminPath.value)
else { else {
const link = navLinks.value.find((item) => item.key === key) const link = [...navLinks, ...moreLinks.value].find((item) => item.key === key)
if (link) router.push(link.to) if (link) router.push(link.to)
} }
} }
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [ const adminPath = computed(() => (userStore.isSuperAdmin ? "/admin" : "/admin/problem/list"))
{
label: pendingHelpCount.value ? `课堂求助(${pendingHelpCount.value})` : "课堂求助", /**
key: "help", * 暗色开关放进个人菜单:顶栏右边留给老师的课堂工具。整行可点,开关只是显示状态
show: collabStore.isTeacher, * (pointer-events 关掉,免得点在开关上切两次)。
icon: renderIcon("streamline-emojis:raising-hands-2"), */
props: { function renderThemeRow() {
onClick: () => (collabStore.helpPanelOpen = true), return h(
"div",
{
class: "theme-row",
role: "switch",
"aria-checked": isDark.value,
tabindex: 0,
onClick: toggleDark,
onKeydown: (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
;(event.currentTarget as HTMLElement).click()
}
},
}, },
}, [
{ h(Icon, { icon: "ph:moon", width: 18 }),
label: "课堂看板", h("span", { class: "theme-label" }, "暗色"),
key: "classroom-board", h(NSwitch, { value: isDark.value, size: "small", style: "pointer-events: none" }),
show: userStore.isTeacherOrAbove, ],
icon: renderIcon("fluent-emoji:school"), )
props: { }
onClick: () => router.push("/classroom"),
/**
* 个人菜单只放「自己的东西」+ 后台入口 + 暗色开关。
* 课堂求助、课堂看板挪到了顶栏上(HelpButton、下面的课堂看板链接),不在这里。
*/
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
const work = userStore.isAdminRole || userStore.canToggleDemoMode
return [
{
label: "后台",
key: "admin",
show: userStore.isAdminRole,
icon: renderIcon("ph:toolbox"),
props: { onClick: () => router.push(adminPath.value) },
}, },
}, {
{ label: userStore.demoMode ? "退出演示" : "进入演示",
label: "我的主页", key: "demo-mode",
key: "home", show: userStore.canToggleDemoMode,
icon: renderIcon("streamline-ultimate-color:newspaper-fold"), icon: renderIcon("ph:presentation"),
props: { props: { onClick: handleToggleDemoMode },
onClick: () => router.push("/user"),
}, },
}, { type: "divider", key: "work-divider", show: work },
{ {
label: "我的成就", label: "我的主页",
key: "achievement", key: "home",
icon: renderIcon("streamline-ultimate-color:award-medal-4"), icon: renderIcon("ph:house"),
props: { props: { onClick: () => router.push("/user") },
onClick: () => router.push("/achievement"),
}, },
}, {
{ label: "我的成就",
label: "我的提交", key: "achievement",
key: "status", icon: renderIcon("ph:medal"),
icon: renderIcon("streamline-ultimate-color:analytics-bars-3d"), props: { onClick: () => router.push("/achievement") },
props: {
onClick: () => router.push("/submission?myself=1"),
}, },
}, {
{ label: "我的提交",
label: "我的设置", key: "status",
key: "setting", icon: renderIcon("ph:list-checks"),
icon: renderIcon("streamline-emojis:musical-score"), props: { onClick: () => router.push("/submission?myself=1") },
props: {
onClick: () => router.push("/setting"),
}, },
}, {
{ label: "智能分析",
label: "智能分析", key: "ai-analysis",
key: "ai-analysis", icon: renderIcon("ph:sparkle"),
icon: renderIcon("vscode-icons:file-type-gemini"), props: { onClick: () => router.push("/ai-analysis") },
props: {
onClick: () => router.push("/ai-analysis"),
}, },
}, {
{ label: "我的设置",
label: userStore.demoMode ? "退出演示" : "进入演示", key: "setting",
key: "demo-mode", icon: renderIcon("ph:gear-six"),
show: userStore.canToggleDemoMode, props: { onClick: () => router.push("/setting") },
icon: renderIcon("fluent-emoji:graduation-cap"), },
props: { onClick: handleToggleDemoMode }, { type: "divider", key: "theme-divider" },
}, { type: "render", key: "theme", render: renderThemeRow },
{ type: "divider" }, { type: "divider", key: "logout-divider" },
{ {
label: "退出", label: "退出",
key: "logout", key: "logout",
icon: renderIcon("streamline-ultimate-color:coffee-cold"), icon: renderIcon("ph:sign-out"),
props: { onClick: handleLogout }, props: { onClick: handleLogout },
}, },
]) ]
})
</script> </script>
<template> <template>
<header class="bar" :class="{ mobile: isMobile }"> <header class="bar" :class="{ compact, wrap: isMobile || narrow }">
<RouterLink to="/" class="brand"> <RouterLink to="/" class="brand">
<Icon icon="streamline-emojis:dog" :height="26"></Icon> <Icon icon="streamline-emojis:dog" :height="26"></Icon>
<span>{{ configStore.config?.websiteName }}</span> <span>{{ configStore.config?.websiteName }}</span>
@@ -244,9 +246,6 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
</n-tag> </n-tag>
</RouterLink> </RouterLink>
<nav v-if="isDesktop" class="nav"> <nav v-if="isDesktop" class="nav">
<n-dropdown trigger="hover" :options="learnOptions" @select="handleNavSelect">
<button type="button" class="nav-link" :class="{ active: active === 'learn' }">自学</button>
</n-dropdown>
<RouterLink <RouterLink
v-for="link in navLinks" v-for="link in navLinks"
:key="link.key" :key="link.key"
@@ -256,36 +255,42 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
> >
{{ link.label }} {{ link.label }}
</RouterLink> </RouterLink>
<n-dropdown trigger="hover" :options="learnOptions" @select="handleNavSelect">
<button type="button" class="nav-link" :class="{ active: active === 'learn' }">
自学<Icon icon="ph:caret-down-bold" :width="11" />
</button>
</n-dropdown>
<n-dropdown trigger="hover" :options="moreOptions" @select="handleNavSelect">
<button type="button" class="nav-link" :class="{ active: moreActive }">
更多<Icon icon="ph:caret-down-bold" :width="11" />
</button>
</n-dropdown>
</nav> </nav>
<div class="spacer"></div> <div class="spacer"></div>
<div class="actions"> <div class="actions">
<template v-if="isDesktop && userStore.isAdminRole"> <ProblemJumpBox v-if="isDesktop" :compact="compact" />
<RouterLink :to="adminPath" class="admin-link"> <template v-if="isDesktop && collabStore.isTeacher">
<Icon icon="ph:gear-six" :width="15" /> <RouterLink
<span>后台</span> to="/classroom"
class="tool-link"
:class="{ active: active === 'classroom' }"
title="课堂看板"
aria-label="课堂看板"
>
<Icon icon="ph:monitor" :width="16" />
<span v-if="!compact">课堂看板</span>
</RouterLink> </RouterLink>
<span class="divider"></span> <span class="divider"></span>
</template> </template>
<n-dropdown v-if="isMobile" :options="mobileMenus" size="large" @select="handleNavSelect"> <n-dropdown v-if="isMobile" :options="mobileMenus" size="large" @select="handleNavSelect">
<n-button>菜单</n-button> <n-button>菜单</n-button>
</n-dropdown> </n-dropdown>
<n-input
v-if="isDesktop"
v-model:value="jumpKeyword"
class="jump"
placeholder="输入题号直达"
:loading="jumping"
@keyup.enter="handleJump"
>
<template #prefix>
<Icon icon="ph:magnifying-glass" />
</template>
</n-input>
<template v-if="userStore.isFinished"> <template v-if="userStore.isFinished">
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large"> <n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
<n-badge :value="pendingHelpCount" :max="99"> <n-button class="name" :title="userStore.user!.username">
<n-button>{{ userStore.user!.username }}</n-button> <span class="name-text">{{ userStore.user!.username }}</span>
</n-badge> <Icon icon="ph:caret-down-bold" :width="11" class="caret" />
</n-button>
</n-dropdown> </n-dropdown>
<template v-else> <template v-else>
<n-button secondary type="primary" @click="authStore.openLoginModal()"> 登录 </n-button> <n-button secondary type="primary" @click="authStore.openLoginModal()"> 登录 </n-button>
@@ -296,19 +301,22 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
> >
注册 注册
</n-button> </n-button>
<!-- 没登录就没有个人菜单,暗色开关只能留在顶栏 -->
<n-button
quaternary
circle
:title="isDark ? '切换到亮色' : '切换到暗色'"
:aria-label="isDark ? '切换到亮色' : '切换到暗色'"
@click="toggleDark"
>
<template #icon>
<Icon :icon="isDark ? 'ph:sun' : 'ph:moon'" />
</template>
</n-button>
</template> </template>
</template> </template>
<n-button <!-- 放在最末尾:出现、消失时左边的东西都不挪 -->
quaternary <HelpButton />
circle
:title="isDark ? '切换到亮色' : '切换到暗色'"
:aria-label="isDark ? '切换到亮色' : '切换到暗色'"
@click="toggleDark"
>
<template #icon>
<Icon :icon="isDark ? 'ph:sun' : 'ph:moon'" />
</template>
</n-button>
</div> </div>
</header> </header>
</template> </template>
@@ -326,8 +334,12 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
white-space: nowrap; white-space: nowrap;
} }
.bar.compact {
gap: 20px;
}
/* 窄屏放不下一行:允许折行,高度跟着内容走 */ /* 窄屏放不下一行:允许折行,高度跟着内容走 */
.bar.mobile { .bar.wrap {
height: auto; height: auto;
min-height: 55px; min-height: 55px;
padding: 8px 12px; padding: 8px 12px;
@@ -349,13 +361,18 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
.nav { .nav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 22px; gap: 2px;
flex: none; flex: none;
} }
.nav-link { .nav-link {
padding: 0; height: 32px;
padding: 0 10px;
display: flex;
align-items: center;
gap: 3px;
border: 0; border: 0;
border-radius: 6px;
background: transparent; background: transparent;
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
@@ -364,11 +381,14 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
} }
.nav-link:hover { .nav-link:hover {
color: v-bind("theme.primaryColor"); background: v-bind("theme.hoverColor");
} }
/* 当前页:浅绿底块(设计稿「顶栏重设计 · 定稿」)。字色在暗色下换浅一档,
深绿放在暗底上看不清 —— 和题目页课堂条同一个做法 */
.nav-link.active { .nav-link.active {
color: v-bind("theme.primaryColorPressed"); background: v-bind("tone.activeBg");
color: v-bind("tone.activeText");
font-weight: 600; font-weight: 600;
} }
@@ -379,11 +399,11 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
.actions { .actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 14px;
flex: none; flex: none;
} }
.admin-link { .tool-link {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
@@ -391,7 +411,8 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
text-decoration: none; text-decoration: none;
} }
.admin-link:hover { .tool-link:hover,
.tool-link.active {
color: v-bind("theme.primaryColor"); color: v-bind("theme.primaryColor");
} }
@@ -401,7 +422,45 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
background-color: v-bind("theme.dividerColor"); background-color: v-bind("theme.dividerColor");
} }
.jump { /* 学生用户名最长有 25 个字符:按钮最宽 160,多出来的省略,全名在 title 里 */
width: 180px; .name {
max-width: 160px;
}
/* 1024 宽、老师那版、名字按最长的算,160 会挤进右边距 */
.bar.compact .name {
max-width: 120px;
}
.name :deep(.n-button__content) {
min-width: 0;
gap: 4px;
}
.name-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.caret {
flex: none;
}
</style>
<!-- 暗色开关那一行是 render 出来挂在 dropdown 里的(teleport 到 body),scoped 够不着 -->
<style>
.theme-row {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 14px 0 12px;
cursor: pointer;
}
.theme-row .theme-label {
flex: 1 1 0;
font-size: 15px;
} }
</style> </style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useCollabStore } from "shared/store/collab"
/**
* 老师顶栏上的「N 人举手」:前台顶栏和后台顶栏共用这一个。
*
* **没人在等就不渲染** —— 平时看不见,冒出来本身就是提醒。人已经在等、又来一个时,
* 按钮闪一下(约 1 秒、没声音、不挡内容),代替原来每来一个就弹一条的 toast:
* 那条 toast 一节课能弹十几次,讲台电脑投着屏的时候还弹在全班面前。
*
* 求助列表另有一个入口在课堂看板上,没人等的时候真想看「谁在处理」从那边进。
*/
const props = withDefaults(defineProps<{ size?: "medium" | "small" }>(), { size: "medium" })
const collabStore = useCollabStore()
const isDark = useDark()
const count = computed(() => (collabStore.isTeacher ? collabStore.pendingCount : 0))
// 每次变大就换一个 key,让 CSS 动画从头再放一遍;从 0 冒出来那次不闪,出现本身就够显眼
const flashKey = ref(0)
watch(count, (value, previous) => {
if (value === 0) flashKey.value = 0
else if (previous > 0 && value > previous) flashKey.value += 1
})
// 暗色下深红底放在暗底上看不清,换一套(和课堂条的做法一样,按明暗各给一组)
const tone = computed(() =>
isDark.value
? {
bg: "#3a1d24",
border: "#6b2d3a",
text: "#f28b9e",
flash: "#6b2d3a",
ring: "rgba(242, 139, 158, 0.25)",
}
: {
bg: "#fdecef",
border: "#f3c1cb",
text: "#b0213d",
flash: "#f7b9c6",
ring: "rgba(208, 48, 80, 0.18)",
},
)
</script>
<template>
<button
v-if="count > 0"
:key="flashKey"
type="button"
class="help"
:class="[props.size, { flash: flashKey > 0 }]"
title="打开求助列表"
@click="collabStore.helpPanelOpen = true"
>
<Icon icon="ph:hand" :width="props.size === 'small' ? 14 : 15" />
<span>{{ count }} 人举手</span>
</button>
</template>
<style scoped>
.help {
flex: none;
display: flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 11px 0 9px;
border: 1px solid v-bind("tone.border");
border-radius: 15px;
background: v-bind("tone.bg");
color: v-bind("tone.text");
font: inherit;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.help.small {
height: 28px;
border-radius: 14px;
font-size: 13px;
}
.help.flash {
animation: flash 1s ease-out;
}
@keyframes flash {
0%,
40% {
background: v-bind("tone.flash");
box-shadow: 0 0 0 4px v-bind("tone.ring");
}
100% {
background: v-bind("tone.bg");
box-shadow: 0 0 0 0 transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.help.flash {
animation: none;
}
}
</style>
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { useLessonStore } from "oj/store/lesson"
import { useProblemJump } from "shared/composables/problemJump"
import { useUserStore } from "shared/store/user"
import type { ClassActivityProblem } from "utils/types"
import { zonedParts } from "utils/functions"
/**
* 顶栏的题号框:老师报完题号,学生在哪一页都能直接敲,不用先回首页或题目列表。
*
* 点进框里先列出「这节课的题」(和首页「班里在做」、题目页课堂条同一份数据),
* 没听清题号的学生不用问同桌;开始输入就只留对得上的。回车的行为没变:题号对上就
* 直达,对不上去题库搜(见 useProblemJump)。
*
* 列表只在**今天**有课、而且**不在题目页**时出现:接口会往前找最近 7 天里有课的那天,
* 周一早上拿到的是上周五的题,那不叫「这节课」;题目页顶上已经有课堂条,不再重复一份。
*/
const props = defineProps<{ compact?: boolean }>()
const route = useRoute()
const router = useRouter()
const theme = useThemeVars()
const isDark = useDark()
const userStore = useUserStore()
const lessonStore = useLessonStore()
const { jump, jumping } = useProblemJump()
const keyword = ref("")
const focused = ref(false)
const input = useTemplateRef<{ blur: () => void }>("input")
/** 题目页的三种入口(题库 / 比赛 / 题单)顶上都有自己的上下文条 */
const PROBLEM_PAGES = new Set(["problem", "contest problem", "problemset problem"])
const onProblemPage = computed(() => PROBLEM_PAGES.has(String(route.name)))
function todayKey() {
const now = zonedParts(new Date())!
return `${now.year}-${String(now.month).padStart(2, "0")}-${String(now.day).padStart(2, "0")}`
}
const lesson = computed(() => {
const activity = lessonStore.activity
if (!activity?.problems.length || activity.day !== todayKey()) return null
return activity
})
const done = computed(
() => lesson.value?.problems.filter((item) => item.myStatus === "accepted").length ?? 0,
)
/** 「下一道」是第一道还没做对的 —— 不在题目页上,没有「当前这道」可以往后数 */
const nextId = computed(
() => lesson.value?.problems.find((item) => item.myStatus !== "accepted")?.problemDisplayId,
)
const filtered = computed(() => {
const list = lesson.value?.problems ?? []
const text = keyword.value.trim().toLowerCase()
if (!text) return list
return list.filter(
(item) =>
item.problemDisplayId.toLowerCase().includes(text) || item.title.toLowerCase().includes(text),
)
})
const open = computed(() => focused.value && !onProblemPage.value && filtered.value.length > 0)
function handleFocus() {
focused.value = true
// 接口要求登录;store 自己有两分钟的缓存,来回点不会重复拉
if (userStore.isAuthed) lessonStore.load()
}
function close() {
input.value?.blur()
keyword.value = ""
}
async function handleEnter() {
const text = keyword.value
close()
await jump(text)
}
function openProblem(item: ClassActivityProblem) {
close()
router.push(`/problem/${item.problemDisplayId}`)
}
const STATUS_ICON: Record<ClassActivityProblem["myStatus"], string> = {
accepted: "ph:check-circle-fill",
tried: "ph:warning-circle-fill",
none: "ph:circle",
}
const statusColor = computed<Record<ClassActivityProblem["myStatus"], string>>(() => ({
accepted: theme.value.successColor,
tried: theme.value.warningColor,
none: theme.value.textColor3,
}))
const tone = computed(() => ({
label: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
nextBg: isDark.value ? "rgba(99, 226, 183, 0.10)" : "#f3f9f5",
tagBg: isDark.value ? "rgba(99, 226, 183, 0.16)" : "#e7f5ed",
}))
/**
* 面板是 n-popover 传送到 body 下的,不在本组件根元素底下 —— scoped 样式里的 v-bind
* 生成的 CSS 变量挂在根元素上,面板取不到(实测整块透明)。所以变量直接挂在面板上
*/
const panelVars = computed(() => ({
"--jb-popoverColor": theme.value.popoverColor,
"--jb-borderRadius": theme.value.borderRadius,
"--jb-boxShadow2": theme.value.boxShadow2,
"--jb-dividerColor": theme.value.dividerColor,
"--jb-label": tone.value.label,
"--jb-textColor3": theme.value.textColor3,
"--jb-textColor1": theme.value.textColor1,
"--jb-hoverColor": theme.value.hoverColor,
"--jb-nextBg": tone.value.nextBg,
"--jb-textColor2": theme.value.textColor2,
"--jb-tagBg": tone.value.tagBg,
}))
</script>
<template>
<n-popover trigger="manual" :show="open" placement="bottom-start" :show-arrow="false" raw>
<template #trigger>
<n-input
ref="input"
v-model:value="keyword"
class="jump"
:class="{ compact: props.compact }"
placeholder="输入题号直达"
:loading="jumping"
@focus="handleFocus"
@blur="focused = false"
@keyup.enter="handleEnter"
@keydown.esc="close"
>
<template #prefix>
<Icon icon="ph:magnifying-glass" />
</template>
</n-input>
</template>
<!-- mousedown 不让输入框失焦:一失焦 open 就变 false,面板先没了,click 落空 -->
<div class="panel" :style="panelVars" @mousedown.prevent>
<div class="head">
<span class="label">{{ lessonStore.label }}</span>
<span class="count">做完 {{ done }}/{{ lesson?.problems.length }}</span>
</div>
<a
v-for="item in filtered"
:key="item.problemDisplayId"
:href="`/problem/${item.problemDisplayId}`"
class="row"
:class="{ next: item.problemDisplayId === nextId, accepted: item.myStatus === 'accepted' }"
@click.prevent="openProblem(item)"
>
<Icon
:icon="STATUS_ICON[item.myStatus]"
:width="18"
:color="statusColor[item.myStatus]"
class="status"
/>
<span class="id">{{ item.problemDisplayId }}</span>
<span class="title">{{ item.title }}</span>
<span v-if="item.problemDisplayId === nextId" class="tag">下一道</span>
</a>
<div class="foot">
{{ keyword.trim() ? "回车按题号直达,对不上就去题库搜" : "也可以直接输入题号,回车打开" }}
</div>
</div>
</n-popover>
</template>
<style scoped>
.jump {
width: 240px;
}
.jump.compact {
width: 160px;
}
.panel {
width: 320px;
margin-top: 4px;
padding: 4px 0;
display: flex;
flex-direction: column;
background: var(--jb-popoverColor);
border-radius: var(--jb-borderRadius);
box-shadow: var(--jb-boxShadow2);
font-size: 14px;
}
.head {
height: 34px;
box-sizing: border-box;
margin-bottom: 4px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--jb-dividerColor);
font-size: 13px;
}
.label {
font-weight: 600;
color: var(--jb-label);
}
.count {
color: var(--jb-textColor3);
}
.row {
height: 36px;
box-sizing: border-box;
padding: 0 12px;
display: flex;
align-items: center;
gap: 10px;
color: var(--jb-textColor1);
text-decoration: none;
}
.row:hover {
background: var(--jb-hoverColor);
}
.row.next {
background: var(--jb-nextBg);
}
.row.accepted {
color: var(--jb-textColor3);
}
.status {
flex: none;
}
.id {
flex: none;
min-width: 38px;
font-family: Consolas, Monaco, monospace;
color: var(--jb-textColor2);
}
.title {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
flex: none;
padding: 1px 8px;
border-radius: 10px;
background: var(--jb-tagBg);
color: var(--jb-label);
font-size: 12px;
}
.foot {
margin-top: 4px;
padding: 8px 12px 6px;
border-top: 1px solid var(--jb-dividerColor);
font-size: 12px;
color: var(--jb-textColor3);
}
</style>
+4
View File
@@ -7,6 +7,7 @@ import { useDarkTransition } from "shared/composables/darkTransition"
import { STORAGE_KEY } from "utils/constants" import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage" import storage from "utils/storage"
import { useUserStore } from "../store/user" import { useUserStore } from "../store/user"
import HelpButton from "../components/HelpButton.vue"
import type { MenuOption } from "naive-ui" import type { MenuOption } from "naive-ui"
const route = useRoute() const route = useRoute()
@@ -284,6 +285,9 @@ onMounted(async () => {
<template v-if="!isMobile">{{ userStore.user.username }}</template> <template v-if="!isMobile">{{ userStore.user.username }}</template>
</n-button> </n-button>
</n-dropdown> </n-dropdown>
<!-- 老师在后台改题时也得知道有人举手(前台顶栏在这里是卸载的)。
和前台一样放最末尾,出现、消失时别的按钮不挪 -->
<HelpButton size="small" />
</n-flex> </n-flex>
</n-layout-header> </n-layout-header>
<n-layout-content <n-layout-content