371 lines
10 KiB
Vue
371 lines
10 KiB
Vue
<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 }}
|
||
位数字,如 251、2510,要和用户名里 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>
|