Compare commits
4 Commits
d3757954cd
...
bc0b782443
| Author | SHA1 | Date | |
|---|---|---|---|
| bc0b782443 | |||
| 8ae14f055a | |||
| 0f34d67cc8 | |||
| 8600f8143a |
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.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
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user