Files
OJ2/apps/web/src/shared/components/Header.vue
T
xuyueandClaude Opus 5.5 80106eaa04 feat(课堂): 老师的课堂看板——布置这节课的题、看全班谁还没开始谁卡住了,布置的题直接出现在学生首页
2025 秋平均每节课有 14 个平时在用的学生一道都没交(三分之一),交了 3 次以上还没过的
平均不到 1 个;而老师原来只能一道题一道题打开统计弹窗看。

- 新表 class_lesson(迁移 0023,仅建表):一个班一天一行,存老师布置的 problem.id 与顺序;
  day 存东八区日历日文本,created_by CASCADE
- GET /classroom/board:这个班今天的题 × 全班学生(通过与否不限今天、今天交了几次、
  最后提交时间);不给班级时猜最近两小时提交人数最多的班,不记老师上次选的
- PUT /classroom/lesson:按展示题号布置,对不上的题号点名拒绝,空数组 = 清掉
- /me/class-activity 优先返回老师布置的题(source = teacher),否则照旧推断
- 前端 /classroom:没交过的名单置顶,按 没交过 → 卡住 → 在做 → 做完 排序,15 秒刷新;
  投影模式只显示每题完成人数、不出名字;用户菜单给老师加入口
- 学生首页:老师布置的标题写「今天老师布置的题」,没人交时不显示 0/0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:37:59 +08:00

