fix(站点配置): WebSocket 广播的 key 是 snake,store 字段是驼峰,实时生效一直空转

后端推的是 options 表里的 snake_case 键:

    {"type":"config_update","key":"enable_maxkb","value":false}

前端 configUpdate.ts 收到后写的是 `if (data.key in configStore.config)`,而
store 里的字段是驼峰(enableMaxkb)。这个 `in` 判断**永远是 false**,一条配置
都写不进去 —— 站点名称、页脚、班级名单、允许注册、提交列表看全部、知识库挂件,
全部都要刷新才生效,且没有任何报错。`conf.ts` 里那句「前端 configStore.config
用的就是这套键名」是写错的,八成就是照着它写的这段代码。

加一层 snake → 驼峰的转换。八个键都符合通用规则,所以用正则转而不是列表,
将来加键不用改这里;转完认不出来的键直接忽略,别把 store 撑出野字段。
站点改名时顺便同步 document.title —— getConfig() 里本来就是这么设的,
只更新页面里那份、标签页还是旧名字说不过去。

顺带把 MaxKB 挂件那块理干净:

- 加载条件改成「已登录 且 后台开关打开」。挂件本来就要登录才能用,原来对匿名
  访客也照样注入。
- 删掉它自己那条 /ws/config 连接和 handleConfigUpdate。store 修好之后,
  原有的 watch 自然就跟着动了;多开一条连接是重复的,而且没登录时会撞 401
  (后端 /ws/config 要求会话),控制台常年一条「[WebSocket] 连接错误」就是它。

匿名访客不做实时生效:/ws/config 保持要求登录,不对外开不鉴权的 WS 端点。
没登录的人下次刷新页面时拿到新配置。

验证:起 dev 栈,用一个假的 MaxKB embed 端点(返回建出 #maxkb-chat-button 的
脚本)顶掉真实地址,headless chromium 走 CDP 看 DOM:

    ① 开关=开、未登录        → 挂件不在,script 标签 0
    ② 开关=开、已登录        → 挂件在,  script 标签 1
    ③ 后台关掉(页面不刷新)  → 挂件消失,script 标签 0
    ④ 后台开回来(不刷新)    → 挂件重现,script 标签 1

另外把其余三条 WS 通道的键名也对了一遍,没有同类问题:submission_update
(submission_id / time_cost / memory_cost / err_info)两边都是 snake;
flowchart 的 criteriaDetails 两边都是驼峰且 handler 没读它;成就通知的字段
全是单词,不涉及大小写。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 00:34:51 -06:00
parent 8600f8143a
commit 0f34d67cc8
3 changed files with 54 additions and 93 deletions

View File

@@ -83,7 +83,9 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
set: { value: sql`excluded.value` }, set: { value: sql`excluded.value` },
}) })
// 广播给所有开着页面的人,改完立刻生效不必刷新,对齐旧 push_config_update。 // 广播给所有开着页面的人,改完立刻生效不必刷新,对齐旧 push_config_update。
// 推的是 options 表里的 snake_case key —— 前端 configStore.config 用的就是这套键名。 // 推的是 options 表里的 snake_case key。**前端 store 的字段是驼峰**
// 换名在前端 configUpdate.ts 里做 —— 这里曾经注释成「前端用的就是这套键名」,
// 结果前端照着直接 `key in store` 判断,一条也命中不了,整个实时生效空转了很久。
for (const entry of entries) await publishConfigUpdate(entry.key, entry.value) for (const entry of entries) await publishConfigUpdate(entry.key, entry.value)
return success(c, null) return success(c, null)
}) })

View File

