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