refactor(站点配置): WS 直接推契约字段名,去掉前端那层 snake→驼峰胶水
上一版是在前端加 toCamel 把 `enable_maxkb` 换成 `enableMaxkb`。但 snake_case
是 options 表从 Django 继承来的**存储格式**,只该活在库里;WS 这一跳两边都是
OJ2 自己写的,没理由让前端再维护一层换名。
后端改成广播 OPTION_KEYS 的 key(契约字段名)而不是 value(表列键),前端直接
按名字赋值。库里的列名一个字没动。
顺带清掉两处已经死掉的客户端→服务端配置推送:
- admin/setting/config.vue 保存后调 updateConfig("enable_maxkb", ...) 和
("submission_list_show_all", ...)。那条 ConfigWebSocket 从没 connect() 过,
send() 直接返回 false;就算连上了,服务端的消息处理也只认 ping / subscribe,
会回一个 error 帧。广播本来就由后端在 POST /admin/website 里做,八个键一个
不落,这两行纯属多余,且用的正是刚废掉的 snake 键。
- ConfigWebSocket.updateConfig 一并删掉,免得再有人调一个静默失败的方法。
这条通道是单向的,原地留注释说明。
验证:dev 栈 + headless chromium 走 CDP 抓 Network.webSocketFrameReceived。
后台保存配置后,页面收到的八条帧的 key 全是驼峰(websiteName / enableMaxkb
/ submissionListShowAll ...),且页面不刷新,document.title 和页头站点名当场
跟着变。小助手那四条路径重跑一遍照旧:未登录不加载、登录后出现、后台关掉当场
消失、开回来当场重现。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,18 +75,20 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
|
|||||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
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][])
|
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 写完,不再一个键一次往返
|
// 8 个键一条 upsert 写完,不再一个键一次往返
|
||||||
await db.insert(schema.optionsSysoptions).values(entries)
|
await db.insert(schema.optionsSysoptions).values(entries.map(({ key, value }) => ({ key, value })))
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: schema.optionsSysoptions.key,
|
target: schema.optionsSysoptions.key,
|
||||||
set: { value: sql`excluded.value` },
|
set: { value: sql`excluded.value` },
|
||||||
})
|
})
|
||||||
// 广播给所有开着页面的人,改完立刻生效不必刷新,对齐旧 push_config_update。
|
// 广播给所有开着页面的人,改完立刻生效不必刷新,对齐旧 push_config_update。
|
||||||
// 推的是 options 表里的 snake_case key。**前端 store 的字段是驼峰**,
|
//
|
||||||
// 换名在前端 configUpdate.ts 里做 —— 这里曾经注释成「前端用的就是这套键名」,
|
// 推的是**契约里的字段名**(websiteName),不是 options 表的列键(website_name)。
|
||||||
// 结果前端照着直接 `key in store` 判断,一条也命中不了,整个实时生效空转了很久。
|
// snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边
|
||||||
for (const entry of entries) await publishConfigUpdate(entry.key, entry.value)
|
// 都是新写的,没理由让前端再写一层换名胶水。曾经推 snake、前端拿它去比驼峰字段,
|
||||||
|
// 一条也命中不了,整个「改完不必刷新」空转了很久。
|
||||||
|
for (const entry of entries) await publishConfigUpdate(entry.field, entry.value)
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { OrphanTestCase, Server, WebsiteConfig } from "utils/types"
|
import type { OrphanTestCase, Server, WebsiteConfig } from "utils/types"
|
||||||
import { useConfigStore } from "shared/store/config"
|
import { useConfigStore } from "shared/store/config"
|
||||||
import { useConfigWebSocket } from "shared/composables/websocket"
|
|
||||||
import {
|
import {
|
||||||
deleteJudgeServer,
|
deleteJudgeServer,
|
||||||
editWebsite,
|
editWebsite,
|
||||||
@@ -22,7 +21,6 @@ import { useUserStore } from "shared/store/user"
|
|||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { updateConfig } = useConfigWebSocket()
|
|
||||||
|
|
||||||
// 确保只有登录用户才能使用WebSocket
|
// 确保只有登录用户才能使用WebSocket
|
||||||
watch(
|
watch(
|
||||||
@@ -158,10 +156,9 @@ async function saveWebsiteConfig() {
|
|||||||
message.success("网站配置保存成功")
|
message.success("网站配置保存成功")
|
||||||
getWebsiteConfig()
|
getWebsiteConfig()
|
||||||
configStore.getConfig()
|
configStore.getConfig()
|
||||||
|
// 广播由后端在 POST /admin/website 里做,八个键一个不落。
|
||||||
// 通过 WebSocket 广播配置变化,实现实时切换
|
// 这里原来还从客户端往 /ws/config 推两个键 —— 那条连接从没 connect() 过、
|
||||||
updateConfig("enable_maxkb", websiteConfig.enableMaxkb)
|
// send() 直接返回 false,而且服务端只认 ping/subscribe,本来就收不下。
|
||||||
updateConfig("submission_list_show_all", websiteConfig.submissionListShowAll)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTestcase(id?: string) {
|
async function deleteTestcase(id?: string) {
|
||||||
|
|||||||
@@ -6,35 +6,29 @@ import {
|
|||||||
} from "shared/composables/websocket"
|
} from "shared/composables/websocket"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后端推的是 options 表里的 snake_case 键(`enable_maxkb`),
|
* 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面
|
||||||
* store 里的字段是驼峰(`enableMaxkb`)—— 两边对不上,这里负责换。
|
* 的人不必刷新就生效。
|
||||||
*
|
*
|
||||||
* 原来是直接 `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() {
|
export function useConfigUpdate() {
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 处理 WebSocket 配置更新
|
|
||||||
const handleConfigUpdate = (data: ConfigUpdate) => {
|
const handleConfigUpdate = (data: ConfigUpdate) => {
|
||||||
const field = toCamel(data.key)
|
|
||||||
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
|
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
|
||||||
if (!(field in configStore.config)) return
|
if (!(data.key in configStore.config)) return
|
||||||
;(configStore.config as any)[field] = data.value
|
;(configStore.config as any)[data.key] = data.value
|
||||||
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
|
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
|
||||||
if (field === "websiteName") document.title = data.value
|
if (data.key === "websiteName") document.title = data.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 WebSocket - handler 会在 onMounted 时自动添加
|
// 初始化 WebSocket - handler 会在 onMounted 时自动添加
|
||||||
const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate)
|
const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate)
|
||||||
|
|
||||||
// 监听登录状态变化
|
// 监听登录状态变化。后端 /ws/config 要求会话,没登录连不上;
|
||||||
|
// 没登录的人下次刷新页面时通过 getConfig() 拿到新配置
|
||||||
watch(
|
watch(
|
||||||
() => userStore.isAuthed,
|
() => userStore.isAuthed,
|
||||||
(isAuthed) => {
|
(isAuthed) => {
|
||||||
|
|||||||
@@ -513,17 +513,9 @@ class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> {
|
|||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||||
super({ url: `${protocol}//${window.location.host}/ws/config` })
|
super({ url: `${protocol}//${window.location.host}/ws/config` })
|
||||||
}
|
}
|
||||||
|
// 这条通道是**单向**的:只收后端广播。服务端的消息处理只认 ping / subscribe,
|
||||||
/**
|
// 客户端往这里推 config_update 会被回一个 error 帧 —— 别再加发送方法。
|
||||||
* 发送配置更新
|
// 配置变更走 POST /admin/website,由后端广播给所有人。
|
||||||
*/
|
|
||||||
updateConfig(key: string, value: any) {
|
|
||||||
this.send({
|
|
||||||
type: "config_update",
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -548,7 +540,6 @@ export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
|
|||||||
return {
|
return {
|
||||||
connect: () => ws.connect(),
|
connect: () => ws.connect(),
|
||||||
disconnect: () => ws.disconnect(),
|
disconnect: () => ws.disconnect(),
|
||||||
updateConfig: (key: string, value: any) => ws.updateConfig(key, value),
|
|
||||||
status: ws.status,
|
status: ws.status,
|
||||||
addHandler: (h: MessageHandler<ConfigUpdate>) => ws.addHandler(h),
|
addHandler: (h: MessageHandler<ConfigUpdate>) => ws.addHandler(h),
|
||||||
removeHandler: (h: MessageHandler<ConfigUpdate>) => ws.removeHandler(h),
|
removeHandler: (h: MessageHandler<ConfigUpdate>) => ws.removeHandler(h),
|
||||||
|
|||||||
Reference in New Issue
Block a user