feat(platform): connect owned submissions and real browser flows

This commit is contained in:
2026-10-09 09:30:12 +08:00
parent 19a071573b
commit fb1b161c0d
31 changed files with 2669 additions and 388 deletions
+7
View File
@@ -1,6 +1,13 @@
import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
import { boundedFetch } from './transport'
let expired: () => void = () => {}
export function onUnauthorized(handler: () => void) {
expired = handler
}
export const api = createClient<paths>({
baseUrl: import.meta.env.VITE_API_BASE_URL || '',
fetch: boundedFetch(globalThis.fetch.bind(globalThis), () => expired()),
})
+333 -1
View File
@@ -150,7 +150,8 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/** History */
get: operations["history_api_v1_submissions_get"];
put?: never;
/** Create */
post: operations["create_api_v1_submissions_post"];
@@ -160,6 +161,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/submissions/{submission_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Detail */
get: operations["detail_api_v1_submissions__submission_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/submissions/{submission_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Cancel */
post: operations["cancel_api_v1_submissions__submission_id__cancel_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/submissions/{submission_id}/source": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Source */
get: operations["source_api_v1_submissions__submission_id__source_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -171,6 +223,30 @@ export interface components {
/** Digest */
digest: string;
};
/** CancellationAccepted */
CancellationAccepted: {
/**
* Accepted
* @default true
* @constant
*/
accepted: true;
/**
* Evaluation Id
* Format: uuid
*/
evaluation_id: string;
/**
* Submission Id
* Format: uuid
*/
submission_id: string;
};
/**
* CompilationStatus
* @enum {string}
*/
CompilationStatus: "succeeded" | "compilation_error" | "compilation_limit" | "cancelled" | "system_error";
/** CreateSubmission */
CreateSubmission: {
/**
@@ -197,6 +273,11 @@ export interface components {
/** Username */
username: string;
};
/**
* FailureCode
* @enum {string}
*/
FailureCode: "artifact_unavailable" | "sandbox_unavailable" | "cleanup_failed" | "lease_lost" | "job_deadline";
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
@@ -339,6 +420,32 @@ export interface components {
*/
version_id: string;
};
/** PublicCaseResult */
PublicCaseResult: {
/** Cpu Time Us */
cpu_time_us: number | null;
/** Memory Peak Bytes */
memory_peak_bytes: number | null;
/** Position */
position: number;
verdict: components["schemas"]["Verdict"];
};
/** PublicJudgement */
PublicJudgement: {
/** Cases */
cases: components["schemas"]["PublicCaseResult"][];
/** Cleanup Complete */
cleanup_complete: boolean;
compilation_status: components["schemas"]["CompilationStatus"];
/** Cpu Time Us */
cpu_time_us: number | null;
/** Diagnostics */
diagnostics: string;
/** Diagnostics Truncated */
diagnostics_truncated: boolean;
/** Memory Peak Bytes */
memory_peak_bytes: number | null;
};
/** PublicSample */
PublicSample: {
/**
@@ -404,6 +511,101 @@ export interface components {
*/
submission_id: string;
};
/** SubmissionDetail */
SubmissionDetail: {
/** Attempts */
attempts: number;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Evaluation Id
* Format: uuid
*/
evaluation_id: string;
failure_code: components["schemas"]["FailureCode"] | null;
language: components["schemas"]["Language"];
/**
* Problem Version Id
* Format: uuid
*/
problem_version_id: string;
result: components["schemas"]["PublicJudgement"] | null;
/**
* State
* @enum {string}
*/
state: "queued" | "claimed" | "compiling" | "running" | "cancelling" | "finished" | "cancelled" | "failed";
/**
* Submission Id
* Format: uuid
*/
submission_id: string;
/**
* Updated At
* Format: date-time
*/
updated_at: string;
verdict: components["schemas"]["Verdict"] | null;
};
/** SubmissionPage */
SubmissionPage: {
/** Items */
items: components["schemas"]["SubmissionSummary"][];
/** Next After */
next_after: string | null;
};
/** SubmissionSource */
SubmissionSource: {
language: components["schemas"]["Language"];
/** Source */
source: string;
/**
* Submission Id
* Format: uuid
*/
submission_id: string;
};
/** SubmissionSummary */
SubmissionSummary: {
/** Attempts */
attempts: number;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Evaluation Id
* Format: uuid
*/
evaluation_id: string;
failure_code: components["schemas"]["FailureCode"] | null;
language: components["schemas"]["Language"];
/**
* Problem Version Id
* Format: uuid
*/
problem_version_id: string;
/**
* State
* @enum {string}
*/
state: "queued" | "claimed" | "compiling" | "running" | "cancelling" | "finished" | "cancelled" | "failed";
/**
* Submission Id
* Format: uuid
*/
submission_id: string;
/**
* Updated At
* Format: date-time
*/
updated_at: string;
verdict: components["schemas"]["Verdict"] | null;
};
/** TestCase */
TestCase: {
/**
@@ -439,6 +641,11 @@ export interface components {
/** Error Type */
type: string;
};
/**
* Verdict
* @enum {string}
*/
Verdict: "accepted" | "wrong_answer" | "compilation_error" | "runtime_error" | "time_limit_exceeded" | "memory_limit_exceeded" | "output_limit_exceeded" | "cancelled" | "system_error";
};
responses: never;
parameters: never;
@@ -673,6 +880,38 @@ export interface operations {
};
};
};
history_api_v1_submissions_get: {
parameters: {
query?: {
after?: string | null;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SubmissionPage"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_api_v1_submissions_post: {
parameters: {
query?: never;
@@ -706,4 +945,97 @@ export interface operations {
};
};
};
detail_api_v1_submissions__submission_id__get: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SubmissionDetail"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
cancel_api_v1_submissions__submission_id__cancel_post: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CancellationAccepted"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
source_api_v1_submissions__submission_id__source_get: {
parameters: {
query?: never;
header?: never;
path: {
submission_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SubmissionSource"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}
+71
View File
@@ -0,0 +1,71 @@
export function boundedPoll<T>(options: {
load: (signal: AbortSignal) => Promise<T>
terminal: (value: T) => boolean
result: (value: T) => void
error: (error: unknown) => void
exhausted: () => void
interval?: number
maxRequests?: number
maxTime?: number
}) {
let timer: ReturnType<typeof setTimeout> | undefined
let request: AbortController | undefined
let generation = 0
let disposed = false
let visible = true
let active = false
let count = 0
let deadline = 0
function stop() {
generation++
clearTimeout(timer)
request?.abort()
request = undefined
}
async function tick() {
if (!active || !visible || disposed) return
if (count >= (options.maxRequests ?? 240) || Date.now() >= deadline) {
active = false
options.exhausted()
return
}
const current = generation
const controller = new AbortController()
request = controller
count++
try {
const value = await options.load(controller.signal)
if (current !== generation || disposed) return
options.result(value)
if (options.terminal(value)) active = false
if (active) timer = setTimeout(tick, options.interval ?? 2000)
} catch (error) {
if (current !== generation || disposed) return
active = false
options.error(error)
} finally {
if (request === controller) request = undefined
}
}
return {
start() {
if (disposed) return
stop()
active = true
count = 0
deadline = Date.now() + (options.maxTime ?? 600000)
void tick()
},
visible(value: boolean) {
if (disposed || visible === value) return
visible = value
stop()
if (visible) void tick()
},
dispose() {
disposed = true
active = false
stop()
},
}
}
+59
View File
@@ -0,0 +1,59 @@
// No source is stored here. Fingerprints bind an immutable request and owner to a UUID.
export type SubmissionInput = {
problem_version_id: string
language: 'c17' | 'cpp20'
source: string
}
type Intent = { fingerprint: string; key: string; submissionId?: string }
export function submissionIntent(storage: Pick<Storage, 'getItem' | 'setItem'>, owner: string) {
const prefix = 'kf-oj:submission-intent:' + owner + ':'
let pending: Promise<string> | undefined
return {
submit(input: SubmissionInput, send: (key: string) => Promise<string>): Promise<string> {
if (pending) return pending
const snapshot = { ...input }
pending = (async () => {
const bytes = new TextEncoder().encode(snapshot.source)
if (!bytes.length || bytes.length > 262144) throw new Error('源码必须为 1 至 262144 字节。')
const hash = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(JSON.stringify(snapshot)),
)
const fingerprint = Array.from(new Uint8Array(hash), (v) =>
v.toString(16).padStart(2, '0'),
).join('')
const location = prefix + snapshot.problem_version_id
let intent: Intent
try {
const raw: unknown = JSON.parse(storage.getItem(location) ?? 'null')
intent =
raw &&
typeof raw === 'object' &&
'fingerprint' in raw &&
raw.fingerprint === fingerprint &&
'key' in raw &&
typeof raw.key === 'string' &&
/^[0-9a-f-]{36}$/.test(raw.key)
? (raw as Intent)
: { fingerprint, key: crypto.randomUUID() }
storage.setItem(location, JSON.stringify(intent))
} catch {
throw new Error('浏览器无法保存提交凭据。请恢复本地存储后重试,草稿仍在编辑器中。')
}
if (intent.submissionId) return intent.submissionId
const id = await send(intent.key)
// The key was saved BEFORE the network call. If this write fails a retry still uses it.
try {
storage.setItem(location, JSON.stringify({ ...intent, submissionId: id }))
} catch {
/* stable key already persisted */
}
return id
})().finally(() => {
pending = undefined
})
return pending
},
}
}
+55
View File
@@ -0,0 +1,55 @@
export class ApiError extends Error {
status: number
constructor(status: number) {
const messages: Record<number, string> = {
401: '登录已过期,请重新登录。草稿已保留。',
403: '请求未通过会话验证,请刷新后重试。',
404: '记录不存在或无权访问。',
409: '提交凭据与内容不一致,请检查草稿后重试。',
422: '请求不符合题目或提交要求。',
429: '当前容量已满,请稍后重试。',
503: '服务暂时不可用,请稍后重试。',
}
super(messages[status] ?? `请求失败(HTTP ${status})`)
this.status = status
}
}
export function boundedFetch(fetcher: typeof fetch, unauthorized: () => void): typeof fetch {
return async (input, init) => {
const request = new Request(input, init)
try {
const response = await fetcher(
new Request(request, {
credentials: 'same-origin',
cache: 'no-store',
signal: AbortSignal.any([request.signal, AbortSignal.timeout(10000)]),
}),
)
if (response.status === 401) unauthorized()
return response
} catch (error) {
if (request.signal.aborted) throw error
throw new Error('网络中断或请求超时。草稿与提交凭据已保留,可手动重试。', { cause: error })
}
}
}
export function unwrap<T>(result: { data?: T; response: Response }): T {
if (!result.response.ok) throw new ApiError(result.response.status)
if (result.data === undefined) throw new Error('服务返回了不完整的数据,请重试。')
return result.data
}
export function csrfHeaders(): Record<string, string> {
const token = document.cookie
.split('; ')
.find((v) => v.startsWith('kf_csrf='))
?.slice(8)
if (!token || !/^[0-9a-f]{64}$/.test(token)) throw new ApiError(401)
return { 'X-CSRF-Token': token }
}
export function errorText(error: unknown): string {
return error instanceof Error ? error.message : '请求失败,请重试。'
}
+5 -2
View File
@@ -28,7 +28,10 @@ let subscription: monaco.IDisposable | undefined
).MonacoEnvironment = { getWorker: () => new EditorWorker() }
onMounted(() => {
if (!container.value) return
model = monaco.editor.createModel(props.modelValue, props.language)
model = monaco.editor.createModel(
props.modelValue,
props.language === 'c' ? 'cpp' : props.language,
)
editor = monaco.editor.create(container.value, {
model,
theme: props.theme,
@@ -57,7 +60,7 @@ watch(
watch(
() => props.language,
(value) => {
if (model) monaco.editor.setModelLanguage(model, value)
if (model) monaco.editor.setModelLanguage(model, value === 'c' ? 'cpp' : value)
},
)
watch(
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { components } from '../api/generated/schema'
const props = defineProps<{
state: components['schemas']['SubmissionSummary']['state']
verdict: components['schemas']['SubmissionSummary']['verdict']
}>()
const grades: Record<string, string> = {
accepted: 'AC',
wrong_answer: 'WA',
compilation_error: 'CE',
runtime_error: 'RE',
time_limit_exceeded: 'TLE',
memory_limit_exceeded: 'MLE',
output_limit_exceeded: 'OLE',
cancelled: '已取消',
system_error: '系统错误',
}
const states: Record<string, string> = {
queued: '等待评测',
claimed: '已领取',
compiling: '正在编译',
running: '正在运行',
cancelling: '正在停止并清理',
cancelled: '已取消',
failed: '评测失败',
finished: '评测完成',
}
const label = computed(() =>
props.verdict ? (grades[props.verdict] ?? props.verdict) : states[props.state],
)
</script>
<template>
<span class="verdict" data-testid="judgement-status">{{ label }}</span>
</template>
+6 -4
View File
@@ -1,11 +1,13 @@
import { computed, onScopeDispose, ref, watch } from 'vue'
export type Language = 'cpp' | 'python' | 'rust'
export type Language = 'c' | 'cpp' | 'python' | 'rust'
export const languageLabels: Record<Language, string> = {
cpp: 'C++',
c: 'C17',
cpp: 'C++20',
python: 'Python',
rust: 'Rust',
}
export const codeTemplates: Record<Language, string> = {
c: '#include <stdio.h>\n\nint main(void) {\n return 0;\n}\n',
cpp: '#include <iostream>\nusing namespace std;\n\nint main() {\n // 在这里编写你的解法\n return 0;\n}\n',
python: '# 在这里编写你的解法\n\n',
rust: 'use std::io::{self, Read};\n\nfn main() {\n let mut input = String::new();\n io::stdin().read_to_string(&mut input).unwrap();\n // 在这里编写你的解法\n}\n',
@@ -30,11 +32,11 @@ export function useCodeDraft(problemId: string) {
typeof saved.drafts === 'object'
) {
const storedDrafts = saved.drafts as Record<string, unknown>
for (const lang of ['cpp', 'python', 'rust'] as const) {
for (const lang of ['c', 'cpp', 'python', 'rust'] as const) {
const value = storedDrafts[lang]
if (typeof value === 'string') drafts.value[lang] = value
}
if ('language' in saved && ['cpp', 'python', 'rust'].includes(String(saved.language)))
if ('language' in saved && ['c', 'cpp', 'python', 'rust'].includes(String(saved.language)))
language.value = saved.language as Language
state.value = 'saved'
}
+49
View File
@@ -0,0 +1,49 @@
import { readonly, ref } from 'vue'
import { api, onUnauthorized } from '../api/client'
import { ApiError, csrfHeaders, unwrap } from '../api/transport'
import type { components } from '../api/generated/schema'
const user = ref<components['schemas']['CurrentUser'] | null>(null)
let generation = 0
let pending: Promise<boolean> | undefined
onUnauthorized(() => {
generation++
user.value = null
})
async function refresh() {
if (pending) return pending
const current = generation
pending = (async () => {
try {
const data = unwrap(await api.GET('/api/v1/auth/me'))
if (current === generation) user.value = data
return current === generation && user.value !== null
} catch (error) {
if (error instanceof ApiError && error.status === 401) return false
throw error
} finally {
pending = undefined
}
})()
return pending
}
async function login(username: string, password: string) {
const current = ++generation
user.value = null
const data = unwrap(await api.POST('/api/v1/auth/login', { body: { username, password } }))
if (current === generation) user.value = data.user
}
async function logout() {
const response = await api.POST('/api/v1/auth/logout', { headers: csrfHeaders() })
if (!response.response.ok && response.response.status !== 401)
throw new ApiError(response.response.status)
generation++
user.value = null
}
export function useSession() {
return { user: readonly(user), refresh, login, logout }
}
+14 -5
View File
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import UiIcon from '../components/UiIcon.vue'
import { useSession } from '../composables/useSession'
const { user, refresh } = useSession()
onMounted(() => {
void refresh().catch(() => {})
})
const route = useRoute()
const mobileOpen = ref(false)
const links = [
@@ -51,11 +56,15 @@ const activePath = computed(() => '/' + route.path.split('/')[1])
>
</nav>
<div class="header-account">
<el-tooltip content="页面使用示例内容,业务服务逐步接入" placement="bottom"
><span class="preview-label">界面预览</span></el-tooltip
><RouterLink to="/login"
<template v-if="user"
><span>{{ user.username }}</span
><RouterLink to="/logout"
><el-button size="small" round>退出登录</el-button></RouterLink
></template
>
<RouterLink v-else to="/login"
><el-button type="primary" size="small" round>登录</el-button></RouterLink
><RouterLink to="/register"><el-button size="small" round>注册</el-button></RouterLink>
>
</div>
</div>
</header>
+11
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useSession } from '../composables/useSession'
export const router = createRouter({
history: createWebHistory(),
scrollBehavior: (to, _from, saved) => saved ?? (to.hash ? { el: to.hash } : { top: 0 }),
@@ -95,6 +96,16 @@ export const router = createRouter({
},
],
})
router.beforeEach(async (to) => {
if (to.path === '/submissions' || to.path.startsWith('/submissions/') || to.path === '/logout') {
try {
if (await useSession().refresh()) return true
} catch {
return { path: '/login', query: { next: to.fullPath, unavailable: '1' } }
}
return { path: '/login', query: { next: to.fullPath } }
}
})
router.afterEach((to) => {
document.title = `${String(to.meta.title ?? '首页')} · KF OnlineJudge`
})
+54 -56
View File
@@ -1,44 +1,50 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { type FormInstance, type FormRules } from 'element-plus'
import { useSession } from '../composables/useSession'
import { ApiError, errorText } from '../api/transport'
const route = useRoute()
const router = useRouter()
const registering = computed(() => route.path === '/register')
const form = ref<FormInstance>()
const model = reactive({ username: '', email: '', password: '', confirmation: '' })
const rules = computed<FormRules<typeof model>>(() => ({
const model = reactive({ username: '', password: '' })
const busy = ref(false)
const error = ref(route.query.unavailable ? '身份服务暂时无法连接,请稍后重试。' : '')
const rules: FormRules<typeof model> = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
...(registering.value
? [
{
pattern: /^[a-zA-Z0-9_]{3,24}$/,
message: '使用 3 至 24 位字母、数字或下划线',
trigger: 'blur',
},
]
: []),
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请填写有效邮箱', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
...(registering.value ? [{ min: 8, message: '密码至少包含 8 个字符', trigger: 'blur' }] : []),
],
confirmation: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
validator: (_rule, value, callback) =>
value === model.password ? callback() : callback(new Error('两次输入的密码不一致')),
required: true,
pattern: /^[a-z0-9_]{3,24}$/,
message: '使用 3 至 24 位小写字母、数字或下划线',
trigger: 'blur',
},
],
}))
password: [
{ required: true, min: 12, max: 128, message: '请输入 12 至 128 位密码', trigger: 'blur' },
],
}
async function submit() {
if (await form.value?.validate().catch(() => false))
ElMessage.info('账号服务尚未接入,暂时无法' + (registering.value ? '注册' : '登录'))
if (busy.value || registering.value) return
busy.value = true
try {
if (!(await form.value?.validate().catch(() => false))) return
error.value = ''
await useSession().login(model.username, model.password)
model.password = ''
const next =
typeof route.query.next === 'string' &&
route.query.next.startsWith('/') &&
!route.query.next.startsWith('//')
? route.query.next
: '/problems'
await router.replace(next)
} catch (cause) {
error.value =
cause instanceof ApiError && cause.status === 401 ? '用户名或密码不正确。' : errorText(cause)
} finally {
busy.value = false
}
}
</script>
<template>
@@ -54,41 +60,33 @@ async function submit() {
<p class="eyebrow">WELCOME TO KF ONLINEJUDGE</p>
<h2>{{ registering ? '创建你的账号' : '欢迎回来' }}</h2>
<p>{{ registering ? '准备好开始新的练习了吗?' : '继续你的算法练习之旅。' }}</p>
<p v-if="registering" class="notice-strip">
暂不开放自助注册。请使用管理员通过本地账号管理命令创建的账号。
</p>
<el-form
v-else
ref="form"
:model="model"
:rules="rules"
label-position="top"
@submit.prevent="submit"
><el-form-item label="用户名" prop="username"
><el-input
v-model="model.username"
placeholder="请输入用户名"
autocomplete="username" /></el-form-item
><el-form-item v-if="registering" label="邮箱" prop="email"
><el-input
v-model="model.email"
placeholder="your@email.com"
autocomplete="email" /></el-form-item
><el-form-item label="密码" prop="password"
>
<el-form-item label="用户名" prop="username"
><el-input v-model="model.username" autocomplete="username" aria-label="用户名"
/></el-form-item>
<el-form-item label="密码" prop="password"
><el-input
v-model="model.password"
type="password"
show-password
placeholder="请输入密码"
:autocomplete="registering ? 'new-password' : 'current-password'" /></el-form-item
><el-form-item v-if="registering" label="确认密码" prop="confirmation"
><el-input
v-model="model.confirmation"
type="password"
show-password
placeholder="再次输入密码"
autocomplete="new-password" /></el-form-item
><el-button type="primary" native-type="submit">{{
registering ? '注册' : '登录'
}}</el-button></el-form
>
<p class="form-help" style="margin-top: 14px">账号功能待接入,当前不会创建账号或保存密码。</p>
autocomplete="current-password"
aria-label="密码"
/></el-form-item>
<p v-if="error" role="alert">{{ error }}</p>
<el-button type="primary" native-type="submit" :loading="busy">登录</el-button>
</el-form>
<p class="form-help" style="margin-top: 14px">
草稿只保存在当前浏览器,重新登录后可继续编辑。
</p>
<div class="auth-links">
<RouterLink to="/">← 返回首页</RouterLink
><RouterLink :to="registering ? '/login' : '/register'">{{
+27 -5
View File
@@ -1,13 +1,35 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useSession } from '../composables/useSession'
import { errorText } from '../api/transport'
import EmptyState from '../components/EmptyState.vue'
const done = ref(false)
const busy = ref(false)
const error = ref('')
async function logout() {
if (busy.value) return
busy.value = true
error.value = ''
try {
await useSession().logout()
done.value = true
} catch (cause) {
error.value = errorText(cause)
} finally {
busy.value = false
}
}
onMounted(logout)
</script>
<template>
<div class="panel" style="max-width: 650px; margin: 50px auto">
<EmptyState title="当前没有登录会话" description="账号服务接入后,这里会清除会话并退出登录。"
><RouterLink to="/"><el-button type="primary">返回首页</el-button></RouterLink
><RouterLink to="/login" style="margin-left: 12px"
><el-button>去登录</el-button></RouterLink
></EmptyState
<EmptyState
:title="done ? '已退出登录' : '正在退出登录'"
description="当前浏览器的题目草稿已保留。"
>
<p v-if="error" role="alert">{{ error }}</p>
<el-button v-if="error" :loading="busy" @click="logout">重试退出</el-button>
<RouterLink to="/login"><el-button type="primary">去登录</el-button></RouterLink>
</EmptyState>
</div>
</template>
+198 -152
View File
@@ -1,28 +1,42 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRoute } from 'vue-router'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { findProblem, discussions, submissions } from '../data/preview'
import { api } from '../api/client'
import { csrfHeaders, errorText, unwrap } from '../api/transport'
import { submissionIntent, type SubmissionInput } from '../api/submissionIntent'
import type { components } from '../api/generated/schema'
import { useSession } from '../composables/useSession'
import { useCodeDraft, codeTemplates, languageLabels } from '../composables/useCodeDraft'
import CodeEditor from '../components/CodeEditor.vue'
import DifficultyTag from '../components/DifficultyTag.vue'
import VerdictTag from '../components/VerdictTag.vue'
import EmptyState from '../components/EmptyState.vue'
import UiIcon from '../components/UiIcon.vue'
const route = useRoute()
const router = useRouter()
const id = String(route.params.id)
const problem = computed(() => findProblem(id))
const problem = ref<components['schemas']['ProblemDetail'] | null>(null)
const { language, code, state, save } = useCodeDraft(id)
const session = useSession()
const theme = ref('vs')
const fontSize = ref(14)
const tab = ref('description')
const testTab = ref('input')
const testInput = ref(problem.value?.sampleInput ?? '')
const plain = ref(false)
const error = ref('')
const submitError = ref('')
const busy = ref(false)
const loading = ref(true)
const testInput = ref('')
const settingsOpen = ref(false)
const split = ref(50)
const workspace = ref<HTMLDivElement>()
const codeRelated = computed(() => submissions.filter((s) => s.problemId === id))
const relatedDiscussion = computed(() => discussions.filter((d) => d.problemId === id))
const request = new AbortController()
let alive = true
const intents = new Map<string, ReturnType<typeof submissionIntent>>()
const wireLanguage = computed(() =>
language.value === 'c' ? 'c17' : language.value === 'cpp' ? 'cpp20' : null,
)
const supported = computed(
() => wireLanguage.value !== null && !!problem.value?.languages.includes(wireLanguage.value),
)
const draftText = computed(
() =>
({
@@ -50,36 +64,107 @@ function beginDrag(event: PointerEvent) {
window.addEventListener('pointerup', stop, { once: true })
stopDrag = stop
}
onBeforeUnmount(() => stopDrag?.())
async function load() {
loading.value = true
error.value = ''
try {
const data = unwrap(
await api.GET('/api/v1/problems/versions/{version_id}', {
params: { path: { version_id: id } },
signal: request.signal,
}),
)
if (alive) problem.value = data
} catch (cause) {
if (alive) error.value = errorText(cause)
} finally {
if (alive) loading.value = false
}
}
async function resetCode() {
try {
await ElMessageBox.confirm('将当前语言的草稿恢复为初始模板?', '重置代码', {
confirmButtonText: '重置',
cancelButtonText: '保留草稿',
type: 'info',
})
code.value = codeTemplates[language.value]
} catch {
/* User kept their draft. */
/* kept */
}
}
function downloadCode() {
const extensions = { cpp: 'cpp', python: 'py', rust: 'rs' }
const url = URL.createObjectURL(new Blob([code.value], { type: 'text/plain;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = 'solution-' + id + '.' + extensions[language.value]
link.download =
'solution-' + id + '.' + { c: 'c', cpp: 'cpp', python: 'py', rust: 'rs' }[language.value]
link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function copySample(value: string) {
function sample(value: string) {
const bytes = Uint8Array.from(atob(value), (c) => c.charCodeAt(0))
try {
await navigator.clipboard.writeText(value)
ElMessage.success('已复制样例')
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
ElMessage.info('浏览器暂不支持复制,请选中样例文本复制')
return (
'字节(十六进制):' + Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(' ')
)
}
}
async function copySample(value: string) {
try {
await navigator.clipboard.writeText(sample(value))
ElMessage.success('已复制样例')
} catch {
ElMessage.info('请选中样例文本复制')
}
}
async function submit() {
if (busy.value || !problem.value || !supported.value || !wireLanguage.value) return
save()
busy.value = true
submitError.value = ''
try {
if (!(await session.refresh())) {
await router.push({ path: '/login', query: { next: route.fullPath } })
return
}
const owner = session.user.value!.id
const input: SubmissionInput = {
problem_version_id: problem.value.version_id,
language: wireLanguage.value,
source: code.value,
}
save()
let intent = intents.get(owner)
if (!intent) {
intent = submissionIntent(localStorage, owner)
intents.set(owner, intent)
}
const submissionId = await intent.submit(
input,
async (key) =>
unwrap(
await api.POST('/api/v1/submissions', {
body: { ...input, idempotency_key: key },
headers: csrfHeaders(),
signal: request.signal,
}),
).submission_id,
)
if (alive && session.user.value?.id === owner) await router.push('/submissions/' + submissionId)
} catch (cause) {
if (alive) submitError.value = errorText(cause)
} finally {
if (alive) busy.value = false
}
}
onMounted(load)
onBeforeUnmount(() => {
alive = false
request.abort()
stopDrag?.()
})
</script>
<template>
<div
@@ -89,112 +174,46 @@ async function copySample(value: string) {
:style="{ '--problem-split': split + '%' }"
>
<section class="panel statement-panel">
<el-tabs v-model="tab" class="statement-tabs"
><el-tab-pane label="题目描述" name="description" /><el-tab-pane
label="提交记录"
name="submissions" /><el-tab-pane label="题解与讨论" name="discussions" /><el-tab-pane
label="AI 答疑"
name="ai"
/></el-tabs>
<div class="statement-content">
<template v-if="tab === 'description'"
><div class="problem-title-row">
<div>
<p class="small muted mono">题号 {{ problem.id }}</p>
<h1>{{ problem.title }}</h1>
</div>
<DifficultyTag :value="problem.difficulty" />
<div class="problem-title-row">
<div>
<p class="small muted mono">{{ problem.slug }} · 版本 {{ problem.revision }}</p>
<h1>{{ problem.title }}</h1>
</div>
<div class="tag-list">
<span v-for="tag in problem.tags" :key="tag" class="tag">{{ tag }}</span
><span class="tag">示例题面</span>
</div>
<div class="problem-limits">
<span>时间限制:1000 ms</span><span>内存限制:256 MiB</span
><span>标准输入 / 标准输出</span>
</div>
<div class="detail-prose">
<h2>题目描述</h2>
<p>{{ problem.description }}</p>
<h2>输入格式</h2>
<p>{{ problem.input }}</p>
<h2>输出格式</h2>
<p>{{ problem.output }}</p>
<template v-if="problem.sampleInput"
><div class="sample-grid">
<div>
<h2>
样例输入
<el-button link size="small" @click="copySample(problem.sampleInput)"
>复制</el-button
>
</h2>
<pre class="sample-block">{{ problem.sampleInput }}</pre>
</div>
<div>
<h2>
样例输出
<el-button link size="small" @click="copySample(problem.sampleOutput)"
>复制</el-button
>
</h2>
<pre class="sample-block">{{ problem.sampleOutput }}</pre>
</div>
</div></template
>
<h2>数据范围与提示</h2>
<p>{{ problem.constraint }}</p>
<h2>提交说明</h2>
<p>
使用标准输入读取数据,只输出题目要求的结果。你可以在右侧编写代码,并在下方准备自测输入。
</p>
</div>
</template>
<template v-else-if="tab === 'submissions'"
><p class="notice-strip">以下为本题的示例评测记录。</p>
<el-table :data="codeRelated" empty-text="暂无本题的示例记录"
><el-table-column label="提交编号" prop="id" width="100" /><el-table-column
label="用户"
prop="username"
min-width="110"
/><el-table-column label="结果" width="100"
><template #default="{ row }"
><VerdictTag :value="row.verdict" /></template></el-table-column
><el-table-column label="详情" width="80"
><template #default="{ row }"
><RouterLink :to="'/submissions/' + row.id" class="text-link"
>查看</RouterLink
></template
></el-table-column
></el-table
></template
>
<template v-else-if="tab === 'discussions'"
><article
v-for="discussion in relatedDiscussion"
:key="discussion.id"
class="discussion-item"
>
</div>
<div class="problem-limits">
<span>CPU 限制:{{ problem.run_limits.cpu_time_us / 1000 }} ms</span
><span>墙钟限制:{{ problem.run_limits.wall_time_us / 1000 }} ms</span
><span>内存限制:{{ problem.run_limits.memory_bytes / 1048576 }} MiB</span
><span>精确字节比较</span>
</div>
<div class="detail-prose">
<h2>题目描述</h2>
<pre style="white-space: pre-wrap">{{ problem.statement }}</pre>
<div v-for="(item, index) in problem.samples" :key="index" class="sample-grid">
<div>
<h2>
<RouterLink :to="'/discussions/' + discussion.id">{{
discussion.title
}}</RouterLink>
样例输入 {{ index + 1 }}
<el-button link @click="copySample(item.input)">复制</el-button>
</h2>
<p>{{ discussion.author }} · {{ discussion.date }} · 示例文章</p>
<pre class="sample-block">{{ sample(item.input) }}</pre>
</div>
</article>
<EmptyState v-if="!relatedDiscussion.length" title="暂无本题讨论" /><RouterLink
to="/discussions"
class="text-link"
>进入讨论社区 →</RouterLink
></template
>
<EmptyState
v-else
title="AI 答疑待接入"
description="后续结合题目、代码和提交记录,提供逐步提示与针对性分析。"
/>
<div>
<h2>
样例输出 {{ index + 1 }}
<el-button link @click="copySample(item.output)">复制</el-button>
</h2>
<pre class="sample-block">{{ sample(item.output) }}</pre>
</div>
</div>
<h2>提交说明</h2>
<p>
使用标准输入 / 标准输出。支持语言:{{
problem.languages.join(' / ')
}}。隐藏测试数据不会公开。
</p>
<RouterLink to="/submissions" class="text-link">查看个人提交记录</RouterLink>
</div>
</div>
</section>
<button
@@ -218,13 +237,16 @@ async function copySample(value: string) {
:key="value"
:label="label"
:value="value" /></el-select
><el-tooltip content="重置模板"
><el-button size="small" aria-label="重置代码模板" @click="resetCode"
><UiIcon name="refresh" /></el-button></el-tooltip
><el-button size="small" aria-label="重置代码模板" @click="resetCode"
><UiIcon name="refresh" /></el-button
><span class="draft-label" aria-live="polite">{{ draftText }}</span
><el-button size="small" aria-label="下载代码" @click="downloadCode"
><UiIcon name="document" /></el-button
><el-popover v-model:visible="settingsOpen" trigger="click" width="240"
><UiIcon name="document"
/></el-button>
<el-button size="small" @click="plain = !plain">{{
plain ? '代码编辑器' : '纯文本编辑'
}}</el-button>
<el-popover v-model:visible="settingsOpen" trigger="click" width="240"
><template #reference
><el-button size="small" aria-label="编辑器设置"><UiIcon name="settings" /></el-button
></template>
@@ -238,42 +260,66 @@ async function copySample(value: string) {
></el-popover>
</header>
<div class="editor-area">
<CodeEditor v-model="code" :language="language" :theme="theme" :font-size="fontSize" />
<textarea
v-if="plain"
v-model="code"
aria-label="源码"
style="
width: 100%;
height: 100%;
min-height: 320px;
padding: 18px;
font-family: monospace;
resize: none;
"
spellcheck="false"
/><CodeEditor
v-else
v-model="code"
:language="language"
:theme="theme"
:font-size="fontSize"
/>
</div>
<div class="editor-testing">
<el-tabs v-model="testTab"
><el-tab-pane label="自测输入" name="input"
><el-input
v-model="testInput"
type="textarea"
:rows="3"
resize="none"
placeholder="输入自测数据"
aria-label="自测输入"
/><el-button
v-if="problem.sampleInput"
link
type="primary"
size="small"
@click="testInput = problem.sampleInput"
>填入题目样例</el-button
></el-tab-pane
><el-tab-pane label="运行结果" name="result"
><p class="test-result-placeholder">
尚未运行。判题服务接入后展示标准输出、错误信息与资源使用情况。
</p></el-tab-pane
></el-tabs
>
<p v-if="!supported" class="notice-strip">
当前语言暂不支持提交,草稿仍会保存。可切换到题目支持的 C17 / C++20。
</p>
<p v-if="submitError" role="alert">
{{ submitError }}
<RouterLink
v-if="!session.user.value"
:to="{ path: '/login', query: { next: route.fullPath } }"
>重新登录</RouterLink
>
</p>
<el-input
v-model="testInput"
type="textarea"
:rows="3"
aria-label="自测输入"
placeholder="自测能力暂未开放,输入不会随提交发送"
/>
</div>
<footer class="editor-buttons">
<span class="small muted">评测服务待接入</span
<span class="small muted">{{
session.user.value ? '提交后评测隐藏测试' : '登录后可提交'
}}</span
><el-button size="small" @click="save">保存草稿</el-button
><el-button size="small" type="success" plain disabled>自测运行</el-button
><el-button size="small" type="primary" disabled>提交代码</el-button>
><el-button
size="small"
type="primary"
:loading="busy"
:disabled="!supported"
@click="submit"
>提交代码</el-button
>
</footer>
</section>
</div>
<EmptyState v-else title="题目不存在"
<EmptyState v-else :title="loading ? '正在加载题目…' : '题目暂时无法读取'" :description="error"
><el-button v-if="!loading" @click="load">重试</el-button
><RouterLink to="/problems"><el-button>返回题库</el-button></RouterLink></EmptyState
>
</template>
+80 -43
View File
@@ -1,62 +1,99 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { problems, problemTags } from '../data/preview'
import { usePagination } from '../composables/usePagination'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api } from '../api/client'
import { errorText, unwrap } from '../api/transport'
import type { components } from '../api/generated/schema'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import ProblemTable from '../components/ProblemTable.vue'
const route = useRoute()
const items = ref<components['schemas']['ProblemSummary'][]>([])
const query = ref('')
const difficulty = ref('')
const tag = ref(typeof route.query.tag === 'string' ? route.query.tag : '')
const error = ref('')
const busy = ref(false)
const next = ref<string | null>(null)
const current = ref<string | undefined>()
const previous = ref<(string | undefined)[]>([])
let request: AbortController | undefined
let generation = 0
const filtered = computed(() =>
problems.filter(
(p) =>
(!query.value || (p.id + p.title).toLowerCase().includes(query.value.trim().toLowerCase())) &&
(!difficulty.value || p.difficulty === difficulty.value) &&
(!tag.value || p.tags.includes(tag.value)),
items.value.filter((p) =>
(p.slug + p.title).toLowerCase().includes(query.value.trim().toLowerCase()),
),
)
const { page, pageSize, paged, total } = usePagination(filtered)
function reset() {
query.value = ''
difficulty.value = ''
tag.value = ''
async function load(after?: string) {
request?.abort()
const epoch = ++generation
const controller = new AbortController()
request = controller
error.value = ''
busy.value = true
try {
const page = unwrap(
await api.GET('/api/v1/problems', {
params: { query: { after, limit: 20 } },
signal: controller.signal,
}),
)
if (epoch !== generation) return
items.value = page.items
next.value = page.next_after
current.value = after
} catch (cause) {
if (epoch === generation) {
error.value = errorText(cause)
items.value = []
next.value = null
}
} finally {
if (epoch === generation) busy.value = false
}
}
function forward() {
if (!next.value) return
previous.value.push(current.value)
void load(next.value)
}
function back() {
void load(previous.value.pop())
}
onMounted(() => load())
onBeforeUnmount(() => {
generation++
request?.abort()
})
</script>
<template>
<PageHeading title="题库" eyebrow="PROBLEM BANK" description="挑选一道题,开始今天的练习。"
><RouterLink to="/trainings"><el-button>按题单练习</el-button></RouterLink></PageHeading
>
<PanelCard title="全部题目" icon="problems" subtitle="示例题库">
<PageHeading
title="题库"
eyebrow="PROBLEM BANK"
description="选择已发布的题目,使用支持的语言提交。"
/>
<PanelCard title="已发布题目" icon="problems">
<p v-if="error" role="alert">{{ error }} <el-button @click="load(current)">重试</el-button></p>
<div class="filters">
<el-input
v-model="query"
placeholder="搜索题号或题目名称"
placeholder="筛选当前页题号或标题"
aria-label="搜索题目"
clearable
/><el-select v-model="difficulty" placeholder="全部难度" aria-label="难度" clearable
><el-option
v-for="d in ['入门', '简单', '中等', '困难']"
:key="d"
:label="d"
:value="d" /></el-select
><el-select v-model="tag" placeholder="全部标签" aria-label="题目标签" clearable filterable
><el-option v-for="t in problemTags" :key="t" :label="t" :value="t" /></el-select
><el-button @click="reset">重置</el-button
><span class="filter-label">{{ total }} 道题目</span>
</div>
<ProblemTable :items="paged" />
<div class="pagination-row">
<span class="muted">共 {{ total }} 道题目</span
><el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes"
/>
</div>
<el-table :data="filtered" :empty-text="busy ? '正在加载…' : '当前页没有题目'">
<el-table-column prop="slug" label="题号" min-width="160" />
<el-table-column label="题目" min-width="220"
><template #default="{ row }"
><RouterLink :to="'/problems/' + row.version_id" class="text-link">{{
row.title
}}</RouterLink></template
></el-table-column
>
<el-table-column label="支持语言"
><template #default="{ row }">{{ row.languages.join(' / ') }}</template></el-table-column
>
</el-table>
<div class="pagination-row">
<span>当前页 {{ items.length }} 道题目</span
><el-button :disabled="busy || !previous.length" @click="back">上一页</el-button
><el-button :disabled="busy || !next" @click="forward">下一页</el-button>
</div>
</PanelCard>
</template>
+183 -41
View File
@@ -1,63 +1,205 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { submissions, findProblem } from '../data/preview'
import { api } from '../api/client'
import { ApiError, csrfHeaders, errorText, unwrap } from '../api/transport'
import { useSession } from '../composables/useSession'
import { boundedPoll } from '../api/polling'
import type { components } from '../api/generated/schema'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import VerdictTag from '../components/VerdictTag.vue'
import EmptyState from '../components/EmptyState.vue'
import JudgementStatus from '../components/JudgementStatus.vue'
const route = useRoute()
const submission = computed(() => submissions.find((s) => s.id === route.params.id))
const id = String(route.params.id)
const submission = ref<components['schemas']['SubmissionDetail'] | null>(null)
const error = ref('')
const notice = ref('')
const source = ref<string | null>(null)
const sourceError = ref('')
const cancelling = ref(false)
const sourceBusy = ref(false)
const request = new AbortController()
let alive = true
const terminal = computed(
() => !!submission.value && ['finished', 'failed', 'cancelled'].includes(submission.value.state),
)
const poll = boundedPoll({
load: async (signal) =>
unwrap(
await api.GET('/api/v1/submissions/{submission_id}', {
params: { path: { submission_id: id } },
signal,
}),
),
terminal: (s) => ['finished', 'failed', 'cancelled'].includes(s.state),
result: (s) => {
submission.value = s
error.value = ''
},
error: (cause) => {
error.value = errorText(cause)
if (cause instanceof ApiError && cause.status === 401) {
submission.value = null
source.value = null
}
},
exhausted: () => {
notice.value = '自动查询已停止。评测仍可继续,点击刷新可查看最新状态。'
},
})
function refresh() {
error.value = ''
notice.value = ''
poll.start()
}
function visibility() {
poll.visible(document.visibilityState === 'visible')
}
async function cancel() {
if (cancelling.value || terminal.value) return
cancelling.value = true
error.value = ''
try {
unwrap(
await api.POST('/api/v1/submissions/{submission_id}/cancel', {
params: { path: { submission_id: id } },
headers: csrfHeaders(),
signal: request.signal,
}),
)
if (alive) {
notice.value = '取消已受理,等待执行停止与清理确认。'
poll.start()
}
} catch (cause) {
if (alive) error.value = errorText(cause)
} finally {
if (alive) cancelling.value = false
}
}
async function readSource() {
if (sourceBusy.value) return
sourceBusy.value = true
sourceError.value = ''
try {
const value = unwrap(
await api.GET('/api/v1/submissions/{submission_id}/source', {
params: { path: { submission_id: id } },
signal: request.signal,
}),
)
if (alive) source.value = value.source
} catch (cause) {
if (alive) sourceError.value = errorText(cause)
} finally {
if (alive) sourceBusy.value = false
}
}
watch(
() => useSession().user.value?.id,
(owner, previousOwner) => {
if (previousOwner && owner !== previousOwner) {
poll.dispose()
submission.value = null
source.value = null
error.value = '会话已改变,请重新登录。'
}
},
)
onMounted(() => {
visibility()
document.addEventListener('visibilitychange', visibility)
poll.start()
})
onBeforeUnmount(() => {
alive = false
poll.dispose()
request.abort()
document.removeEventListener('visibilitychange', visibility)
})
</script>
<template>
<template v-if="submission"
><div class="breadcrumbs">
<RouterLink to="/submissions">评测记录</RouterLink><span>/</span
><span>#{{ submission.id }}</span>
</div>
<PageHeading
:title="'提交 #' + submission.id"
:description="findProblem(submission.problemId)?.title"
><VerdictTag :value="submission.verdict" full
/></PageHeading>
<PageHeading :title="'提交 ' + id" description="个人提交的持久化评测状态"
><JudgementStatus v-if="submission" :state="submission.state" :verdict="submission.verdict"
/></PageHeading>
<p v-if="error" role="alert">
{{ error }}
<RouterLink :to="{ path: '/login', query: { next: route.fullPath } }">重新登录</RouterLink>
</p>
<p v-if="notice" role="status">{{ notice }}</p>
<div class="filters">
<el-button @click="refresh">刷新状态</el-button
><el-button v-if="submission && !terminal" :loading="cancelling" @click="cancel"
>取消评测</el-button
><RouterLink to="/submissions"><el-button>个人提交</el-button></RouterLink>
</div>
<template v-if="submission">
<div class="summary-grid">
<div class="summary-card">
<p>运行时间</p>
<p>运行 CPU 时间</p>
<strong
>{{ submission.time ?? '—' }} <small v-if="submission.time !== null">ms</small></strong
><small>示例数据</small>
>{{ submission.result?.cpu_time_us == null ? '—' : submission.result.cpu_time_us / 1000 }}
<small v-if="submission.result?.cpu_time_us != null">ms</small></strong
>
</div>
<div class="summary-card">
<p>内存使用</p>
<p>运行内存峰值</p>
<strong
>{{ submission.memory === null ? '—' : submission.memory / 1024 }}
<small v-if="submission.memory !== null">MiB</small></strong
><small>示例数据</small>
>{{
submission.result?.memory_peak_bytes == null
? '—'
: (submission.result.memory_peak_bytes / 1048576).toFixed(2)
}}
<small v-if="submission.result?.memory_peak_bytes != null">MiB</small></strong
>
</div>
<div class="summary-card">
<p>语言</p>
<strong>{{ submission.language }}</strong
><small>工具链版本待接入</small>
><small>尝试 {{ submission.attempts }} 次</small>
</div>
<div class="summary-card">
<p>提交用户</p>
<strong style="font-size: 21px"
><RouterLink :to="'/users/' + submission.username">{{
submission.username
}}</RouterLink></strong
><small>{{ submission.created }}</small>
<p>提交时间</p>
<small>{{ submission.created_at }}</small
><RouterLink :to="'/problems/' + submission.problem_version_id"
>返回提交时的题目版本</RouterLink
>
</div>
</div>
<PanelCard title="评测详情" icon="submissions"
><p class="notice-strip">当前展示评测界面的示例记录,实际用例结果、日志和源码尚未接入。</p>
<EmptyState
title="用例详情待接入"
description="评测完成后展示各测试点状态、资源用量及可见的编译信息。"
/><RouterLink :to="'/problems/' + submission.problemId"
><el-button>返回题目</el-button></RouterLink
></PanelCard
></template
><EmptyState v-else title="提交记录不存在"
><RouterLink to="/submissions"><el-button>返回评测列表</el-button></RouterLink></EmptyState
>
<PanelCard title="评测详情" icon="submissions">
<p v-if="submission.failure_code" role="alert">系统原因:{{ submission.failure_code }}</p>
<template v-if="submission.result">
<p>编译状态:{{ submission.result.compilation_status }}</p>
<pre
v-if="submission.result.diagnostics"
class="sample-block"
data-testid="compiler-diagnostics"
>{{ submission.result.diagnostics }}</pre>
<p v-if="submission.result.diagnostics_truncated">编译诊断已截断。</p>
<p v-if="!submission.result.cleanup_complete" role="alert">
资源清理未确认,服务保留该任务并隔离容量。
</p>
<el-table :data="submission.result.cases" empty-text="没有运行测试点"
><el-table-column label="测试点"
><template #default="{ row }">{{ row.position + 1 }}</template></el-table-column
><el-table-column prop="verdict" label="结果" /><el-table-column label="CPU ms"
><template #default="{ row }">{{
row.cpu_time_us == null ? '—' : row.cpu_time_us / 1000
}}</template></el-table-column
><el-table-column label="内存 MiB"
><template #default="{ row }">{{
row.memory_peak_bytes == null ? '—' : (row.memory_peak_bytes / 1048576).toFixed(2)
}}</template></el-table-column
></el-table
>
</template>
<p v-else>
{{ terminal ? '没有执行报告;终态以持久化状态为准。' : '尚无完整成绩,请等待评测。' }}
</p>
<el-button :loading="sourceBusy" @click="readSource">查看我的源码</el-button>
<p v-if="sourceError" role="alert">{{ sourceError }}</p>
<pre v-if="source !== null" class="sample-block">{{ source }}</pre>
</PanelCard>
</template>
<p v-else-if="!error">正在读取提交…</p>
</template>
+93 -65
View File
@@ -1,75 +1,103 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { submissions, verdictLabels, type Verdict } from '../data/preview'
import { usePagination } from '../composables/usePagination'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api } from '../api/client'
import { ApiError, errorText, unwrap } from '../api/transport'
import { useSession } from '../composables/useSession'
import { boundedPoll } from '../api/polling'
import type { components } from '../api/generated/schema'
import PageHeading from '../components/PageHeading.vue'
import PanelCard from '../components/PanelCard.vue'
import SubmissionTable from '../components/SubmissionTable.vue'
const route = useRoute()
const username = ref(typeof route.query.user === 'string' ? route.query.user : '')
const problemId = ref('')
const language = ref('')
const verdict = ref<Verdict | ''>('')
const filtered = computed(() =>
submissions.filter(
(s) =>
(!username.value || s.username.toLowerCase().includes(username.value.trim().toLowerCase())) &&
(!problemId.value || s.problemId.includes(problemId.value.trim())) &&
(!language.value || s.language === language.value) &&
(!verdict.value || s.verdict === verdict.value),
),
)
const { page, pageSize, paged, total } = usePagination(filtered)
function reset() {
username.value = ''
problemId.value = ''
language.value = ''
verdict.value = ''
import JudgementStatus from '../components/JudgementStatus.vue'
const page = ref<components['schemas']['SubmissionPage'] | null>(null)
const current = ref<string | undefined>()
const previous = ref<(string | undefined)[]>([])
const error = ref('')
const notice = ref('')
let poll: ReturnType<typeof boundedPoll<components['schemas']['SubmissionPage']>> | undefined
function load(after?: string) {
poll?.dispose()
current.value = after
error.value = ''
notice.value = ''
poll = boundedPoll({
load: async (signal) =>
unwrap(
await api.GET('/api/v1/submissions', { params: { query: { after, limit: 20 } }, signal }),
),
terminal: (value) =>
value.items.every((s) => ['finished', 'failed', 'cancelled'].includes(s.state)),
result: (value) => {
page.value = value
},
error: (cause) => {
error.value = errorText(cause)
if (cause instanceof ApiError && cause.status === 401) page.value = null
},
exhausted: () => {
notice.value = '自动查询已停止,请手动刷新查看最新状态。'
},
})
visibility()
poll.start()
}
function visibility() {
poll?.visible(document.visibilityState === 'visible')
}
function forward() {
if (!page.value?.next_after) return
previous.value.push(current.value)
load(page.value.next_after)
}
function back() {
load(previous.value.pop())
}
watch(
() => useSession().user.value?.id,
(owner, previousOwner) => {
if (previousOwner && owner !== previousOwner) {
poll?.dispose()
page.value = null
error.value = '会话已改变,请重新登录。'
}
},
)
onMounted(() => {
document.addEventListener('visibilitychange', visibility)
load()
})
onBeforeUnmount(() => {
poll?.dispose()
document.removeEventListener('visibilitychange', visibility)
})
</script>
<template>
<PageHeading
title="评测记录"
title="个人提交"
eyebrow="SUBMISSIONS"
description="查看提交状态、运行情况与评测结果。"
/><PanelCard title="提交列表" icon="submissions" subtitle="示例评测记录"
><div class="filters">
<el-input
v-model="username"
placeholder="用户名"
aria-label="筛选用户名"
clearable
style="width: 180px"
/><el-input
v-model="problemId"
placeholder="题号"
aria-label="筛选题号"
clearable
style="width: 150px"
/><el-select v-model="verdict" aria-label="评测结果" placeholder="全部结果" clearable
><el-option
v-for="(label, value) in verdictLabels"
:key="value"
:label="label"
:value="value" /></el-select
><el-select v-model="language" aria-label="语言" placeholder="全部语言" clearable
><el-option
v-for="l in ['C++', 'Python', 'Rust']"
:key="l"
:label="l"
:value="l" /></el-select
><el-button @click="reset">重置</el-button>
</div>
<SubmissionTable :items="paged" />
description="仅显示当前登录账号的持久化提交记录。"
/>
<PanelCard title="我的提交" icon="submissions"
><p v-if="error" role="alert">{{ error }} <RouterLink to="/login">重新登录</RouterLink></p>
<p v-if="notice" role="status">{{ notice }}</p>
<el-table :data="page?.items ?? []" empty-text="当前页没有提交">
<el-table-column label="提交" min-width="230"
><template #default="{ row }"
><RouterLink :to="'/submissions/' + row.submission_id" class="text-link">{{
row.submission_id
}}</RouterLink></template
></el-table-column
>
<el-table-column prop="language" label="语言" width="100" /><el-table-column
label="状态"
min-width="130"
><template #default="{ row }"
><JudgementStatus :state="row.state" :verdict="row.verdict" /></template></el-table-column
><el-table-column prop="created_at" label="提交时间" min-width="180" />
</el-table>
<div class="pagination-row">
<span>{{ total }} 条记录</span
><el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes"
/></div
></PanelCard>
<el-button @click="load(current)">刷新列表</el-button
><el-button :disabled="!previous.length" @click="back">上一页</el-button
><el-button :disabled="!page?.next_after" @click="forward">下一页</el-button>
</div>
</PanelCard>
</template>
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { submissionIntent } from '../src/api/submissionIntent.ts'
import { boundedPoll } from '../src/api/polling.ts'
import { ApiError, boundedFetch, unwrap } from '../src/api/transport.ts'
function memory() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
}
}
const input = {
problem_version_id: 'version',
language: 'c17' as const,
source: 'synthetic ordinary text, never executed',
}
const flush = async () => {
await new Promise<void>((resolve) => setImmediate(resolve))
}
test('timeout and reload reuse persisted key; double click has one flight; content and owner change key', async () => {
const storage = memory()
const keys: string[] = []
const intent = submissionIntent(storage, 'owner-a')
let respond: (id: string) => void = () => {}
const send = async (key: string) => {
keys.push(key)
return new Promise<string>((resolve) => {
respond = resolve
})
}
const a = intent.submit(input, send)
const b = intent.submit(input, send)
assert.equal(a, b)
await flush()
assert.equal(keys.length, 1)
respond('real-id')
assert.equal(await a, 'real-id')
assert.equal(await submissionIntent(storage, 'owner-a').submit(input, send), 'real-id')
const uncertain = { ...input, source: 'changed synthetic content' }
await assert.rejects(
intent.submit(uncertain, async (key) => {
keys.push(key)
throw new Error('timeout')
}),
)
const failedKey = keys.at(-1)
assert.notEqual(keys[0], failedKey)
await submissionIntent(storage, 'owner-a').submit(uncertain, async (key) => {
assert.equal(key, failedKey)
return 'confirmed-id'
})
await submissionIntent(storage, 'owner-b').submit(uncertain, async (key) => {
assert.notEqual(key, failedKey)
return 'different-owner-id'
})
})
test('unavailable intent storage or oversized UTF-8 source fails before POST', async () => {
let calls = 0
const send = async () => {
calls++
return 'id'
}
const denied = {
getItem: () => null,
setItem: () => {
throw new Error('denied')
},
}
await assert.rejects(submissionIntent(denied, 'a').submit(input, send))
await assert.rejects(
submissionIntent(memory(), 'a').submit({ ...input, source: 'é'.repeat(131073) }, send),
)
assert.equal(calls, 0)
})
test('terminal response stops polling; failure stops until manual retry; requests and time are bounded', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
const results: number[] = []
let calls = 0,
errors = 0,
exhausted = 0
let fail = false
const poll = boundedPoll({
load: async () => {
calls++
if (fail) throw new ApiError(401)
return calls
},
terminal: (n) => n === 2,
result: (n) => results.push(n),
error: () => {
errors++
},
exhausted: () => {
exhausted++
},
interval: 10,
maxRequests: 2,
maxTime: 100,
})
poll.start()
await flush()
t.mock.timers.tick(10)
await flush()
assert.deepEqual(results, [1, 2])
t.mock.timers.tick(1000)
await flush()
assert.equal(calls, 2)
fail = true
poll.start()
await flush()
t.mock.timers.tick(100)
await flush()
assert.equal(calls, 3)
assert.equal(errors, 1)
fail = false
poll.start()
await flush()
t.mock.timers.tick(10)
await flush()
t.mock.timers.tick(10)
await flush()
assert.equal(calls, 5)
assert.equal(exhausted, 1)
poll.start()
await flush()
poll.visible(false)
t.mock.timers.tick(1000)
poll.visible(true)
await flush()
assert.equal(exhausted, 2)
poll.dispose()
})
test('hidden and unmounted requests abort; stale responses cannot replace current route data', async () => {
const pending: { signal: AbortSignal; done: (v: string) => void }[] = []
const results: string[] = []
const poll = boundedPoll({
load: (signal) =>
new Promise<string>((done) => {
pending.push({ signal, done })
}),
terminal: () => true,
result: (n) => results.push(n),
error: () => assert.fail('stale error'),
exhausted: () => {},
})
poll.start()
poll.visible(false)
assert.equal(pending[0]!.signal.aborted, true)
poll.visible(true)
pending[0]!.done('stale')
await flush()
assert.deepEqual(results, [])
pending[1]!.done('current')
await flush()
assert.deepEqual(results, ['current'])
poll.start()
poll.dispose()
assert.equal(pending[2]!.signal.aborted, true)
pending[2]!.done('unmounted')
await flush()
assert.deepEqual(results, ['current'])
})
test('transport expires identity on401, preserves cancellation and never fills failure with preview data', async () => {
let expired = 0
const fetcher = boundedFetch(
async (request) => {
assert.equal((request as Request).credentials, 'same-origin')
assert.equal((request as Request).cache, 'no-store')
return new Response('{}', { status: 401 })
},
() => {
expired++
},
)
const response = await fetcher('http://localhost/api/v1/auth/me')
assert.equal(expired, 1)
assert.throws(() => unwrap({ response, data: {} }), ApiError)
assert.throws(() => unwrap({ response: new Response('', { status: 200 }) }))
const offline = boundedFetch(
async () => {
throw new Error('synthetic private detail')
},
() => {},
)
await assert.rejects(offline('http://localhost/api'), /网络中断/)
})
+29 -1
View File
@@ -138,7 +138,12 @@ test('pagehide preserves the selected language and keeps problem drafts separate
assert.deepEqual(env.read('1001'), {
version: 1,
language: 'python',
drafts: { cpp: '// C++ draft', python: '# Python draft', rust: codeTemplates.rust },
drafts: {
c: codeTemplates.c,
cpp: '// C++ draft',
python: '# Python draft',
rust: codeTemplates.rust,
},
})
assert.equal(env.read('1002')?.drafts.cpp, '// another problem')
first.scope.stop()
@@ -162,3 +167,26 @@ test('a failed lifecycle save reports unavailable storage without throwing', (t)
assert.equal(draft.state.value, 'saved')
assert.equal(env.read()?.drafts.cpp, '// storage recovered')
})
test('old Python/Rust drafts survive adding C17; C and C++ retain separate contents', (t) => {
const env = browser(t)
env.storage.items.set(
'kf-oj:code-draft:1001',
JSON.stringify({
version: 1,
language: 'rust',
drafts: { cpp: '// original C++', python: '# original Python', rust: '// original Rust' },
}),
)
const draft = env.open()
assert.equal(draft.language.value, 'rust')
assert.equal(draft.code.value, '// original Rust')
draft.language.value = 'c'
draft.code.value = '// new C17'
draft.language.value = 'cpp'
assert.equal(draft.code.value, '// original C++')
draft.save()
assert.equal(env.read()?.drafts.c, '// new C17')
assert.equal(env.read()?.drafts.python, '# original Python')
assert.equal(env.read()?.drafts.rust, '// original Rust')
})