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:
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 时自动添加
|
||||||
|
|||||||
@@ -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.html,Vue 还没启动就已经加载执行了,后台开关根本拦不住 —— 别再加回去。
|
||||||
|
*
|
||||||
|
* 配置的实时推送不归这里管: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()
|
||||||
|
|||||||
Reference in New Issue
Block a user