feat(admin): connect authenticated problem maintenance console
This commit is contained in:
@@ -46,3 +46,15 @@ POST前先保存 UUID,双击共用请求,网络超时/刷新重试复用原
|
||||
运行指标仅来自完整持久化报告,null显示“—”;诊断和源码按纯文本显示。
|
||||
`pnpm test` 覆盖草稿、幂等重试、轮询期限/隐藏/终态/失效响应和401处理,
|
||||
已纳入 `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/浏览器验收。
|
||||
|
||||
@@ -1,9 +1,49 @@
|
||||
<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 UiIcon from '../components/UiIcon.vue'
|
||||
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>
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
@@ -13,7 +53,10 @@ const route = useRoute()
|
||||
><span class="brand-mark">KF.</span
|
||||
><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>
|
||||
</header>
|
||||
<nav class="admin-app-nav" aria-label="管理导航">
|
||||
@@ -22,17 +65,22 @@ const route = useRoute()
|
||||
:key="section.id"
|
||||
:to="section.id === 'overview' ? '/' : '/' + section.id"
|
||||
: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
|
||||
>
|
||||
</nav>
|
||||
<main class="admin-app-main">
|
||||
<p v-if="identityError" role="alert">{{ identityError }}</p>
|
||||
<RouterView v-slot="{ Component, route: currentRoute }"
|
||||
><component :is="Component" :key="currentRoute.path"
|
||||
/></RouterView>
|
||||
</main>
|
||||
<footer class="admin-app-footer">
|
||||
KF OnlineJudge / 管理控制台<span>示例数据 · 管理员授权与业务接口待接入</span>
|
||||
KF OnlineJudge / 管理控制台<span>题目维护已接通 · 其他业务区为明确预览</span>
|
||||
</footer>
|
||||
</div>
|
||||
</el-config-provider>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,272 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { problems, contests, rankings } from '../data/preview'
|
||||
import PageHeading from '../components/PageHeading.vue'
|
||||
import PanelCard from '../components/PanelCard.vue'
|
||||
import SubmissionChart from '../components/SubmissionChart.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import { adminSections as sections } from './navigation'
|
||||
import { adminSections } from './navigation'
|
||||
const route = useRoute()
|
||||
const section = computed(() => String(route.params.section || 'overview'))
|
||||
const current = computed(() => sections.find((s) => s.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
|
||||
}
|
||||
const current = computed(() => adminSections.find((item) => item.id === section.value))
|
||||
</script>
|
||||
<template>
|
||||
<PageHeading
|
||||
title="管理中心"
|
||||
:title="current?.title ?? '页面不存在'"
|
||||
eyebrow="ADMINISTRATION"
|
||||
description="管理界面预览 · 管理员身份与写入接口待接入。"
|
||||
><a href="/"><el-button>返回站点</el-button></a></PageHeading
|
||||
description="题目维护与审计已接通真实服务。"
|
||||
/>
|
||||
<PanelCard v-if="section === 'overview'" title="管理工作台" icon="connection"
|
||||
><p>当前可维护不可变题目版本、上传并校验题包、下架或恢复题目,以及查询维护审计。</p>
|
||||
<RouterLink to="/problems">打开题目维护</RouterLink>
|
||||
<p>比赛、用户管理、评测节点和站点设置仍在开发中。</p></PanelCard
|
||||
>
|
||||
<div class="admin-shell">
|
||||
<div v-if="current" class="stack">
|
||||
<template v-if="section === 'overview'"
|
||||
><div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<p>公开题目</p>
|
||||
<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
|
||||
<PanelCard v-else-if="current" :title="`${current.title} · 预览`" :icon="current.icon"
|
||||
><p>本区尚未接入业务接口,暂无真实统计或写入操作。</p>
|
||||
<button disabled>尚未开放</button></PanelCard
|
||||
>
|
||||
<PanelCard v-else title="无此管理页面" icon="connection"
|
||||
><RouterLink to="/">返回管理工作台</RouterLink></PanelCard
|
||||
>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,12 +1,37 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { adminSections } from './navigation'
|
||||
|
||||
import { useSession } from '../composables/useSession'
|
||||
import { hasMaintenance } from './intents'
|
||||
export const adminRouter = createRouter({
|
||||
history: createWebHashHistory('/admin/index.html'),
|
||||
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) => {
|
||||
const section = adminSections.find((item) => item.id === (to.params.section || 'overview'))
|
||||
document.title = `${section?.title ?? '页面不存在'} · KF 管理控制台`
|
||||
document.title = `${to.path === '/problems' ? '题目维护' : (section?.title ?? '管理员身份')} · KF 管理控制台`
|
||||
})
|
||||
|
||||
@@ -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 : '维护请求失败。'
|
||||
}
|
||||
@@ -19,3 +19,15 @@ export const api = createClient<paths>({
|
||||
() => 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,
|
||||
),
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ export function boundedFetch(
|
||||
fetcher: typeof fetch,
|
||||
unauthorized: () => void,
|
||||
authorizationVersion: () => number = () => 0,
|
||||
deadlineMs = 10000,
|
||||
): typeof fetch {
|
||||
return async (input, init) => {
|
||||
const version = authorizationVersion()
|
||||
@@ -28,7 +29,7 @@ export function boundedFetch(
|
||||
new Request(request, {
|
||||
credentials: 'same-origin',
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user