@@ -5,17 +5,30 @@ import {
type ConfigUpdate, type ConfigUpdate,
} from "shared/composables/websocket" } from "shared/composables/websocket"
/**
* 后端推的是 options 表里的 snake_case 键(`enable_maxkb`
* store 里的字段是驼峰(`enableMaxkb`)—— 两边对不上,这里负责换。
*
* 原来是直接 `data.key in configStore.config`snake 键永远命中不了驼峰字段,
* 于是**一条配置都写不进去**,整个「改完不必刷新」是空转的:站点名称、页脚、
* 班级名单、允许注册、提交列表看全部、知识库挂件,全都要刷新才生效。
*/
function toCamel(key: string) {
return key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
}
export function useConfigUpdate() { export function useConfigUpdate() {
const configStore = useConfigStore() const configStore = useConfigStore()
const userStore = useUserStore() const userStore = useUserStore()
// 处理 WebSocket 配置更新 // 处理 WebSocket 配置更新
const handleConfigUpdate = (data: ConfigUpdate) => { const handleConfigUpdate = (data: ConfigUpdate) => {
// 更新全局配置 - 使用响应式方式 const field = toCamel(data.key)
if (data.key in configStore.config) { // 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
// 直接修改 ref 的值来触发响应式更新 if (!(field in configStore.config)) return
;(configStore.config as any)[data.key] = data.value ;(configStore.config as any)[field] = data.value
} // getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
if (field === "websiteName") document.title = data.value
} }
// 初始化 WebSocket - handler 会在 onMounted 时自动添加 // 初始化 WebSocket - handler 会在 onMounted 时自动添加

View File

@@ -1,126 +1,72 @@
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import {
useConfigWebSocket,
type ConfigUpdate,
} from "shared/composables/websocket"
/**
* MaxKB 知识库挂件的加载 / 卸载。
*
* 两个前提同时满足才加载:**已登录**(挂件本身就要登录才能用)且**后台开关打开**。
* 任一条件变假就把脚本和它建出来的 DOM 一起撤掉。
*
* 脚本**只在这里注入**。原来 vite 有个 inject-maxkb 插件把 <script src> 写进
* index.htmlVue 还没启动就已经加载执行了,后台开关根本拦不住 —— 别再加回去。
*
* 配置的实时推送不归这里管App.vue 的 useConfigUpdate() 收 /ws/config 的广播、
* 写进 configStore下面那个 watch 自然就跟着动。这里再开一条连接是重复的,
* 而且没登录时会撞 401后端 /ws/config 要求会话)。
*/
export function useMaxKB() { export function useMaxKB() {
const configStore = useConfigStore() const configStore = useConfigStore()
const userStore = useUserStore() const userStore = useUserStore()
const isLoaded = ref(false) const isLoaded = ref(false)
// 处理 WebSocket 配置更新 - 只处理 MaxKB 相关 const url = import.meta.env.PUBLIC_MAXKB_URL
const handleConfigUpdate = (data: ConfigUpdate) => {
if (data.key === "enable_maxkb") {
if (data.value) {
loadMaxKBScript()
} else {
removeMaxKBScript()
}
}
}
// 初始化 WebSocket
const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate)
// 监听登录状态变化
watch(
() => userStore.isAuthed,
(isAuthed) => {
if (isAuthed) {
connect()
} else {
disconnect()
}
},
{ immediate: true },
)
const loadMaxKBScript = () => { const loadMaxKBScript = () => {
const { enableMaxkb } = configStore.config // 没配地址就什么都不做。这道判断原来在 vite 插件里,插件删掉之后挪过来,
// 否则会拿 undefined 当 src 去请求一个 /undefined
if (!url) return
if (!userStore.isAuthed) return
if (!configStore.config.enableMaxkb) return
if (!enableMaxkb) { if (document.querySelector(`script[src="${url}"]`)) {
return
}
// 没配地址就什么都不做。这道判断原来在 vite 的 inject-maxkb 插件里,
// 插件删掉之后得挪过来,否则会拿 undefined 当 src 去请求一个 /undefined
if (!import.meta.env.PUBLIC_MAXKB_URL) {
return
}
const existingScript = document.querySelector(
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`,
)
if (existingScript) {
isLoaded.value = true isLoaded.value = true
return return
} }
// 创建并插入脚本标签
const script = document.createElement("script") const script = document.createElement("script")
script.src = import.meta.env.PUBLIC_MAXKB_URL script.src = url
script.async = true script.async = true
script.defer = true script.defer = true
script.onload = () => { script.onload = () => {
isLoaded.value = true isLoaded.value = true
} }
script.onerror = () => { script.onerror = () => {
console.error("Failed to load MaxKB script") console.error("Failed to load MaxKB script")
} }
document.head.appendChild(script) document.head.appendChild(script)
} }
const removeMaxKBScript = () => { const removeMaxKBScript = () => {
// 把 script 也删除 if (!url) return
const script = document.querySelector( document.querySelector(`script[src="${url}"]`)?.remove()
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`, // 脚本删掉只是不再重复执行,它已经建出来的挂件 DOM 得自己收拾
) const removeElements = () => {
if (script) { document.querySelectorAll('[id^="maxkb-"]').forEach((el) => el.remove())
script.remove()
} }
// 等待DOM加载完成后删除所有id以"maxkb-"开头的元素
const removeMaxKBElements = () => {
// 查找所有id以"maxkb-"开头的元素
const elements = document.querySelectorAll('[id^="maxkb-"]')
elements.forEach((element) => {
element.remove()
})
}
// 如果DOM已经加载完成直接执行删除
if (document.readyState === "complete") { if (document.readyState === "complete") {
removeMaxKBElements() removeElements()
} else { } else {
// 等待DOM加载完成 window.addEventListener("load", removeElements, { once: true })
window.addEventListener("load", removeMaxKBElements, { once: true })
} }
// 移除MaxKB脚本标签
const existingScript = document.querySelector(
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`,
)
if (existingScript) {
existingScript.remove()
}
// 重置加载状态
isLoaded.value = false isLoaded.value = false
} }
// 连接 WebSocket // 登录态和开关任一变化都重新判一次。immediate 是必须的:
onMounted(() => { // 页面加载时这两个值就已经定了,不会再触发一次变化
connect()
})
watch( watch(
() => configStore.config.enableMaxkb, () => [userStore.isAuthed, configStore.config.enableMaxkb] as const,
(enabled) => { ([authed, enabled]) => {
if (enabled) { if (authed && enabled) {
loadMaxKBScript() loadMaxKBScript()
} else { } else {
removeMaxKBScript() removeMaxKBScript()