feat(布置作业): 课上老师改了作业,学生页面上弹出改了哪些题,点「刷新」就拿到新题单
Deploy / deploy (push) Canceled after 0s

看板「改 / 新的一节 / 清掉」和后台课堂作业的增改删,前后比较学生看到的那一节,
真变了才按班推 lesson_update(加了、去掉了、语言、顺序),走学生的 /ws/config,老师不收。
没点刷新又改会合并成一条。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 21:14:52 +08:00
1 parent 0fd94ee8bf
commit df8af40d61
12 files changed
+344 -14

No files matched your search

@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { LessonUpdate } from '@oj2/contract'
import { pendingLessonUpdate } from 'shared/composables/lessonUpdate'
import { useThemeVars } from 'naive-ui'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
/**
* 课上老师改了这节课的题:顶上居中一张卡,写清改了哪些,点「刷新」重新载入页面拿新的题单。
* 不自己消失 —— 学生可能正低头写代码,几秒就没的提示等于没提示。代码草稿边写边存,刷新不丢。
*/
const update = pendingLessonUpdate
const theme = useThemeVars()
type Item = LessonUpdate['problems'][number]
function names(list: Item[]) {
return list.map((item) => `${item.problemDisplayId} ${item.title}`).join('、')
}
function languageText(value: LessonUpdate) {
return value.language ? LANGUAGE_SHOW_VALUE[value.language] : ''
}
const title = computed(() => {
const value = update.value
if (!value) return ''
if (value.kind === 'assigned') return '老师布置了这节课的题'
if (value.kind === 'cleared') return '老师清掉了这节课的题'
return '老师改了这节课的题'
})
/** 一行一件事:加了哪些、去掉了哪些、语言、顺序 */
const lines = computed(() => {
const value = update.value
if (!value) return []
if (value.kind === 'cleared') return []
if (value.kind === 'assigned') {
const language = languageText(value)
return [{ label: '', text: names(value.problems) }, ...(language ? [{ label: '用', text: language }] : [])]
}
const result: { label: string; text: string }[] = []
if (value.added.length) result.push({ label: '加了', text: names(value.added) })
if (value.removed.length) result.push({ label: '去掉了', text: names(value.removed) })
if (value.languageChanged) result.push({ label: '语言', text: value.language ? `改成 ${languageText(value)}` : '不限了' })
if (value.reordered) result.push({ label: '', text: '换了题目的顺序' })
return result
})
function refresh() {
location.reload()
}
function dismiss() {
update.value = null
}
</script>
<template>
<Transition name="drop">
<div v-if="update" class="notice" role="alert" :style="{ background: theme.cardColor, borderColor: theme.primaryColor }">
<div class="title">{{ title }}</div>
<div v-for="(line, index) in lines" :key="index" class="line">
<span v-if="line.label" class="label">{{ line.label }}</span>
<span>{{ line.text }}</span>
</div>
<div class="actions">
<n-button quaternary @click="dismiss">先不刷新</n-button>
<n-button type="primary" @click="refresh">刷新</n-button>
</div>
</div>
</Transition>
</template>
<style scoped>
.notice {
position: fixed;
/* 顶栏下面、居中:右上角是成就提示的位置,两个一起来不能叠上 */
top: 72px;
left: 50%;
transform: translateX(-50%);
z-index: 3000;
width: min(480px, calc(100vw - 32px));
padding: 16px 20px;
border-radius: var(--oj-radius);
border: 2px solid;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
font-size: var(--oj-fs-body);
}
.title {
font-size: var(--oj-fs-h2);
font-weight: 700;
margin-bottom: 8px;
}
.line {
line-height: 1.7;
}
.label {
font-weight: 600;
margin-right: 8px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.drop-enter-active,
.drop-leave-active {
transition: all 0.3s ease;
}
.drop-enter-from,
.drop-leave-to {
opacity: 0;
transform: translate(-50%, -16px);
}
</style>
@@ -1,6 +1,7 @@
import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user'
import { useConfigWebSocket, type ConfigUpdate } from 'shared/composables/websocket'
import { receiveLessonUpdate } from 'shared/composables/lessonUpdate'
import { useConfigWebSocket, type ConfigChannelMessage } from 'shared/composables/websocket'
/**
* 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面
@@ -13,7 +14,12 @@ export function useConfigUpdate() {
const configStore = useConfigStore()
const userStore = useUserStore()
const handleConfigUpdate = (data: ConfigUpdate) => {
const handleConfigUpdate = (data: ConfigChannelMessage) => {
// 同一条连接上的班级广播:老师改了这节课的题(只推给学生,见后端 classTopicOf)
if (data.type === 'lesson_update') {
receiveLessonUpdate(data)
return
}
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
if (!(data.key in configStore.config)) return
;(configStore.config as unknown as Record<string, unknown>)[data.key] = data.value
@@ -0,0 +1,40 @@
import type { LessonUpdate } from '@oj2/contract'
/**
* 课上老师改了这节课的题(/ws/config 推来的 lesson_update),等学生点「刷新」。
* useConfigUpdate 收、LessonUpdateNotice 显示,两头不在一个组件里,所以放模块级。
*/
export const pendingLessonUpdate = shallowRef<LessonUpdate | null>(null)
type Item = LessonUpdate['problems'][number]
function minus(list: Item[], other: Item[]) {
const ids = new Set(other.map((item) => item.problemDisplayId))
return list.filter((item) => !ids.has(item.problemDisplayId))
}
/**
* 还没点刷新老师又改了一次:并成一条,免得第二条把第一次加的题盖掉。
* 刚布置(还没刷新)又改 → 仍算刚布置,题目按最新的列;两次「改」→ 加的、去掉的互相抵消后合起来
*/
export function receiveLessonUpdate(next: LessonUpdate) {
const prev = pendingLessonUpdate.value
if (!prev || next.kind !== 'changed' || prev.kind === 'cleared') {
pendingLessonUpdate.value = next
return
}
if (prev.kind === 'assigned') {
pendingLessonUpdate.value = { ...next, kind: 'assigned', added: [], removed: [], languageChanged: false, reordered: false }
return
}
const merged: LessonUpdate = {
...next,
added: [...minus(prev.added, next.removed), ...minus(next.added, prev.removed)],
removed: [...minus(prev.removed, next.added), ...minus(next.removed, prev.added)],
languageChanged: prev.languageChanged || next.languageChanged,
reordered: prev.reordered || next.reordered,
}
// 改回去了:等于没改
const nothing = !merged.added.length && !merged.removed.length && !merged.languageChanged && !merged.reordered
pendingLessonUpdate.value = nothing ? null : merged
}
+8 -3
View File
@@ -1,4 +1,4 @@
import { WS_AUTH_PROTOCOL } from '@oj2/contract'
import { WS_AUTH_PROTOCOL, type LessonUpdate } from '@oj2/contract'
import { createDiscreteApi } from 'naive-ui'
import { ref, getCurrentScope, onScopeDispose, type Ref } from 'vue'
@@ -559,14 +559,19 @@ export interface ConfigUpdate extends WebSocketMessage {
value: any
}
/**
* 这条通道上还有一种按班推的:老师改了这节课的题(学生才收得到,见契约 lessonUpdateSchema)
*/
export type ConfigChannelMessage = ConfigUpdate | LessonUpdate
/**
* 站点配置广播。这条通道是**单向**的:只收后端广播。服务端的消息处理只认
* ping / subscribe,客户端往这里推 config_update 会被回一个 error 帧 ——
* 别拿返回值里的 subscribe 往这条通道上发东西。配置变更走 POST /admin/website,
* 由后端广播给所有人。
*/
export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
return useChannel<ConfigUpdate>('/ws/config', handler)
export function useConfigWebSocket(handler?: MessageHandler<ConfigChannelMessage>) {
return useChannel<ConfigChannelMessage>('/ws/config', handler)
}
export interface CollabRequestItem {
+2
View File
@@ -4,6 +4,7 @@ import Header from '../components/Header.vue'
import Login from '../components/Login.vue'
import Signup from '../components/Signup.vue'
import AchievementToast from '../components/AchievementToast.vue'
import LessonUpdateNotice from '../components/LessonUpdateNotice.vue'
import { useAchievementStore } from 'shared/store/achievement'
import { useUserStore } from 'shared/store/user'
@@ -40,6 +41,7 @@ watch(
<Login />
<Signup />
<AchievementToast />
<LessonUpdateNotice />
<Beian />
</n-layout>
</template>