Files
OJ2/apps/web/src/admin/setting/config.vue
yuetsh 1198895b54 refactor(前端): vue-tsc 清零,type-check 可以当 CI 门禁了
接上 vue-tsc 时有 143 条既有错误,上一批迁移带走 89 条,这里把剩下的清完。

大部分是噪音(未使用的回调参数、死变量、少数 unknown),但里面躺着两个真问题:

- 个人主页查不存在的用户会抛:getProfile 返回 null 时后面照样取
  .acmProblemsStatus,靠 `!` 压着。加了早返回。
- getProblemSetProgress 我上一批标错了类型:后台这个接口返回的是裸数组,
  不是分页信封(和 oj 侧的 /user-progress 不一样)。已改正并加注释区分。

其余处理:

- 未使用的回调参数按 TS 约定加 `_` 前缀(v-for 的项、供子类重写的空钩子、
  路由守卫的 from);确认无用的局部变量直接删(clickX/clickY 算了点击位置
  但飞出方向用的是随机角度,hasToday 下面已经用 lastDateOnly 判过,
  prefix 算了周/月但标签里没用上)。
- App.vue 的 highlight.js 注册:Promise.all([...]).then(m => m.map(x => x.default))
  会把元组塌成 (HLJSApi | LanguageFn)[],hljs 上就找不到 registerLanguage。
  改成逐个取 .default。
- 给一批 api 补上契约类型(getMetrics / getHitokoto / getTutorials /
  formatCode / getProblemBeatRate / getClassUsernames 等),契约相应补了
  8 个 z.infer 导出。
- ContestRank.submissionInfo 和 AcmHelperItem.acInfo 在 api 边界窄化成
  SubmissionInfo —— 契约里是 Record<string, unknown>(JSONB 原文)。
- FlowchartEditor 的 TS2589:vue-flow 的 Node 嵌套太深,ref<Node[]>([]) 的
  UnwrapRef 推导撞上实例化层级上限,改成 ref([]) as Ref<Node[]>。
- api2.ts 的响应拦截器**故意**不返回 AxiosResponse(要把 { data } 信封剥掉),
  类型上确实说不通,没硬掰,写注释说明为什么用断言。
- 题目列表的「随机」按钮在模板里一直是注释状态,对应的 getRandom 和
  getRandomProblemID 一并删除(后端 /problems/random 保留不动)。

验证:vue-tsc 0 条,apps/api tsc、check:routes、web build 全通过;
修过 key 的列都打接口确认过字段真实存在。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:04:10 -06:00

312 lines
8.2 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 { useConfigWebSocket } from "shared/composables/websocket"
import {
deleteJudgeServer,
editWebsite,
getJudgeServer,
getWebsite,
listInvalidTestcases,
pruneInvalidTestcases,
} from "../api"
import { useUserStore } from "shared/store/user"
interface Testcase {
id: string
create_time: string
}
const message = useMessage()
const configStore = useConfigStore()
const userStore = useUserStore()
const { updateConfig } = useConfigWebSocket()
// 确保只有登录用户才能使用WebSocket
watch(
() => userStore.isAuthed,
(isAuthed) => {
if (!isAuthed) {
// 如果用户未登录禁用WebSocket功能
console.warn("用户未登录WebSocket配置更新功能已禁用")
}
},
{ immediate: true },
)
const testcaseColumns: DataTableColumn<Testcase>[] = [
{ 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: "last_heartbeat",
render: (row) => parseTime(row.lastHeartbeat, "YYYY-MM-DD HH:mm:ss"),
width: 120,
},
{
title: "创建时间",
key: "create_time",
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: import.meta.env.PUBLIC_OJ_URL,
websiteName: "判题狗",
websiteNameShortcut: "判题狗",
websiteFooter: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
allowRegister: true,
submissionListShowAll: true,
classList: [],
enableMaxkb: true,
})
async function getWebsiteConfig() {
const res = await getWebsite()
websiteConfig.websiteBaseUrl = res.data.websiteBaseUrl
websiteConfig.websiteName = res.data.websiteName
websiteConfig.websiteNameShortcut = res.data.websiteNameShortcut
websiteConfig.websiteFooter = res.data.websiteFooter
websiteConfig.allowRegister = res.data.allowRegister
websiteConfig.submissionListShowAll = res.data.submissionListShowAll
websiteConfig.classList = res.data.classList
websiteConfig.enableMaxkb = res.data.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()
// 通过 WebSocket 广播配置变化,实现实时切换
updateConfig("enable_maxkb", websiteConfig.enableMaxkb)
updateConfig("submission_list_show_all", websiteConfig.submissionListShowAll)
}
async function deleteTestcase(id?: string) {
await pruneInvalidTestcases(id)
message.success("删除成功")
getTestcases()
}
async function getTestcases() {
const res = await listInvalidTestcases()
testcases.value = res.data
}
async function getJudgeServerData() {
const res = await getJudgeServer()
token.value = res.data.token
servers.value = res.data.servers
}
async function delJudgeServer(hostname: string) {
await deleteJudgeServer(hostname)
message.success("删除成功")
}
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" />
<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>