feat(admin): connect authenticated problem maintenance console
This commit is contained in:
@@ -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/浏览器验收。
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
<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>
|
||||||
|
|||||||
@@ -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 { 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 管理控制台`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
() => 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,
|
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()
|
||||||
|
|||||||
@@ -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