feat(admin): connect authenticated problem maintenance console

This commit is contained in:
2026-10-12 02:33:15 +08:00
parent 5366418881
commit 835df6b7e7
12 changed files with 1065 additions and 268 deletions
+12
View File
@@ -46,3 +46,15 @@ POST前先保存 UUID,双击共用请求,网络超时/刷新重试复用原
运行指标仅来自完整持久化报告,null显示“—”;诊断和源码按纯文本显示。 运行指标仅来自完整持久化报告,null显示“—”;诊断和源码按纯文本显示。
`pnpm test` 覆盖草稿、幂等重试、轮询期限/隐藏/终态/失效响应和401处理, `pnpm test` 覆盖草稿、幂等重试、轮询期限/隐藏/终态/失效响应和401处理,
已纳入 `make check-frontend`;真实浏览器编译/判题另在受限独立 VM 验收。 已纳入 `make check-frontend`;真实浏览器编译/判题另在受限独立 VM 验收。
管理入口 `/admin/index.html#/problems` 共用同源 Cookie、CSRF 与实时权限 API。
`admin/` 管理路由和维护页面,`api/adminProblems.ts` 使用生成 DTO;普通用户进入明确
403 页,权限失效或离页终止请求。题目、版本历史、本人导入和审计采用服务端游标分页。
题包只接受有界单层 ZIP,导入 UUID 在上传前绑定当前账号、SHA-256 和字节数。
浏览器只持久保存 UUID/摘要等重试信息,不保存文件正文、密码或管理员能力。
状态查询每 2 秒一次,最多 90 次/3 分钟;错误、终态、离页停止,可核对原操作。
发布、下架和恢复使用独立稳定 UUID 与版本/状态前置条件;409 后重新读取当前状态,
网络不确定时核对原回执。候选题面、公开样例与预算按安全文本预览,隐藏工件不下发。
发布产生新不可变版本;下架的受理任务与本人历史保留。其他管理业务区明确未实现。
事件测试验证本地凭据与生命周期,不代替独立 VM 中的真实 Cookie/HTTP/浏览器验收。
+53 -5
View File
@@ -1,9 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useSession } from '../composables/useSession'
import { clearIntents, hasMaintenance } from './intents'
import { maintenanceError } from '../api/adminProblems'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import UiIcon from '../components/UiIcon.vue' import UiIcon from '../components/UiIcon.vue'
import { adminSections } from './navigation' import { adminSections } from './navigation'
const route = useRoute() const route = useRoute(),
router = useRouter(),
session = useSession()
const identityError = ref('')
watch(session.user, (user) => {
if (!user) {
clearIntents()
if (!route.meta.public) void router.replace('/login')
} else if (!hasMaintenance(user) && !route.meta.public) void router.replace('/forbidden')
})
async function revalidate() {
if (document.hidden || route.meta.public) return
try {
await session.refresh()
identityError.value = ''
} catch (e) {
identityError.value = maintenanceError(e)
await router.replace('/unavailable')
}
}
async function logout() {
try {
await session.logout()
clearIntents()
await router.replace('/login')
} catch (e) {
identityError.value = maintenanceError(e)
}
}
onMounted(() => {
window.addEventListener('focus', revalidate)
document.addEventListener('visibilitychange', revalidate)
})
onUnmounted(() => {
window.removeEventListener('focus', revalidate)
document.removeEventListener('visibilitychange', revalidate)
})
</script> </script>
<template> <template>
<el-config-provider :locale="zhCn"> <el-config-provider :locale="zhCn">
@@ -13,7 +53,10 @@ const route = useRoute()
><span class="brand-mark">KF.</span ><span class="brand-mark">KF.</span
><span class="brand-name">管理控制台<small>ADMIN CONSOLE</small></span></a ><span class="brand-name">管理控制台<small>ADMIN CONSOLE</small></span></a
> >
<span class="admin-app-label">独立管理应用 · 界面预览</span> <span class="admin-app-label">题目维护 · 服务端权限验证</span>
<button v-if="session.user.value" class="admin-return" @click="logout">
退出 {{ session.user.value.username }}
</button>
<a href="/" class="admin-return">返回前台 <UiIcon name="arrow" /></a> <a href="/" class="admin-return">返回前台 <UiIcon name="arrow" /></a>
</header> </header>
<nav class="admin-app-nav" aria-label="管理导航"> <nav class="admin-app-nav" aria-label="管理导航">
@@ -22,17 +65,22 @@ const route = useRoute()
:key="section.id" :key="section.id"
:to="section.id === 'overview' ? '/' : '/' + section.id" :to="section.id === 'overview' ? '/' : '/' + section.id"
:aria-label="section.title" :aria-label="section.title"
:class="{ active: (route.params.section || 'overview') === section.id }" :class="{
active:
(route.path === '/problems' ? 'problems' : route.params.section || 'overview') ===
section.id,
}"
><UiIcon :name="section.icon" /><span>{{ section.title }}</span></RouterLink ><UiIcon :name="section.icon" /><span>{{ section.title }}</span></RouterLink
> >
</nav> </nav>
<main class="admin-app-main"> <main class="admin-app-main">
<p v-if="identityError" role="alert">{{ identityError }}</p>
<RouterView v-slot="{ Component, route: currentRoute }" <RouterView v-slot="{ Component, route: currentRoute }"
><component :is="Component" :key="currentRoute.path" ><component :is="Component" :key="currentRoute.path"
/></RouterView> /></RouterView>
</main> </main>
<footer class="admin-app-footer"> <footer class="admin-app-footer">
KF OnlineJudge / 管理控制台<span>示例数据 · 管理员授权与业务接口待接入</span> KF OnlineJudge / 管理控制台<span>题目维护已接通 · 其他业务区为明确预览</span>
</footer> </footer>
</div> </div>
</el-config-provider> </el-config-provider>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSession } from '../composables/useSession'
import { maintenanceError } from '../api/adminProblems'
import { hasMaintenance } from './intents'
const session = useSession(),
router = useRouter()
const username = ref(''),
password = ref(''),
busy = ref(false),
error = ref('')
async function login() {
if (busy.value) return
busy.value = true
error.value = ''
try {
await session.login(username.value, password.value)
await router.replace(hasMaintenance(session.user.value) ? '/problems' : '/forbidden')
} catch (e) {
error.value = maintenanceError(e)
} finally {
password.value = ''
busy.value = false
}
}
</script>
<template>
<section class="admin-real-panel">
<h1>管理员登录</h1>
<p>使用已有账号与同源安全会话。维护权限由服务端授予。</p>
<form @submit.prevent="login">
<label
>用户名<input v-model="username" autocomplete="username" required maxlength="24"
/></label>
<label
>密码<input
v-model="password"
type="password"
autocomplete="current-password"
required
maxlength="256"
/></label>
<button :disabled="busy">{{ busy ? '正在登录…' : '登录' }}</button>
</form>
<p v-if="error" role="alert">{{ error }}</p>
</section>
</template>
+617
View File
@@ -0,0 +1,617 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useSession } from '../composables/useSession'
import {
adminProblems as api,
maintenanceError,
type ImportDetail,
type ManagedProblem,
type Version,
type AuditEvent,
} from '../api/adminProblems'
import { ApiError } from '../api/transport'
import { boundedPoll } from '../api/polling'
import {
importIntent,
mutationIntent,
loadIntent,
saveIntent,
hasMaintenance,
type ImportIntent,
type MutationIntent,
} from './intents'
const session = useSession()
const problems = ref<ManagedProblem[]>([]),
problemAfter = ref<string | null>(null)
const selected = ref<ManagedProblem | null>(null),
versions = ref<Version[]>([]),
versionBefore = ref<number | null>(null)
const imports = ref<ImportDetail['operation_id'][]>([]),
importAfter = ref<string | null>(null),
task = ref<ImportDetail | null>(null)
const audits = ref<AuditEvent[]>([]),
auditBefore = ref<string | null>(null)
const file = ref<File | null>(null),
fileName = ref(''),
busy = ref(false),
loading = ref(true),
error = ref(''),
info = ref('')
const targetReady = ref(false)
const uploadAvailable = ref(false),
enabledFilter = ref('all'),
publicationTarget = ref<ManagedProblem | null>(null)
let intent = loadIntent<ImportIntent>('import'),
mutation = loadIntent<MutationIntent>('mutation')
let generation = 0,
poll: ReturnType<typeof boundedPoll<ImportDetail>> | undefined
const controllers = new Set<AbortController>()
const candidate = computed(() => task.value?.candidate ?? null)
const permitted = computed(() => hasMaintenance(session.user.value))
function fence() {
generation++
for (const c of controllers) c.abort()
controllers.clear()
poll?.dispose()
poll = undefined
busy.value = false
}
async function request<T>(call: (signal: AbortSignal) => Promise<T>): Promise<T> {
const c = new AbortController()
controllers.add(c)
try {
return await call(c.signal)
} finally {
controllers.delete(c)
}
}
async function run(action: () => Promise<void>) {
if (busy.value || !permitted.value) return
const current = generation
busy.value = true
error.value = ''
info.value = ''
try {
await action()
} catch (e) {
if (current === generation) {
error.value = maintenanceError(e)
if (e instanceof ApiError && [401, 403].includes(e.status)) {
fence()
task.value = null
selected.value = null
await session.refresh().catch(() => {})
}
}
} finally {
if (current === generation) busy.value = false
}
}
async function loadProblems(append = false) {
const current = generation
const page = await request((s) =>
api.page(
s,
append ? (problemAfter.value ?? undefined) : undefined,
enabledFilter.value === 'all' ? undefined : enabledFilter.value === 'enabled',
),
)
if (current !== generation) return
problems.value = page.items
problemAfter.value = page.next_after
}
async function loadImports(append = false) {
const current = generation
const page = await request((s) =>
api.imports(s, append ? (importAfter.value ?? undefined) : undefined),
)
if (current !== generation) return
const ids = page.items.map((x) => x.operation_id)
imports.value = ids
importAfter.value = page.next_after
}
async function loadAudit(append = false) {
const current = generation
const page = await request((s) =>
api.audit(s, append ? (auditBefore.value ?? undefined) : undefined),
)
if (current !== generation) return
audits.value = page.items
auditBefore.value = page.next_before
}
async function chooseProblem(problem: ManagedProblem) {
fence()
selected.value = problem
versions.value = []
versionBefore.value = null
await run(async () => {
const current = generation
const page = await request((s) => api.versions(problem.id, s))
if (current === generation) {
versions.value = page.items
versionBefore.value = page.next_before
}
})
}
async function moreVersions() {
await run(async () => {
const p = selected.value
if (!p || !versionBefore.value) return
const current = generation
const page = await request((s) => api.versions(p.id, s, versionBefore.value!))
if (current === generation) {
versions.value = page.items
versionBefore.value = page.next_before
}
})
}
async function targetFor(value: ImportDetail, current: number) {
publicationTarget.value = null
targetReady.value = false
if (!value.candidate) return
try {
const target = await request((s) => api.bySlug(value.candidate!.slug, s))
if (current === generation) publicationTarget.value = target
} catch (e) {
if (!(e instanceof ApiError && e.status === 404)) throw e
}
if (current === generation) targetReady.value = true
}
function startPoll(key: string) {
poll?.dispose()
const current = generation
poll = boundedPoll({
load: (signal) => api.import(key, signal),
maxRequests: 90,
maxTime: 180000,
interval: 2000,
terminal: (value) =>
['ready', 'published', 'failed', 'expired', 'discarded'].includes(value.state),
result: (value) => {
if (current !== generation) return
task.value = value
if (value.candidate)
void targetFor(value, current).catch((e) => {
if (current === generation) error.value = maintenanceError(e)
})
},
error: (e) => {
if (current === generation) error.value = maintenanceError(e)
},
exhausted: () => {
if (current === generation)
info.value = '自动查询已停止。可手动核对原操作;不会重复创建导入。'
},
})
poll.visible(!document.hidden)
poll.start()
}
async function chooseTask(key: string) {
fence()
task.value = null
publicationTarget.value = null
await run(async () => {
const current = generation
const value = await request((s) => api.import(key, s))
if (current !== generation) return
task.value = value
await targetFor(value, current)
if (!['ready', 'published', 'failed', 'expired', 'discarded'].includes(value.state))
startPoll(key)
})
}
async function chooseFile(event: Event) {
const picked = (event.target as HTMLInputElement).files?.[0] ?? null
fence()
file.value = null
fileName.value = ''
task.value = null
publicationTarget.value = null
if (!picked) return
await run(async () => {
const current = generation
if (picked.size < 1 || picked.size > 67108864) throw new Error('ZIP 题包限 1 字节至 64 MiB。')
const digest = Array.from(
new Uint8Array(await crypto.subtle.digest('SHA-256', await picked.arrayBuffer())),
(b) => b.toString(16).padStart(2, '0'),
).join('')
if (current !== generation) return
intent = importIntent(intent, session.user.value!.id, digest, picked.size)
saveIntent('import', intent)
file.value = picked
fileName.value = picked.name
info.value = '文件摘要已绑定。重试同一文件沿用同一导入 UUID。'
})
}
async function upload() {
await run(async () => {
if (!file.value || !intent) return
const current = generation,
picked = file.value,
receipt = intent
const created = await request((s) =>
api.create(
{
schema_version: 1,
operation_id: receipt.operation_id,
archive_digest: receipt.archive_digest,
archive_bytes: receipt.archive_bytes,
},
s,
),
)
if (current !== generation) return
if (created.state === 'uploading')
await request((s) => api.upload(receipt.operation_id, picked, s))
if (current !== generation) return
const value = await request((s) => api.import(receipt.operation_id, s))
if (current !== generation) return
task.value = value
await targetFor(value, current)
await loadImports()
startPoll(receipt.operation_id)
})
}
async function discard() {
await run(async () => {
const value = task.value
if (!value) return
const current = generation
await request((s) => api.discard(value.operation_id, s))
if (current !== generation) return
task.value = await request((s) => api.import(value.operation_id, s))
publicationTarget.value = null
await loadAudit()
info.value = '候选已按服务端结果丢弃。'
})
}
async function publish() {
await run(async () => {
const value = task.value
if (!value?.candidate || value.state !== 'ready' || !targetReady.value) return
const current = generation
const body = {
schema_version: 1 as const,
import_operation_id: value.operation_id,
candidate_id: value.candidate_id!,
expected_active_version_id: publicationTarget.value?.active_version_id ?? null,
}
mutation = mutationIntent(
mutation,
session.user.value!.id,
JSON.stringify({ action: 'publish', ...body }),
)
saveIntent('mutation', mutation)
try {
const result = await request((s) =>
api.publish({ ...body, operation_id: mutation!.operation_id }, s),
)
if (current !== generation) return
info.value = `服务端已发布 revision ${result.problem.revision}。`
publicationTarget.value = result.problem
task.value = await request((s) => api.import(value.operation_id, s))
await loadProblems()
await loadAudit()
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
await targetFor(value, current)
await loadProblems()
}
throw e
}
})
}
async function availability() {
await run(async () => {
const value = selected.value
if (!value?.active_version_id) return
const current = generation
const enabled = !value.enabled
const body = {
schema_version: 1 as const,
expected_active_version_id: value.active_version_id,
expected_enabled: value.enabled,
}
mutation = mutationIntent(
mutation,
session.user.value!.id,
JSON.stringify({ action: enabled ? 'restore' : 'disable', problem: value.id, ...body }),
)
saveIntent('mutation', mutation)
try {
const result = await request((s) =>
api.availability(value.id, enabled, { ...body, operation_id: mutation!.operation_id }, s),
)
if (current !== generation) return
selected.value = result.problem
info.value = enabled ? '服务端已恢复新提交。' : '服务端已下架;已受理任务与本人历史保留。'
await loadProblems()
await loadAudit()
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
const latest = await request((s) => api.detail(value.id, s))
if (current === generation) selected.value = latest
await loadProblems()
}
throw e
}
})
}
async function verifyMutation() {
await run(async () => {
if (!mutation || mutation.owner !== session.user.value?.id) return
const current = generation
const receipt = await request((s) => api.receipt(mutation!.operation_id, s))
if (current !== generation) return
info.value = `服务器已确认 ${receipt.action} / revision ${receipt.problem.revision} / ${receipt.operation_id}。`
await loadProblems()
await loadAudit()
})
}
function restartImport() {
if (
busy.value ||
!file.value ||
!intent ||
!task.value ||
!['failed', 'discarded', 'expired'].includes(task.value.state)
)
return
intent = importIntent(null, session.user.value!.id, intent.archive_digest, intent.archive_bytes)
saveIntent('import', intent)
task.value = null
publicationTarget.value = null
targetReady.value = false
info.value = '上次操作已终止;已创建新的本地导入凭据,请重新上传。'
}
function visibility() {
poll?.visible(!document.hidden)
}
watch(
() => session.user.value?.capabilities?.authorization_version,
() => {
fence()
task.value = null
selected.value = null
file.value = null
audits.value = []
problems.value = []
},
)
onMounted(() => {
document.addEventListener('visibilitychange', visibility)
void run(async () => {
const current = generation
const caps = await request((s) => api.capabilities(s))
if (current !== generation) return
uploadAvailable.value = caps.uploads_available && caps.validation_available
await loadProblems()
await loadImports()
await loadAudit()
if (intent && intent.owner === session.user.value?.id)
info.value = `已保留导入 ${intent.operation_id};可重新选择原文件或从任务列表核对。`
}).finally(() => (loading.value = false))
})
onUnmounted(() => {
fence()
document.removeEventListener('visibilitychange', visibility)
})
function sampleText(encoded: string) {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(
Uint8Array.from(atob(encoded), (x) => x.charCodeAt(0)),
)
} catch {
return '公开样例为二进制数据。'
}
}
</script>
<template>
<section class="admin-real-panel" data-testid="admin-problems">
<h1>题目维护</h1>
<p>发布生成不可变新版本;下架停止公开读取和新提交,已受理任务继续使用原版本。</p>
<p v-if="loading" role="status">正在读取维护服务…</p>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="info" role="status">{{ info }}</p>
<div class="admin-real-toolbar">
<label
>状态
<select v-model="enabledFilter" :disabled="busy" @change="run(() => loadProblems())">
<option value="all">全部</option>
<option value="enabled">已上架</option>
<option value="disabled">已下架</option>
</select></label
><button :disabled="busy" @click="run(() => loadProblems())">刷新题目</button
><button :disabled="busy" @click="verifyMutation">核对最近维护回执</button>
</div>
<table>
<thead>
<tr>
<th>题目</th>
<th>当前版本</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in problems" :key="p.id">
<td>{{ p.slug }} · {{ p.title }}</td>
<td>revision {{ p.revision }}</td>
<td>{{ p.enabled ? '已上架' : '已下架' }}</td>
<td><button :disabled="busy" @click="chooseProblem(p)">版本与状态</button></td>
</tr>
</tbody>
</table>
<p v-if="!loading && !problems.length">没有符合条件的题目。</p>
<button v-if="problemAfter" :disabled="busy" @click="run(() => loadProblems(true))">
下一页题目
</button>
<section v-if="selected">
<h2>{{ selected.slug }} 的版本历史</h2>
<p>当前 revision {{ selected.revision }} / {{ selected.active_version_id }}</p>
<button :disabled="busy" @click="availability">
{{ selected.enabled ? '下架题目' : '恢复题目' }}
</button>
<ul>
<li v-for="v in versions" :key="v.version_id">
revision {{ v.revision }} · {{ v.title }} · {{ v.languages.join(' / ') }} ·
{{ v.case_count }} 个测试 · {{ v.published_at }}
</li>
</ul>
<button v-if="versionBefore" :disabled="busy" @click="moreVersions">下一页历史版本</button>
</section>
<section>
<h2>导入新候选</h2>
<p>单层 ZIP,problem.json 清单;最大 64 MiB;不运行上传内容。修改题目请上传新包。</p>
<p v-if="!uploadAvailable">上传或校验服务尚未就绪。可查询已有任务。</p>
<input
aria-label="选择 ZIP 题包"
type="file"
accept=".zip,application/zip"
:disabled="busy || !uploadAvailable"
@change="chooseFile"
/>
<p>{{ fileName }}</p>
<button :disabled="busy || !file || !uploadAvailable" @click="upload">上传并校验</button
><button :disabled="busy" @click="run(() => loadImports())">刷新我的导入</button>
<ul class="admin-import-list">
<li v-for="id in imports" :key="id">
<button :disabled="busy" @click="chooseTask(id)">{{ id }}</button>
</li>
</ul>
<button v-if="importAfter" :disabled="busy" @click="run(() => loadImports(true))">
下一页导入
</button>
</section>
<section v-if="task">
<h2>导入状态:{{ task.state }}</h2>
<p>{{ task.operation_id }} · 到期 {{ task.expires_at }}</p>
<p v-if="task.failure_code" role="alert">校验失败:{{ task.failure_code }}</p>
<button :disabled="busy" @click="chooseTask(task.operation_id)">核对导入状态</button
><button
v-if="['ready', 'failed', 'uploading', 'uploaded'].includes(task.state)"
:disabled="busy"
@click="discard"
>
丢弃候选
</button>
<button
v-if="file && ['failed', 'discarded', 'expired'].includes(task.state)"
:disabled="busy"
@click="restartImport"
>
用新凭据重新导入
</button>
<article v-if="candidate">
<h3>{{ candidate.title }} / {{ candidate.slug }}</h3>
<pre>{{ candidate.statement }}</pre>
<p>{{ candidate.languages.join(' / ') }} · {{ candidate.case_count }} 个隐藏测试</p>
<p>
当前版本:{{
publicationTarget
? `revision ${publicationTarget.revision} · ${publicationTarget.title}`
: '尚未发布'
}}
</p>
<ul>
<li v-for="c in candidate.language_configurations" :key="c.language">
{{ c.language }} / {{ c.runtime_version }} / CPU {{ c.run_limits.cpu_time_us }} μs /
总内存 {{ c.run_limits.memory_bytes }} B
</li>
</ul>
<p v-if="candidate.legacy_budget">
C/C++ CPU {{ candidate.legacy_budget.run_limits.cpu_time_us }} μs / 总内存
{{ candidate.legacy_budget.run_limits.memory_bytes }} B
</p>
<div v-for="(s, i) in candidate.samples" :key="i">
<h4>公开样例 {{ i + 1 }}</h4>
<pre>{{ sampleText(s.input) }}</pre>
<pre>{{ sampleText(s.output) }}</pre>
</div>
<button
v-if="task.state === 'ready'"
:disabled="
busy || !targetReady || (publicationTarget !== null && !publicationTarget.enabled)
"
@click="publish"
>
发布新版本
</button>
<p v-if="task.state === 'published'">发布版本:{{ task.published_version_id }}</p>
</article>
</section>
<section>
<h2>维护审计</h2>
<button :disabled="busy" @click="run(() => loadAudit())">刷新审计</button>
<table>
<thead>
<tr>
<th>时间</th>
<th>主体</th>
<th>动作与结果</th>
<th>操作 UUID</th>
</tr>
</thead>
<tbody>
<tr v-for="a in audits" :key="a.id">
<td>{{ a.created_at }}</td>
<td>{{ a.operator_name ?? a.actor_id }}</td>
<td>{{ a.action }} / {{ a.result }}</td>
<td>{{ a.operation_id }}</td>
</tr>
</tbody>
</table>
<button v-if="auditBefore" :disabled="busy" @click="run(() => loadAudit(true))">
下一页审计
</button>
</section>
</section>
</template>
<style scoped>
.admin-real-panel {
display: grid;
gap: 1rem;
padding: 1.5rem;
background: var(--surface, white);
}
.admin-real-panel section {
border-top: 1px solid #ddd;
padding-top: 1rem;
}
.admin-real-panel button {
margin: 0.3rem;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
.admin-real-panel button:disabled {
cursor: default;
opacity: 0.55;
}
.admin-real-panel table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.admin-real-panel td,
.admin-real-panel th {
padding: 0.6rem;
border-bottom: 1px solid #ddd;
overflow-wrap: anywhere;
}
.admin-real-panel pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 24rem;
overflow: auto;
}
.admin-import-list {
max-height: 16rem;
overflow: auto;
}
.admin-real-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<section class="admin-real-panel">
<h1>{{ route.path === '/forbidden' ? '无题目维护权限' : '身份服务暂时不可用' }}</h1>
<p>请核对当前账号或稍后重新验证;此页面没有管理操作权限。</p>
<RouterLink to="/login">更换账号</RouterLink> · <RouterLink to="/problems">重新验证</RouterLink>
</section>
</template>
+16 -259
View File
@@ -1,272 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { problems, contests, rankings } from '../data/preview'
import PageHeading from '../components/PageHeading.vue' import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue' import PanelCard from '../components/PanelCard.vue'
import SubmissionChart from '../components/SubmissionChart.vue' import { adminSections } from './navigation'
import EmptyState from '../components/EmptyState.vue'
import { adminSections as sections } from './navigation'
const route = useRoute() const route = useRoute()
const section = computed(() => String(route.params.section || 'overview')) const section = computed(() => String(route.params.section || 'overview'))
const current = computed(() => sections.find((s) => s.id === section.value)) const current = computed(() => adminSections.find((item) => item.id === section.value))
const query = ref('')
const problemItems = computed(() => problems.filter((p) => (p.id + p.title).includes(query.value)))
const contestItems = computed(() => contests.filter((c) => c.title.includes(query.value)))
const userItems = computed(() =>
rankings.filter((u) => u.username.toLowerCase().includes(query.value.toLowerCase())),
)
const editOpen = ref(false)
const draft = reactive({ id: '', title: '', difficulty: '入门', status: '公开' })
const settings = reactive({
name: 'KF OnlineJudge',
slogan: '每一次提交,都有新的收获。',
registration: true,
announcement: '欢迎来到 KF OnlineJudge',
filing: '',
})
const nodes = [
{
name: 'home-worker-01',
host: 'Debian',
capacity: '4 核 / 8 GB',
status: '在线',
load: 32,
tasks: 2,
},
{
name: 'cloud-worker-01',
host: 'Ubuntu',
capacity: '8 核 / 8 GB',
status: '在线',
load: 46,
tasks: 4,
},
{
name: 'cloud-worker-02',
host: 'Ubuntu',
capacity: '2 核 / 2 GB',
status: '离线',
load: 0,
tasks: 0,
},
]
function editProblem(id?: string) {
const row = problems.find((problem) => problem.id === id)
Object.assign(draft, {
id: row?.id ?? '',
title: row?.title ?? '',
difficulty: row?.difficulty ?? '入门',
status: '公开',
})
editOpen.value = true
}
</script> </script>
<template> <template>
<PageHeading <PageHeading
title="管理中心" :title="current?.title ?? '页面不存在'"
eyebrow="ADMINISTRATION" eyebrow="ADMINISTRATION"
description="管理界面预览 · 管理员身份与写入接口待接入。" description="题目维护与审计已接通真实服务。"
><a href="/"><el-button>返回站点</el-button></a></PageHeading />
<PanelCard v-if="section === 'overview'" title="管理工作台" icon="connection"
><p>当前可维护不可变题目版本、上传并校验题包、下架或恢复题目,以及查询维护审计。</p>
<RouterLink to="/problems">打开题目维护</RouterLink>
<p>比赛、用户管理、评测节点和站点设置仍在开发中。</p></PanelCard
> >
<div class="admin-shell"> <PanelCard v-else-if="current" :title="`${current.title} · 预览`" :icon="current.icon"
<div v-if="current" class="stack"> ><p>本区尚未接入业务接口,暂无真实统计或写入操作。</p>
<template v-if="section === 'overview'" <button disabled>尚未开放</button></PanelCard
><div class="summary-grid"> >
<div class="summary-card"> <PanelCard v-else title="无此管理页面" icon="connection"
<p>公开题目</p> ><RouterLink to="/">返回管理工作台</RouterLink></PanelCard
<strong>248</strong><small>示例统计</small>
</div>
<div class="summary-card">
<p>累计提交</p>
<strong>18,240</strong><small>示例统计</small>
</div>
<div class="summary-card">
<p>注册用户</p>
<strong>328</strong><small>示例统计</small>
</div>
<div class="summary-card">
<p>在线节点</p>
<strong>2 / 3</strong><small>示例统计</small>
</div>
</div>
<PanelCard title="提交趋势" icon="rankings"><SubmissionChart /></PanelCard
><PanelCard title="开发接入状态" icon="connection"
><el-descriptions :column="1" border
><el-descriptions-item label="当前界面">前端预览</el-descriptions-item
><el-descriptions-item label="账号与权限">待接入</el-descriptions-item
><el-descriptions-item label="评测与节点状态">待接入</el-descriptions-item
><el-descriptions-item label="管理写入与操作审计"
>待接入</el-descriptions-item
></el-descriptions
></PanelCard
></template
>
<PanelCard
v-else-if="section === 'problems'"
title="题目管理"
icon="problems"
subtitle="示例题库"
><template #actions
><el-button size="small" type="primary" @click="editProblem()"
>新建题目草稿</el-button
></template
>
<div class="filters">
<el-input
v-model="query"
placeholder="搜索题号或名称"
aria-label="搜索管理题目"
clearable
/>
</div>
<el-table :data="problemItems" stripe
><el-table-column label="题号" prop="id" width="80" /><el-table-column
label="名称"
prop="title"
min-width="190"
/><el-table-column label="难度" prop="difficulty" width="90" /><el-table-column
label="更新日期"
prop="updated"
width="130"
/><el-table-column label="操作" width="150"
><template #default="{ row }"
><el-button link type="primary" @click="editProblem(row.id)">编辑预览</el-button
><a :href="'/problems/' + row.id" class="text-link" style="margin-left: 10px"
>查看</a
></template
></el-table-column
></el-table
></PanelCard
>
<PanelCard
v-else-if="section === 'contests'"
title="比赛管理"
icon="contests"
subtitle="示例赛程"
><template #actions
><el-button size="small" type="primary" disabled>创建比赛</el-button></template
>
<div class="filters">
<el-input v-model="query" placeholder="搜索比赛" aria-label="搜索管理比赛" clearable />
</div>
<el-table :data="contestItems" stripe
><el-table-column label="比赛名称" prop="title" min-width="220" /><el-table-column
label="赛制"
prop="type"
width="75"
/><el-table-column label="状态" prop="status" width="120" /><el-table-column
label="开始时间"
prop="start"
min-width="165"
/><el-table-column label="操作" width="90"
><template #default="{ row }"
><a class="text-link" :href="'/contests/' + row.id">查看</a></template
></el-table-column
></el-table
></PanelCard
>
<PanelCard v-else-if="section === 'users'" title="用户管理" icon="teams" subtitle="示例用户"
><div class="filters">
<el-input v-model="query" placeholder="搜索用户名" aria-label="搜索管理用户" clearable />
</div>
<el-table :data="userItems" stripe
><el-table-column label="用户名" prop="username" min-width="170" /><el-table-column
label="通过题目"
prop="solved"
width="100"
/><el-table-column label="竞赛积分" prop="rating" width="110" /><el-table-column
label="角色"
width="90"
><template #default>用户</template></el-table-column
><el-table-column label="操作" width="160"
><template #default="{ row }"
><a class="text-link" :href="'/users/' + row.username">查看主页</a
><el-button link disabled style="margin-left: 12px">修改角色</el-button></template
></el-table-column
></el-table
></PanelCard
>
<PanelCard
v-else-if="section === 'judges'"
title="判题节点"
icon="connection"
subtitle="示例监控数据"
><p class="notice-strip">实际节点、负载、心跳和任务状态将在协调器接口接入后展示。</p>
<el-table :data="nodes" stripe
><el-table-column label="节点" prop="name" min-width="170" /><el-table-column
label="系统"
prop="host"
width="100"
/><el-table-column label="资源" prop="capacity" min-width="135" /><el-table-column
label="状态"
width="90"
><template #default="{ row }"
><span class="status-dot" :class="{ offline: row.status === '离线' }" />{{
row.status
}}</template
></el-table-column
><el-table-column label="负载" min-width="140"
><template #default="{ row }"
><el-progress :percentage="row.load" :stroke-width="5" /></template></el-table-column
><el-table-column label="任务" prop="tasks" width="70" /><el-table-column
label="操作"
width="100"
><template #default
><el-button link disabled>暂停接单</el-button></template
></el-table-column
></el-table
>
<div class="comment-placeholder">
队列积压、租约过期、重试记录与故障恢复操作待接入。
</div></PanelCard
>
<PanelCard v-else-if="section === 'settings'" title="站点设置" icon="settings"
><el-form label-position="top" style="max-width: 700px" @submit.prevent
><el-form-item label="站点名称"><el-input v-model="settings.name" /></el-form-item
><el-form-item label="站点标语"><el-input v-model="settings.slogan" /></el-form-item
><el-form-item label="首页公告"
><el-input v-model="settings.announcement" type="textarea" :rows="3" /></el-form-item
><el-form-item label="备案信息"
><el-input
v-model="settings.filing"
placeholder="上线前填写真实备案信息" /></el-form-item
><el-form-item label="允许注册"
><el-switch v-model="settings.registration"
/></el-form-item>
<div class="form-actions">
<el-button type="primary" disabled>保存设置</el-button
><span class="muted">当前编辑只在本页面预览</span>
</div></el-form
></PanelCard
>
</div>
<EmptyState v-else title="管理页面不存在" />
</div>
<el-dialog
v-model="editOpen"
:title="draft.id ? '编辑题目 · ' + draft.id : '新建题目草稿'"
width="min(600px, 92vw)"
><el-form label-position="top"
><el-form-item label="题目名称"
><el-input v-model="draft.title" placeholder="填写题目名称" /></el-form-item
><el-form-item label="难度"
><el-select v-model="draft.difficulty"
><el-option
v-for="d in ['入门', '简单', '中等', '困难']"
:key="d"
:label="d"
:value="d" /></el-select></el-form-item
><el-form-item label="发布状态"
><el-radio-group v-model="draft.status"
><el-radio value="公开">公开</el-radio
><el-radio value="草稿">草稿</el-radio></el-radio-group
></el-form-item
>
<p class="small muted">题面编辑、测试数据和版本发布将在题库接口接入后启用。</p></el-form
><template #footer
><el-button @click="editOpen = false">关闭预览</el-button
><el-button type="primary" disabled>保存题目</el-button></template
></el-dialog
> >
</template> </template>
+90
View File
@@ -0,0 +1,90 @@
// Persist UUID receipts, never file bytes, passwords, roles or session tokens.
export type ImportIntent = {
owner: string
operation_id: string
archive_digest: string
archive_bytes: number
created: number
}
export type MutationIntent = {
owner: string
operation_id: string
fingerprint: string
created: number
}
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
export function importIntent(
previous: ImportIntent | null,
owner: string,
digest: string,
bytes: number,
now = Date.now(),
makeId = () => crypto.randomUUID(),
): ImportIntent {
if (!/^[0-9a-f]{64}$/.test(digest) || !Number.isInteger(bytes) || bytes < 1 || bytes > 67108864)
throw new Error('请选择 1 字节至 64 MiB 的 ZIP 题包。')
if (
previous &&
previous.owner === owner &&
previous.archive_digest === digest &&
previous.archive_bytes === bytes &&
uuid.test(previous.operation_id) &&
now >= previous.created &&
now - previous.created < 86400000
)
return previous
return {
owner,
operation_id: makeId(),
archive_digest: digest,
archive_bytes: bytes,
created: now,
}
}
export function mutationIntent(
previous: MutationIntent | null,
owner: string,
fingerprint: string,
now = Date.now(),
makeId = () => crypto.randomUUID(),
): MutationIntent {
if (
previous &&
previous.owner === owner &&
previous.fingerprint === fingerprint &&
uuid.test(previous.operation_id) &&
now >= previous.created &&
now - previous.created < 86400000
)
return previous
return { owner, operation_id: makeId(), fingerprint, created: now }
}
const prefix = 'kf-admin-receipt-v1-'
export function loadIntent<T>(kind: 'import' | 'mutation'): T | null {
try {
const value = localStorage.getItem(prefix + kind)
return value && value.length <= 2048 ? (JSON.parse(value) as T) : null
} catch {
return null
}
}
export function saveIntent(
kind: 'import' | 'mutation',
value: ImportIntent | MutationIntent | null,
) {
try {
if (value) localStorage.setItem(prefix + kind, JSON.stringify(value))
else localStorage.removeItem(prefix + kind)
} catch {
/* In-memory intent still deduplicates this page. */
}
}
export function clearIntents() {
saveIntent('import', null)
saveIntent('mutation', null)
}
export function hasMaintenance(
user: { capabilities?: { problem_maintenance: boolean } } | null,
): boolean {
return user?.capabilities?.problem_maintenance === true
}
+28 -3
View File
@@ -1,12 +1,37 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import { adminSections } from './navigation' import { adminSections } from './navigation'
import { useSession } from '../composables/useSession'
import { hasMaintenance } from './intents'
export const adminRouter = createRouter({ export const adminRouter = createRouter({
history: createWebHashHistory('/admin/index.html'), history: createWebHashHistory('/admin/index.html'),
scrollBehavior: () => ({ top: 0 }), scrollBehavior: () => ({ top: 0 }),
routes: [{ path: '/:section?', component: () => import('./AdminView.vue') }], routes: [
{ path: '/login', component: () => import('./AdminLogin.vue'), meta: { public: true } },
{
path: '/forbidden',
component: () => import('./AdminUnavailable.vue'),
meta: { public: true },
},
{
path: '/unavailable',
component: () => import('./AdminUnavailable.vue'),
meta: { public: true },
},
{ path: '/problems', component: () => import('./AdminProblems.vue') },
{ path: '/:section?', component: () => import('./AdminView.vue') },
],
})
adminRouter.beforeEach(async (to) => {
if (to.meta.public) return true
const session = useSession()
try {
if (!(await session.refresh())) return '/login'
return hasMaintenance(session.user.value) ? true : '/forbidden'
} catch {
return '/unavailable'
}
}) })
adminRouter.afterEach((to) => { adminRouter.afterEach((to) => {
const section = adminSections.find((item) => item.id === (to.params.section || 'overview')) const section = adminSections.find((item) => item.id === (to.params.section || 'overview'))
document.title = `${section?.title ?? '页面不存在'} · KF 管理控制台` document.title = `${to.path === '/problems' ? '题目维护' : (section?.title ?? '管理员身份')} · KF 管理控制台`
}) })
+126
View File
@@ -0,0 +1,126 @@
import { api, uploadApi } from './client'
import { csrfHeaders, unwrap, ApiError } from './transport'
import type { components } from './generated/schema'
export type ImportDetail = components['schemas']['ProblemImportDetail']
export type ManagedProblem = components['schemas']['MaintainedProblem']
export type PublishRequest = components['schemas']['PublishCandidate']
export type AvailabilityRequest = components['schemas']['ChangeProblemAvailability']
export type Candidate = components['schemas']['CandidatePreview']
export type Version = components['schemas']['MaintainedVersion']
export type AuditEvent = components['schemas']['MaintenanceAuditEvent']
export const adminProblems = {
capabilities: async (signal: AbortSignal) =>
unwrap(await api.GET('/api/v1/admin/problem-imports/capabilities', { signal })),
page: async (signal: AbortSignal, after?: string, enabled?: boolean) =>
unwrap(
await api.GET('/api/v1/admin/problems', {
signal,
params: { query: { after, enabled, limit: 20 } },
}),
),
bySlug: async (slug: string, signal: AbortSignal) =>
unwrap(
await api.GET('/api/v1/admin/problem-slugs/{slug}', { signal, params: { path: { slug } } }),
),
detail: async (id: string, signal: AbortSignal) =>
unwrap(
await api.GET('/api/v1/admin/problems/{problem_id}', {
signal,
params: { path: { problem_id: id } },
}),
),
versions: async (id: string, signal: AbortSignal, before?: number) =>
unwrap(
await api.GET('/api/v1/admin/problems/{problem_id}/versions', {
signal,
params: { path: { problem_id: id }, query: { before, limit: 20 } },
}),
),
imports: async (signal: AbortSignal, after?: string) =>
unwrap(
await api.GET('/api/v1/admin/problem-imports', {
signal,
params: { query: { after, limit: 20 } },
}),
),
import: async (id: string, signal: AbortSignal) =>
unwrap(
await api.GET('/api/v1/admin/problem-imports/{operation_id}', {
signal,
params: { path: { operation_id: id } },
}),
),
create: async (body: components['schemas']['CreateProblemImport'], signal: AbortSignal) =>
unwrap(
await api.POST('/api/v1/admin/problem-imports', { body, signal, headers: csrfHeaders() }),
),
upload: async (id: string, file: File, signal: AbortSignal) =>
unwrap(
await uploadApi.PUT('/api/v1/admin/problem-imports/{operation_id}/archive', {
signal,
params: { path: { operation_id: id } },
headers: { ...csrfHeaders(), 'Content-Type': 'application/zip' },
body: file as unknown as string,
bodySerializer: (value) => value as unknown as Blob,
}),
),
discard: async (id: string, signal: AbortSignal) =>
unwrap(
await api.POST('/api/v1/admin/problem-imports/{operation_id}/discard', {
signal,
params: { path: { operation_id: id } },
headers: csrfHeaders(),
}),
),
publish: async (body: PublishRequest, signal: AbortSignal) =>
unwrap(
await api.POST('/api/v1/admin/problem-publications', {
signal,
body,
headers: csrfHeaders(),
}),
),
availability: async (
id: string,
enabled: boolean,
body: AvailabilityRequest,
signal: AbortSignal,
) => {
const options = { signal, body, params: { path: { problem_id: id } }, headers: csrfHeaders() }
return unwrap(
enabled
? await api.POST('/api/v1/admin/problems/{problem_id}/restore', options)
: await api.POST('/api/v1/admin/problems/{problem_id}/disable', options),
)
},
receipt: async (id: string, signal: AbortSignal) =>
unwrap(
await api.GET('/api/v1/admin/problem-maintenance/{operation_id}', {
signal,
params: { path: { operation_id: id } },
}),
),
audit: async (signal: AbortSignal, before?: string) =>
unwrap(
await api.GET('/api/v1/admin/maintenance-audit', {
signal,
params: { query: { before, limit: 20 } },
}),
),
}
export function maintenanceError(error: unknown): string {
if (error instanceof ApiError) {
const text: Record<number, string> = {
403: '维护权限已失效或请求未通过 CSRF 验证,请重新核对身份。',
408: '上传超过 30 秒期限,请重新选择文件并核对导入状态。',
409: '候选或活动版本已变化。请刷新并核对服务器状态后重新操作。',
413: '题包超出 64 MiB 限制。',
415: '请上传单层 ZIP 题包。',
503: '维护服务暂时不可用;操作凭据已保留,可核对后重试。',
}
return text[error.status] ?? error.message
}
return error instanceof Error ? error.message : '维护请求失败。'
}
+12
View File
@@ -19,3 +19,15 @@ export const api = createClient<paths>({
() => authorizationVersion, () => authorizationVersion,
), ),
}) })
// Only the bounded administrator ZIP endpoint uses the longer upload deadline.
// Ordinary login/submission requests keep their existing ten-second budget.
export const uploadApi = createClient<paths>({
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
fetch: boundedFetch(
globalThis.fetch.bind(globalThis),
() => expired(),
() => authorizationVersion,
40000,
),
})
+2 -1
View File
@@ -19,6 +19,7 @@ export function boundedFetch(
fetcher: typeof fetch, fetcher: typeof fetch,
unauthorized: () => void, unauthorized: () => void,
authorizationVersion: () => number = () => 0, authorizationVersion: () => number = () => 0,
deadlineMs = 10000,
): typeof fetch { ): typeof fetch {
return async (input, init) => { return async (input, init) => {
const version = authorizationVersion() const version = authorizationVersion()
@@ -28,7 +29,7 @@ export function boundedFetch(
new Request(request, { new Request(request, {
credentials: 'same-origin', credentials: 'same-origin',
cache: 'no-store', cache: 'no-store',
signal: AbortSignal.any([request.signal, AbortSignal.timeout(10000)]), signal: AbortSignal.any([request.signal, AbortSignal.timeout(deadlineMs)]),
}), }),
) )
if (response.status === 401 && version === authorizationVersion()) unauthorized() if (response.status === 401 && version === authorizationVersion()) unauthorized()
+50
View File
@@ -0,0 +1,50 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { importIntent, mutationIntent, hasMaintenance } from '../src/admin/intents.ts'
const first = '11111111-1111-4111-8111-111111111111',
second = '22222222-2222-4222-8222-222222222222'
test('import receipt survives retry but new content/actor/expiry receives a new UUID', () => {
const value = importIntent(null, 'owner', 'a'.repeat(64), 100, 1000, () => first)
assert.equal(
importIntent(value, 'owner', 'a'.repeat(64), 100, 2000, () => second),
value,
)
assert.equal(
importIntent(value, 'owner', 'b'.repeat(64), 100, 2000, () => second).operation_id,
second,
)
assert.equal(
importIntent(value, 'other', 'a'.repeat(64), 100, 2000, () => second).operation_id,
second,
)
assert.equal(
importIntent(value, 'owner', 'a'.repeat(64), 100, 86402000, () => second).operation_id,
second,
)
assert.throws(() => importIntent(value, 'owner', 'a'.repeat(64), 67108865))
assert.deepEqual(Object.keys(value).sort(), [
'archive_bytes',
'archive_digest',
'created',
'operation_id',
'owner',
])
})
test('mutation retries preserve UUID and changing any precondition creates a new receipt', () => {
const v = mutationIntent(null, 'owner', 'publish:candidate:v1', 1000, () => first)
assert.equal(
mutationIntent(v, 'owner', 'publish:candidate:v1', 2000, () => second),
v,
)
assert.equal(
mutationIntent(v, 'owner', 'publish:candidate:v2', 2000, () => second).operation_id,
second,
)
assert.equal(
mutationIntent(v, 'owner', 'disable:problem:v1', 2000, () => second).operation_id,
second,
)
assert.equal(hasMaintenance(null), false)
assert.equal(hasMaintenance({ capabilities: { problem_maintenance: false } }), false)
assert.equal(hasMaintenance({ capabilities: { problem_maintenance: true } }), true)
})