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")
|
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 —— 前端 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)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -45,15 +45,15 @@
|
|||||||
<n-spin :show="loadingDetail">
|
<n-spin :show="loadingDetail">
|
||||||
<div v-if="detail" class="detail">
|
<div v-if="detail" class="detail">
|
||||||
<n-descriptions :column="2" bordered size="small" class="meta">
|
<n-descriptions :column="2" bordered size="small" class="meta">
|
||||||
<n-descriptions-item label="用户">{{
|
<n-descriptions-item label="用户">
|
||||||
detail.username
|
{{ detail.username }}
|
||||||
}}</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
<n-descriptions-item label="班级">{{
|
<n-descriptions-item label="班级">
|
||||||
detail.className || "-"
|
{{ detail.className || "-" }}
|
||||||
}}</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
<n-descriptions-item label="时间" :span="2">{{
|
<n-descriptions-item label="时间">
|
||||||
parseTime(detail.createTime, "YYYY-MM-DD HH:mm:ss")
|
{{ parseTime(detail.createTime, "YYYY-MM-DD HH:mm:ss") }}
|
||||||
}}</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
</n-descriptions>
|
</n-descriptions>
|
||||||
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
|
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
|
||||||
<MdPreview :model-value="detail.analysis" />
|
<MdPreview :model-value="detail.analysis" />
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -5,23 +5,30 @@ import {
|
|||||||
type ConfigUpdate,
|
type ConfigUpdate,
|
||||||
} from "shared/composables/websocket"
|
} from "shared/composables/websocket"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面
|
||||||
|
* 的人不必刷新就生效。
|
||||||
|
*
|
||||||
|
* 广播里的 key 就是 store 的字段名(后端推的是契约字段名,不是 options 表那套
|
||||||
|
* snake_case 列键),所以这里直接按名字赋值,不需要换名。
|
||||||
|
*/
|
||||||
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) => {
|
||||||
// 更新全局配置 - 使用响应式方式
|
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
|
||||||
if (data.key in configStore.config) {
|
if (!(data.key in configStore.config)) return
|
||||||
// 直接修改 ref 的值来触发响应式更新
|
|
||||||
;(configStore.config as any)[data.key] = data.value
|
;(configStore.config as any)[data.key] = data.value
|
||||||
}
|
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
|
||||||
|
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) => {
|
||||||
|
|||||||
@@ -1,121 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ export const useConfigStore = defineStore("config", () => {
|
|||||||
submissionListShowAll: true,
|
submissionListShowAll: true,
|
||||||
allowRegister: false,
|
allowRegister: false,
|
||||||
classList: [],
|
classList: [],
|
||||||
enableMaxkb: true,
|
// 默认 false:这是给 useMaxKB 用的开关,而 MaxKB 是第三方脚本。
|
||||||
|
// 默认 true 的话,getConfig() 还没回来挂件就已经加载执行了,服务端配置说
|
||||||
|
// 「关」也只能事后删标签,等于开关失效。宁可晚一个来回出现,也不要关不掉。
|
||||||
|
enableMaxkb: false,
|
||||||
})
|
})
|
||||||
async function getConfig() {
|
async function getConfig() {
|
||||||
const res = await getWebsiteConfig()
|
const res = await getWebsiteConfig()
|
||||||
|
|||||||
+10
-21
@@ -1,5 +1,5 @@
|
|||||||
import { fileURLToPath, URL } from "node:url"
|
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 vue from "@vitejs/plugin-vue"
|
||||||
import legacy from "@vitejs/plugin-legacy"
|
import legacy from "@vitejs/plugin-legacy"
|
||||||
import AutoImport from "unplugin-auto-import/vite"
|
import AutoImport from "unplugin-auto-import/vite"
|
||||||
@@ -69,25 +69,15 @@ const polyfills = [
|
|||||||
"web.url.can-parse",
|
"web.url.can-parse",
|
||||||
]
|
]
|
||||||
|
|
||||||
// index.html 里按需注入 MaxKB 脚本(原 Rsbuild EJS 模板的等价实现)
|
// MaxKB 脚本**不要**在这里注入 index.html。
|
||||||
function injectMaxkb(maxkbUrl: string | undefined): Plugin {
|
//
|
||||||
return {
|
// 原来有个 inject-maxkb 插件把 <script src> 写进 head,于是构建产物在 Vue 启动之前
|
||||||
name: "inject-maxkb",
|
// 就把挂件拉下来执行了 —— 后台那个「启用 MaxKB」开关根本拦不住它,关掉也只是
|
||||||
transformIndexHtml(html) {
|
// 事后把标签和 DOM 删掉,代码早跑完了(表现之一:MaxKB 自带的性能上报在每个页面
|
||||||
if (!maxkbUrl) return html
|
// 抛 `Cannot read properties of undefined (reading 'startTime')`,且关不掉)。
|
||||||
return {
|
//
|
||||||
html,
|
// 现在只走运行时那一条路:App.vue 的 useMaxKB() 等站点配置回来,
|
||||||
tags: [
|
// enableMaxkb 为真才建 script 标签。
|
||||||
{
|
|
||||||
tag: "script",
|
|
||||||
attrs: { async: true, defer: true, src: maxkbUrl },
|
|
||||||
injectTo: "head",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), "PUBLIC_")
|
const env = loadEnv(mode, process.cwd(), "PUBLIC_")
|
||||||
@@ -144,7 +134,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
resolvers: [NaiveUiResolver()],
|
resolvers: [NaiveUiResolver()],
|
||||||
dts: "./src/components.d.ts",
|
dts: "./src/components.d.ts",
|
||||||
}),
|
}),
|
||||||
injectMaxkb(env["PUBLIC_MAXKB_URL"]),
|
|
||||||
],
|
],
|
||||||
envPrefix: "PUBLIC_",
|
envPrefix: "PUBLIC_",
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user