347 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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 { useProblemJump } from "shared/composables/problemJump"
import { useAuthModalStore } from "shared/store/authModal"
import { useCollabStore } from "shared/store/collab"
import { useScreenModeStore } from "shared/store/screenMode"
import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user"
const userStore = useUserStore()
const configStore = useConfigStore()
const collabStore = useCollabStore()
const authStore = useAuthModalStore()
const screenModeStore = useScreenModeStore()
const route = useRoute()
const router = useRouter()
const { isMobile, isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const { isDark, toggleDark } = useDarkTransition()
/**
* 求助的入口收进姓名下拉里,顶栏只留姓名按钮上的角标 —— 老师不用展开菜单
* 也能看见有没有人举手。窄屏同样给:接单之后要在弹框里替学生写代码,那件事
* 确实只有桌面端好使,但「有没有人在等」是宽度多少都得知道的。
*/
const pendingHelpCount = computed(() => (collabStore.isTeacher ? collabStore.pendingCount : 0))
// 从 store 中获取屏幕模式状态
const { screenMode } = storeToRefs(screenModeStore)
const names = [
"man-with-chinese-cap-1",
"cat-face",
"china",
"chicken",
"eyes",
"elephant",
"hear-no-evil-monkey",
"panda-face",
"penguin-1",
"rooster",
"star-struck-1",
"tomato",
"rocket",
"sparkles",
"money-bag",
"ghost",
"game-dice",
"ewe-1",
"artist-palette",
"baby-bottle",
]
function getRandomAvatar() {
const name = names[Math.floor(Math.random() * names.length)]
return `streamline-emojis:${name}`
}
const avatar = ref(getRandomAvatar())
/**
* 顶栏的题号框:老师报完题号,学生在哪一页都能直接敲,不用先回首页或题目列表。
* 只给桌面端 —— 窄屏顶栏已经挤满了,手机上从首页的搜索框走同一套逻辑。
*/
const { jump, jumping } = useProblemJump()
const jumpKeyword = ref("")
async function handleJump() {
await jump(jumpKeyword.value)
jumpKeyword.value = ""
}
const envVersion = computed(() => {
if (import.meta.env.PUBLIC_ENV === "test") {
return "测试版"
} else if (import.meta.env.PUBLIC_ENV === "dev") {
return "开发版"
}
return ""
})
/**
* 站名后面括号里的东西:环境名 + 演示中。
*
* 演示模式除了下拉里那行「退出演示」再没有别的痕迹,而它是存在 localStorage 里
* 的,刷新、关标签页都还在,只有退出登录才清 —— 不在这儿常驻标一下,很容易
* 投屏完忘了退,第二天纳闷后台入口怎么没了。
*/
const titleTags = computed(() =>
[envVersion.value, userStore.demoMode ? "演示中" : ""].filter(Boolean),
)
// 一级路径就是菜单 key,对不上的页面(/user、/setting、/achievement 等)
// 自然没有一项亮着。根路径没登录时就是题目列表,登录了是个人首页,不亮任何一项
const active = computed(() => route.path.split("/")[1] || (userStore.isAuthed ? "" : "problem"))
async function handleLogout() {
await userStore.signOut()
// 整页跳转而不是 router.replace:AI 分析、学情小结这些 store 还装着这个人的数据,
// 机房下一个学生坐下来就能看到。重载一次把内存清干净,比挨个 reset 可靠。
window.location.replace("/")
}
function handleToggleDemoMode() {
const entering = !userStore.demoMode
userStore.toggleDemoMode()
// 进入演示模式时若正停在后台页面,当前界面已经失去权限,必须主动退出去
if (entering && route.path.startsWith("/admin")) {
router.push("/")
}
}
function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 20 })
}
function learnLink(type: "python" | "c") {
return `/learn/${type}/${learnStep.value[type].toString().padStart(2, "0")}`
}
const menus = computed<MenuOption[]>(() => [
{
label: "自学",
key: "learn",
icon: renderIcon("fluent-emoji:books"),
children: [
{
label: () => h(RouterLink, { to: learnLink("python") }, { default: () => "Python" }),
key: "learn-python",
},
{
label: () => h(RouterLink, { to: learnLink("c") }, { default: () => "C语言" }),
key: "learn-c",
},
],
},
{
label: () => h(RouterLink, { to: "/problem" }, { default: () => "题目" }),
key: "problem",
icon: renderIcon("fluent-emoji:memo"),
},
{
label: () => h(RouterLink, { to: "/problemset" }, { default: () => "题单" }),
key: "problemset",
icon: renderIcon("fluent-emoji:clipboard"),
},
{
label: () => h(RouterLink, { to: "/submission" }, { default: () => "提交" }),
key: "submission",
icon: renderIcon("fluent-emoji:inbox-tray"),
show: userStore.showSubmissions,
},
{
label: () => h(RouterLink, { to: "/contest" }, { default: () => "比赛" }),
key: "contest",
icon: renderIcon("fluent-emoji:chequered-flag"),
},
{
label: () => h(RouterLink, { to: "/rank" }, { default: () => "排名" }),
key: "rank",
icon: renderIcon("fluent-emoji:trophy"),
},
{
label: () => h(RouterLink, { to: "/announcement" }, { default: () => "公告" }),
key: "announcement",
icon: renderIcon("fluent-emoji:loudspeaker"),
},
{
label: () =>
h(
RouterLink,
{ to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list" },
{ default: () => "后台" },
),
show: userStore.isAdminRole,
key: "admin",
icon: renderIcon("fluent-emoji:gear"),
},
])
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
{
label: pendingHelpCount.value ? `课堂求助(${pendingHelpCount.value})` : "课堂求助",
key: "help",
show: collabStore.isTeacher,
icon: renderIcon("streamline-emojis:raising-hands-2"),
props: {
onClick: () => (collabStore.helpPanelOpen = true),
},
},
{
label: "课堂看板",
key: "classroom-board",
show: userStore.isTeacherOrAbove,
icon: renderIcon("fluent-emoji:school"),
props: {
onClick: () => router.push("/classroom"),
},
},
{
label: "我的主页",
key: "home",
icon: renderIcon("streamline-ultimate-color:newspaper-fold"),
props: {
onClick: () => router.push("/user"),
},
},
{
label: "我的成就",
key: "achievement",
icon: renderIcon("streamline-ultimate-color:award-medal-4"),
props: {
onClick: () => router.push("/achievement"),
},
},
{
label: "我的提交",
key: "status",
icon: renderIcon("streamline-ultimate-color:analytics-bars-3d"),
props: {
onClick: () => router.push("/submission?myself=1"),
},
},
{
label: "我的设置",
key: "setting",
icon: renderIcon("streamline-emojis:musical-score"),
props: {
onClick: () => router.push("/setting"),
},
},
{
label: "智能分析",
key: "ai-analysis",
icon: renderIcon("vscode-icons:file-type-gemini"),
props: {
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" },
{
label: "退出",
key: "logout",
icon: renderIcon("streamline-ultimate-color:coffee-cold"),
props: { onClick: handleLogout },
},
])
function goHome() {
router.push("/")
}
</script>
<template>
<n-flex justify="space-between" align="center">
<n-flex align="center">
<!-- 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" />
</div>
</n-flex>
<n-flex align="center">
<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>
</n-button>
</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>
<n-button
v-if="isDesktop && (route.name === 'problem' || route.name === 'contest problem')"
@click="() => screenModeStore.switchScreenMode()"
>
{{ screenMode }}
</n-button>
<div v-if="userStore.isFinished">
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
<n-badge :value="pendingHelpCount" :max="99">
<n-button>
<Icon :icon="avatar" height="20"></Icon>
<span style="padding-left: 8px">
{{ userStore.user!.username }}
</span>
</n-button>
</n-badge>
</n-dropdown>
<n-flex align="center" v-else>
<n-button secondary type="primary" @click="authStore.openLoginModal()"> 登录 </n-button>
<n-button
tertiary
v-if="configStore.config?.allowRegister"
@click="authStore.openSignupModal()"
>
注册
</n-button>
</n-flex>
</div>
<n-button :bordered="false" circle @click="toggleDark">
<template #icon>
<Icon v-if="isDark" icon="fluent-emoji:sun"></Icon>
<Icon v-else icon="fluent-emoji:full-moon"></Icon>
</template>
</n-button>
</n-flex>
</n-flex>
</template>
<style scoped>
.title {
font-size: 18px;
}
.jump {
width: 160px;
}
</style>