Files
OJ2/apps/web/src/admin/setting/config.vue
T
xuyue 4189c59162
Deploy / deploy (push) Has been cancelled
update
2026-09-14 08:39:58 -06:00

371 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_RE,
} from "utils/constants"
import { parseTime } from "utils/functions"
import type { OrphanTestCase, Server, WebsiteConfig } from "utils/types"
import { useConfigStore } from "shared/store/config"
import {
deleteJudgeServer,
editWebsite,
getJudgeServer,
getWebsite,
listInvalidTestcases,
pruneInvalidTestcases,
} from "../api"
import { useUserStore } from "shared/store/user"
const message = useMessage()
const configStore = useConfigStore()
const userStore = useUserStore()
// 确保只有登录用户才能使用WebSocket
watch(
() => userStore.isAuthed,
(isAuthed) => {
if (!isAuthed) {
// 如果用户未登录,禁用WebSocket功能
console.warn("用户未登录,WebSocket配置更新功能已禁用")
}
},
{ immediate: true },
)
const testcaseColumns: DataTableColumn<OrphanTestCase>[] = [
{ title: "测试用例 ID", key: "id" },
{
title: "选项",
key: "delete",
render: (row) =>
h(
NButton,
{ size: "small", onClick: () => deleteTestcase(row.id) },
() => "删除",
),
},
]
const statusMap: {
[key in "normal" | "abnormal"]: { color: "primary" | "error"; label: string }
} = {
normal: { color: "primary", label: "正常" },
abnormal: { color: "error", label: "异常" },
}
const serverColumns: DataTableColumn<Server>[] = [
{
title: "状态",
key: "status",
width: 80,
render: (row) =>
h(
NTag,
{ type: statusMap[row.status].color, size: "small" },
() => statusMap[row.status].label,
),
},
{
title: "选项",
key: "options",
width: 80,
render: (row) =>
h(
NButton,
{
type: "primary",
size: "small",
disabled: row.status === "normal",
onClick: () => delJudgeServer(row.hostname),
},
() => "删除",
),
},
{ title: "主机", key: "hostname", width: 140 },
{
title: "内存占用",
key: "memory_usage",
render: (row) => row.memoryUsage + "%",
width: 100,
},
{ title: "IP", key: "ip", width: 140 },
{ title: "判题机版本", key: "judgerVersion", width: 100 },
{ title: "服务器 URL", key: "serviceUrl", width: 200 },
{
title: "上一次心跳",
key: "lastHeartbeat",
render: (row) => parseTime(row.lastHeartbeat, "YYYY-MM-DD HH:mm:ss"),
width: 120,
},
{
title: "创建时间",
key: "createTime",
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
width: 120,
},
]
const testcases = ref<OrphanTestCase[]>([])
const token = ref("")
const servers = ref<Server[]>([])
const abnormalServers = computed(() =>
servers.value.filter((item) => item.status === "abnormal"),
)
const websiteConfig = reactive<WebsiteConfig>({
websiteBaseUrl: "",
websiteName: "判题狗",
websiteNameShortcut: "判题狗",
websiteFooter: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
allowRegister: true,
submissionListShowAll: true,
classList: [],
enableMaxkb: true,
})
// 班级列表的顺序就是登录页、班级 PK、统计面板下拉框里的顺序,数组原样存进 options 表,
// 所以拖拽只改前端数组,点「保存」生效
const dragFrom = ref<number | null>(null)
const dragOver = ref<number | null>(null)
function moveClass(from: number, to: number) {
const list = [...websiteConfig.classList]
const [moved] = list.splice(from, 1)
list.splice(to, 0, moved)
websiteConfig.classList = list
}
function renderClassTag(tag: string | { label: string }, index: number) {
const label = typeof tag === "string" ? tag : tag.label
return h(
NTag,
{
key: index,
closable: true,
draggable: true,
type: dragOver.value === index ? "primary" : "default",
style: {
cursor: "move",
opacity: dragFrom.value === index ? 0.4 : 1,
},
onClose: () => {
websiteConfig.classList = websiteConfig.classList.filter(
(_, i) => i !== index,
)
},
onDragstart: (e: DragEvent) => {
dragFrom.value = index
// Firefox 不 setData 就不会开始拖
e.dataTransfer?.setData("text/plain", label)
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"
},
onDragover: (e: DragEvent) => {
if (dragFrom.value === null) return
e.preventDefault()
dragOver.value = index
},
onDragleave: () => {
if (dragOver.value === index) dragOver.value = null
},
onDrop: (e: DragEvent) => {
e.preventDefault()
if (dragFrom.value !== null && dragFrom.value !== index) {
moveClass(dragFrom.value, index)
}
dragFrom.value = null
dragOver.value = null
},
onDragend: () => {
dragFrom.value = null
dragOver.value = null
},
},
() => label,
)
}
async function getWebsiteConfig() {
const res = await getWebsite()
websiteConfig.websiteBaseUrl = res.websiteBaseUrl
websiteConfig.websiteName = res.websiteName
websiteConfig.websiteNameShortcut = res.websiteNameShortcut
websiteConfig.websiteFooter = res.websiteFooter
websiteConfig.allowRegister = res.allowRegister
websiteConfig.submissionListShowAll = res.submissionListShowAll
websiteConfig.classList = res.classList
websiteConfig.enableMaxkb = res.enableMaxkb
}
async function saveWebsiteConfig() {
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
const invalid = websiteConfig.classList.filter((c) => !CLASS_NAME_RE.test(c))
if (invalid.length) {
message.error(
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return
}
try {
await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
message.success("网站配置保存成功")
getWebsiteConfig()
configStore.getConfig()
// 广播由后端在 POST /admin/website 里做,八个键一个不落。
// 这里原来还从客户端往 /ws/config 推两个键 —— 那条连接从没 connect() 过、
// send() 直接返回 false,而且服务端只认 ping/subscribe,本来就收不下。
}
async function deleteTestcase(id?: string) {
await pruneInvalidTestcases(id)
message.success("删除成功")
getTestcases()
}
async function getTestcases() {
const res = await listInvalidTestcases()
testcases.value = res
}
async function getJudgeServerData() {
const res = await getJudgeServer()
token.value = res.token
servers.value = res.servers
}
async function delJudgeServer(hostname: string) {
await deleteJudgeServer(hostname)
message.success("删除成功")
// 不刷新的话删掉的那行还挂在表里,得手动刷新页面才消失(批量删除那支一直是刷的)
getJudgeServerData()
}
async function deleteAbnormalServers() {
const dels = abnormalServers.value.map((item) =>
deleteJudgeServer(item.hostname),
)
await Promise.all(dels)
message.success("删除成功")
getJudgeServerData()
}
onMounted(() => {
getWebsiteConfig()
getTestcases()
getJudgeServerData()
})
</script>
<template>
<n-card class="box">
<template #header>
<n-flex align="center">
网站设置
<n-button type="primary" size="small" @click="saveWebsiteConfig">
保存
</n-button>
</n-flex>
</template>
<n-form inline label-placement="left">
<n-form-item label="网站 URL">
<n-input class="url" v-model:value="websiteConfig.websiteBaseUrl" />
</n-form-item>
<n-form-item label="网站名">
<n-input v-model:value="websiteConfig.websiteName" />
</n-form-item>
<n-form-item label="网站简称">
<n-input v-model:value="websiteConfig.websiteNameShortcut" />
</n-form-item>
</n-form>
<n-form label-placement="left">
<n-form-item label="班级列表">
<n-flex vertical size="small">
<n-dynamic-tags
v-model:value="websiteConfig.classList"
:render-tag="renderClassTag"
/>
<n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致
拖动标签调整顺序登录页等下拉框按此顺序),改完点保存
</n-text>
</n-flex>
</n-form-item>
</n-form>
<n-flex align="center">
<n-flex align="center">
<span>是否允许注册</span>
<n-switch v-model:value="websiteConfig.allowRegister" />
</n-flex>
<n-flex align="center">
<span>显示所有提交</span>
<n-switch v-model:value="websiteConfig.submissionListShowAll" />
</n-flex>
<n-flex align="center">
<span>启用AI小助手</span>
<n-switch v-model:value="websiteConfig.enableMaxkb" />
</n-flex>
</n-flex>
</n-card>
<n-card class="box">
<template #header>
<n-flex align="center">
判题服务器
<n-button
v-if="abnormalServers.length"
size="small"
type="warning"
@click="deleteAbnormalServers"
>
删除无效服务器
</n-button>
</n-flex>
</template>
<div class="box">
接口凭证 <n-tag size="small">{{ token }}</n-tag>
</div>
<n-data-table
:single-line="false"
striped
:columns="serverColumns"
:data="servers"
/>
</n-card>
<n-card class="box" v-if="testcases.length">
<template #header>
<n-flex align="center">
无效的测试用例
<n-button size="small" type="warning" @click="() => deleteTestcase()">
全部删除
</n-button>
</n-flex>
</template>
<n-data-table
striped
class="table"
:columns="testcaseColumns"
:data="testcases"
/>
</n-card>
</template>
<style scoped>
.url {
width: 200px;
}
.box {
margin-bottom: 16px;
}
.table {
width: 40%;
}
</style>