feat(platform): connect owned submissions and real browser flows
This commit is contained in:
@@ -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
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 : '请求失败,请重试。'
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
@@ -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'">{{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'), /网络中断/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user