diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 23b3f81..5ee80e8 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -75,18 +75,20 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => { return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") } const entries = (Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]) - .map(([field, key]) => ({ key, value: parsed.data[field] })) + .map(([field, key]) => ({ field, key, value: parsed.data[field] })) // 8 个键一条 upsert 写完,不再一个键一次往返 - await db.insert(schema.optionsSysoptions).values(entries) + await db.insert(schema.optionsSysoptions).values(entries.map(({ key, value }) => ({ key, value }))) .onConflictDoUpdate({ target: schema.optionsSysoptions.key, set: { value: sql`excluded.value` }, }) // 广播给所有开着页面的人,改完立刻生效不必刷新,对齐旧 push_config_update。 - // 推的是 options 表里的 snake_case key。**前端 store 的字段是驼峰**, - // 换名在前端 configUpdate.ts 里做 —— 这里曾经注释成「前端用的就是这套键名」, - // 结果前端照着直接 `key in store` 判断,一条也命中不了,整个实时生效空转了很久。 - for (const entry of entries) await publishConfigUpdate(entry.key, entry.value) + // + // 推的是**契约里的字段名**(websiteName),不是 options 表的列键(website_name)。 + // snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边 + // 都是新写的,没理由让前端再写一层换名胶水。曾经推 snake、前端拿它去比驼峰字段, + // 一条也命中不了,整个「改完不必刷新」空转了很久。 + for (const entry of entries) await publishConfigUpdate(entry.field, entry.value) return success(c, null) }) diff --git a/apps/web/src/admin/setting/config.vue b/apps/web/src/admin/setting/config.vue index 1c2187f..630300a 100644 --- a/apps/web/src/admin/setting/config.vue +++ b/apps/web/src/admin/setting/config.vue @@ -8,7 +8,6 @@ import { import { parseTime } from "utils/functions" import type { OrphanTestCase, Server, WebsiteConfig } from "utils/types" import { useConfigStore } from "shared/store/config" -import { useConfigWebSocket } from "shared/composables/websocket" import { deleteJudgeServer, editWebsite, @@ -22,7 +21,6 @@ import { useUserStore } from "shared/store/user" const message = useMessage() const configStore = useConfigStore() const userStore = useUserStore() -const { updateConfig } = useConfigWebSocket() // 确保只有登录用户才能使用WebSocket watch( @@ -158,10 +156,9 @@ async function saveWebsiteConfig() { message.success("网站配置保存成功") getWebsiteConfig() configStore.getConfig() - - // 通过 WebSocket 广播配置变化,实现实时切换 - updateConfig("enable_maxkb", websiteConfig.enableMaxkb) - updateConfig("submission_list_show_all", websiteConfig.submissionListShowAll) + // 广播由后端在 POST /admin/website 里做,八个键一个不落。 + // 这里原来还从客户端往 /ws/config 推两个键 —— 那条连接从没 connect() 过、 + // send() 直接返回 false,而且服务端只认 ping/subscribe,本来就收不下。 } async function deleteTestcase(id?: string) { diff --git a/apps/web/src/shared/composables/configUpdate.ts b/apps/web/src/shared/composables/configUpdate.ts index 9efbd75..15e5eb8 100644 --- a/apps/web/src/shared/composables/configUpdate.ts +++ b/apps/web/src/shared/composables/configUpdate.ts @@ -6,35 +6,29 @@ import { } from "shared/composables/websocket" /** - * 后端推的是 options 表里的 snake_case 键(`enable_maxkb`), - * store 里的字段是驼峰(`enableMaxkb`)—— 两边对不上,这里负责换。 + * 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面 + * 的人不必刷新就生效。 * - * 原来是直接 `data.key in configStore.config`,snake 键永远命中不了驼峰字段, - * 于是**一条配置都写不进去**,整个「改完不必刷新」是空转的:站点名称、页脚、 - * 班级名单、允许注册、提交列表看全部、知识库挂件,全都要刷新才生效。 + * 广播里的 key 就是 store 的字段名(后端推的是契约字段名,不是 options 表那套 + * snake_case 列键),所以这里直接按名字赋值,不需要换名。 */ -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) => { - const field = toCamel(data.key) // 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段 - if (!(field in configStore.config)) return - ;(configStore.config as any)[field] = data.value + if (!(data.key in configStore.config)) return + ;(configStore.config as any)[data.key] = data.value // getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份 - if (field === "websiteName") document.title = data.value + if (data.key === "websiteName") document.title = data.value } // 初始化 WebSocket - handler 会在 onMounted 时自动添加 const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate) - // 监听登录状态变化 + // 监听登录状态变化。后端 /ws/config 要求会话,没登录连不上; + // 没登录的人下次刷新页面时通过 getConfig() 拿到新配置 watch( () => userStore.isAuthed, (isAuthed) => { diff --git a/apps/web/src/shared/composables/websocket.ts b/apps/web/src/shared/composables/websocket.ts index 7948fa0..bbda82b 100644 --- a/apps/web/src/shared/composables/websocket.ts +++ b/apps/web/src/shared/composables/websocket.ts @@ -513,17 +513,9 @@ class ConfigWebSocket extends BaseWebSocket { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" super({ url: `${protocol}//${window.location.host}/ws/config` }) } - - /** - * 发送配置更新 - */ - updateConfig(key: string, value: any) { - this.send({ - type: "config_update", - key, - value, - }) - } + // 这条通道是**单向**的:只收后端广播。服务端的消息处理只认 ping / subscribe, + // 客户端往这里推 config_update 会被回一个 error 帧 —— 别再加发送方法。 + // 配置变更走 POST /admin/website,由后端广播给所有人。 } /** @@ -548,7 +540,6 @@ export function useConfigWebSocket(handler?: MessageHandler) { return { connect: () => ws.connect(), disconnect: () => ws.disconnect(), - updateConfig: (key: string, value: any) => ws.updateConfig(key, value), status: ws.status, addHandler: (h: MessageHandler) => ws.addHandler(h), removeHandler: (h: MessageHandler) => ws.removeHandler(h),