feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions
+314
View File
@@ -0,0 +1,314 @@
<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 { Server } 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.memory_usage + "%",
width: 100,
},
{ title: "IP", key: "ip", width: 140 },
{ title: "判题机版本", key: "judger_version", width: 100 },
{ title: "服务器 URL", key: "service_url", width: 200 },
{
title: "上一次心跳",
key: "last_heartbeat",
render: (row) => parseTime(row.last_heartbeat, "YYYY-MM-DD HH:mm:ss"),
width: 120,
},
{
title: "创建时间",
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
width: 120,
},
]
const testcases = ref<Testcase[]>([])
const token = ref("")
const servers = ref<Server[]>([])
const abnormalServers = computed(() =>
servers.value.filter((item) => item.status === "abnormal"),
)
const websiteConfig = reactive({
website_base_url: import.meta.env.PUBLIC_OJ_URL,
website_name: "判题狗",
website_name_shortcut: "判题狗",
website_footer: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
allow_register: true,
submission_list_show_all: true,
class_list: [],
enable_maxkb: true,
})
async function getWebsiteConfig() {
const res = await getWebsite()
websiteConfig.website_base_url = res.data.website_base_url
websiteConfig.website_name = res.data.website_name
websiteConfig.website_name_shortcut = res.data.website_name_shortcut
websiteConfig.website_footer = res.data.website_footer
websiteConfig.allow_register = res.data.allow_register
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
websiteConfig.class_list = res.data.class_list
websiteConfig.enable_maxkb = res.data.enable_maxkb
}
async function saveWebsiteConfig() {
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
const invalid = websiteConfig.class_list.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.enable_maxkb)
updateConfig(
"submission_list_show_all",
websiteConfig.submission_list_show_all,
)
}
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.website_base_url" />
</n-form-item>
<n-form-item label="网站名">
<n-input v-model:value="websiteConfig.website_name" />
</n-form-item>
<n-form-item label="网站简称">
<n-input v-model:value="websiteConfig.website_name_shortcut" />
</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.class_list" />
<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.allow_register" />
</n-flex>
<n-flex align="center">
<span>显示所有提交</span>
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
</n-flex>
<n-flex align="center">
<span>启用AI小助手</span>
<n-switch v-model:value="websiteConfig.enable_maxkb" />
</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>
+244
View File
@@ -0,0 +1,244 @@
<script setup lang="ts">
import { h, onMounted, reactive, ref, watch } from "vue"
import { useRouter } from "vue-router"
import { NButton } from "naive-ui"
import { getRank } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
import { useUserStore } from "shared/store/user"
import { getACRate } from "utils/functions"
import type { Rank } from "utils/types"
import { getBaseInfo, randomUser10 } from "../api"
const userCount = ref(0)
const submissionCount = ref(0)
const contestCount = ref(0)
const userStore = useUserStore()
const router = useRouter()
const showModal = ref(false)
const luckyGuy = ref("")
const isRolling = ref(false)
const rollingNames = ref<string[]>([])
const pulseKey = ref(0)
let rollingTimer: ReturnType<typeof setInterval> | null = null
let rollingStopper: ReturnType<typeof setTimeout> | null = null
const data = ref<Rank[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
classroom: "",
})
const columns: DataTableColumn<Rank>[] = [
{
title: "排名",
key: "index",
width: 80,
align: "center",
render: (_, index) => index + (query.page - 1) * query.limit + 1,
},
{
title: "用户",
key: "username",
width: 200,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
},
{ title: "个性签名", key: "mood" },
{ title: "已解决", key: "accepted_number", width: 100 },
{ title: "提交数", key: "submission_number", width: 100 },
{
title: "正确率",
key: "rate",
width: 100,
render: (row) => getACRate(row.accepted_number, row.submission_number),
},
]
onMounted(async () => {
const res = await getBaseInfo()
userCount.value = res.data.user_count
submissionCount.value = res.data.today_submission_count
contestCount.value = res.data.recent_contest_count
})
async function listRanks() {
const offset = (query.page - 1) * query.limit
const res = await getRank(offset, query.limit, 0, query.classroom)
data.value = res.data.results
total.value = res.data.total
}
function stopRolling() {
if (rollingTimer) {
clearInterval(rollingTimer)
rollingTimer = null
}
if (rollingStopper) {
clearTimeout(rollingStopper)
rollingStopper = null
}
isRolling.value = false
}
function startRolling(finalName: string) {
stopRolling()
if (!rollingNames.value.length) return
isRolling.value = true
const interval = 80
const duration = 2000
let index = 0
rollingTimer = setInterval(() => {
luckyGuy.value = rollingNames.value[index % rollingNames.value.length]
index += 1
}, interval)
rollingStopper = setTimeout(() => {
stopRolling()
luckyGuy.value = finalName
pulseKey.value += 1
}, duration)
}
async function getRandom() {
const res = await randomUser10(query.classroom)
const names = (res.data as string[]).map(
(name) => name.split(query.classroom)[1],
)
rollingNames.value = names
const finalName = names[names.length - 1]
startRolling(finalName)
}
async function getRandomModal() {
showModal.value = true
stopRolling()
luckyGuy.value = ""
}
watch(() => query.page, listRanks)
watch(
() => query.limit,
() => {
query.page = 1
listRanks()
},
)
watch(
() => query.classroom,
(v) => {
query.page = 1
if (!v) {
data.value = []
total.value = 0
}
},
)
watch(showModal, (v) => {
if (!v) {
stopRolling()
luckyGuy.value = ""
}
})
</script>
<template>
<n-flex align="center">
<n-avatar round :size="60" :src="userStore.profile?.avatar" />
<h1 class="name">亲爱的管理员{{ userStore.user?.username }}</h1>
</n-flex>
<n-flex>
<h2>
<n-gradient-text type="info"> 总用户数{{ userCount }} </n-gradient-text>
</h2>
<h2>
<n-gradient-text type="error">
今日提交{{ submissionCount }}
</n-gradient-text>
</h2>
<h2>
<n-gradient-text type="warning">
近期比赛{{ contestCount }}
</n-gradient-text>
</h2>
</n-flex>
<n-flex align="center" class="actions">
<span>我猜你要</span>
<n-button @click="router.push('/admin/problem/create')">新题目</n-button>
<n-button @click="router.push('/admin/contest/create')">新比赛</n-button>
<div>
<n-input
style="width: 200px"
clearable
v-model:value="query.classroom"
placeholder="班级前缀"
/>
</div>
<n-button @click="listRanks">用户排名</n-button>
<n-button @click="getRandomModal" v-if="query.classroom">随机抽签</n-button>
<Pagination
class="pagination"
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</n-flex>
<n-data-table v-if="data.length" striped :data="data" :columns="columns" />
<n-modal
preset="card"
title="猜猜看幸运儿是谁?"
v-model:show="showModal"
style="width: 400px"
>
<n-flex vertical justify="center" align="center">
<n-h1 :key="pulseKey" class="lucky pulse">{{ luckyGuy }}</n-h1>
<n-button block :disabled="isRolling" @click="getRandom">
{{ luckyGuy ? "再来一次" : "开始抽签" }}
</n-button>
</n-flex>
</n-modal>
</template>
<style scoped>
.name {
font-size: 32px;
margin: 0;
}
.actions {
margin-bottom: 20px;
}
.pagination {
margin: 0;
}
.lucky {
height: 48px;
}
.pulse {
animation: lucky-pulse 0.6s ease-out;
}
@keyframes lucky-pulse {
0% {
transform: scale(0.9);
}
60% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
</style>