Compare commits

..

4 Commits

Author SHA1 Message Date
bc0b782443 update
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-08-27 00:42:39 -06:00
8ae14f055a 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>
2026-08-27 00:42:14 -06:00
0f34d67cc8 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>
2026-08-27 00:34:51 -06:00
8600f8143a fix(MaxKB): 「启用 MaxKB」开关在生产构建下拦不住脚本
vite 的 inject-maxkb 插件把 `<script src>` 写进 index.html 的 head,于是构建
产物在 Vue 启动之前就把第三方挂件拉下来执行了。App.vue 的 useMaxKB() 那条运行时
路径只能事后把标签和 `[id^="maxkb-"]` 的 DOM 删掉 —— 代码早跑完了。表现就是后台
把开关关掉、刷新,挂件的副作用照旧(其中之一是它自带的性能上报在每个页面抛
`Cannot read properties of undefined (reading 'startTime')`,堆栈全是 <anonymous>
因为它 bootstrap 时又 eval 了一层),而且关不掉。

改成只留运行时那一条路:

- 删掉 inject-maxkb 插件,index.html 不再带这个 script 标签。原地留注释说明
  为什么不能加回去。
- config store 的 enableMaxkb 默认值从 true 改成 false。默认 true 的话
  getConfig() 还没回来挂件就已经加载了,服务端说「关」依然晚一步。代价是配置
  拿不到时挂件不出现 —— 第三方脚本 fail closed 是对的方向。
- URL 为空的兜底从 vite 插件挪进 loadMaxKBScript,否则会拿 undefined 当 src。

验证:headless chromium 走 CDP 记录网络请求。

- dev(5173):enable_maxkb=false → 0 个请求、0 个 script 标签;改成 true →
  发出 embed 请求、head 里 1 个标签。
- 生产构建(bun run build 后把 dist 静态跑起来、/api 转发到后端):同样两种
  配置下分别是 0 和 1。构建出来的 index.html 里已经没有 maxkb 的 script 标签,
  只剩原本就在源文件里的那行 maxkbMaskTip。

MaxKB 脚本自身那个 startTime 报错是它自己的 bug,本次只保证「关得掉」,没去
动第三方代码。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:26:54 -06:00
8 changed files with 84 additions and 142 deletions

View File

@@ -75,16 +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 —— 前端 configStore.config 用的就是这套键名。
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)
})

View File

@@ -45,15 +45,15 @@
<n-spin :show="loadingDetail">
<div v-if="detail" class="detail">
<n-descriptions :column="2" bordered size="small" class="meta">
<n-descriptions-item label="用户">{{
detail.username
}}</n-descriptions-item>
<n-descriptions-item label="班级">{{
detail.className || "-"
}}</n-descriptions-item>
<n-descriptions-item label="时间" :span="2">{{
parseTime(detail.createTime, "YYYY-MM-DD HH:mm:ss")
}}</n-descriptions-item>
<n-descriptions-item label="用户">
{{ detail.username }}
</n-descriptions-item>
<n-descriptions-item label="班级">
{{ detail.className || "-" }}
</n-descriptions-item>
<n-descriptions-item label="时间">
{{ parseTime(detail.createTime, "YYYY-MM-DD HH:mm:ss") }}
</n-descriptions-item>
</n-descriptions>
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
<MdPreview :model-value="detail.analysis" />

View File

@@ -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) {

View File

@@ -5,23 +5,30 @@ import {
type ConfigUpdate,
} from "shared/composables/websocket"
/**
* 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面
* 的人不必刷新就生效。
*
* 广播里的 key 就是 store 的字段名(后端推的是契约字段名,不是 options 表那套
* snake_case 列键),所以这里直接按名字赋值,不需要换名。
*/
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
}
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
if (!(data.key in configStore.config)) return
;(configStore.config as any)[data.key] = data.value
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
if (data.key === "websiteName") document.title = data.value
}
// 初始化 WebSocket - handler 会在 onMounted 时自动添加
const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate)
// 监听登录状态变化
// 监听登录状态变化。后端 /ws/config 要求会话,没登录连不上;
// 没登录的人下次刷新页面时通过 getConfig() 拿到新配置
watch(
() => userStore.isAuthed,
(isAuthed) => {

View File

@@ -1,121 +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.htmlVue 还没启动就已经加载执行了,后台开关根本拦不住 —— 别再加回去。
*
* 配置的实时推送不归这里管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
}
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()

View File

@@ -513,17 +513,9 @@ class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> {
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<ConfigUpdate>) {
return {
connect: () => ws.connect(),
disconnect: () => ws.disconnect(),
updateConfig: (key: string, value: any) => ws.updateConfig(key, value),
status: ws.status,
addHandler: (h: MessageHandler<ConfigUpdate>) => ws.addHandler(h),
removeHandler: (h: MessageHandler<ConfigUpdate>) => ws.removeHandler(h),

View File

@@ -10,7 +10,10 @@ export const useConfigStore = defineStore("config", () => {
submissionListShowAll: true,
allowRegister: false,
classList: [],
enableMaxkb: true,
// 默认 false这是给 useMaxKB 用的开关,而 MaxKB 是第三方脚本。
// 默认 true 的话getConfig() 还没回来挂件就已经加载执行了,服务端配置说
// 「关」也只能事后删标签,等于开关失效。宁可晚一个来回出现,也不要关不掉。
enableMaxkb: false,
})
async function getConfig() {
const res = await getWebsiteConfig()

View File

@@ -1,5 +1,5 @@
import { fileURLToPath, URL } from "node:url"
import { defineConfig, loadEnv, type Plugin } from "vite"
import { defineConfig, loadEnv } from "vite"
import vue from "@vitejs/plugin-vue"
import legacy from "@vitejs/plugin-legacy"
import AutoImport from "unplugin-auto-import/vite"
@@ -69,25 +69,15 @@ const polyfills = [
"web.url.can-parse",
]
// index.html 里按需注入 MaxKB 脚本(原 Rsbuild EJS 模板的等价实现)
function injectMaxkb(maxkbUrl: string | undefined): Plugin {
return {
name: "inject-maxkb",
transformIndexHtml(html) {
if (!maxkbUrl) return html
return {
html,
tags: [
{
tag: "script",
attrs: { async: true, defer: true, src: maxkbUrl },
injectTo: "head",
},
],
}
},
}
}
// MaxKB 脚本**不要**在这里注入 index.html。
//
// 原来有个 inject-maxkb 插件把 <script src> 写进 head于是构建产物在 Vue 启动之前
// 就把挂件拉下来执行了 —— 后台那个「启用 MaxKB」开关根本拦不住它关掉也只是
// 事后把标签和 DOM 删掉代码早跑完了表现之一MaxKB 自带的性能上报在每个页面
// 抛 `Cannot read properties of undefined (reading 'startTime')`,且关不掉)。
//
// 现在只走运行时那一条路App.vue 的 useMaxKB() 等站点配置回来,
// enableMaxkb 为真才建 script 标签。
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "PUBLIC_")
@@ -144,7 +134,6 @@ export default defineConfig(({ mode }) => {
resolvers: [NaiveUiResolver()],
dts: "./src/components.d.ts",
}),
injectMaxkb(env["PUBLIC_MAXKB_URL"]),
],
envPrefix: "PUBLIC_",
resolve: {