feat(frontend): share real submission details and performance in workbench

This commit is contained in:
2026-10-11 03:55:23 +08:00
parent 218fa38f8e
commit bc5cd12056
7 changed files with 833 additions and 346 deletions
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { api } from '../api/client'
import { errorText, unwrap } from '../api/transport'
import { useSession } from '../composables/useSession'
import type { components } from '../api/generated/schema'
import JudgementStatus from './JudgementStatus.vue'
const props = defineProps<{ problemVersionId: string }>()
const emit = defineEmits<{ select: [id: string] }>()
const session = useSession()
const language = ref<components['schemas']['SubmissionSummary']['language'] | ''>('')
const page = ref<components['schemas']['SubmissionPage'] | null>(null)
const error = ref('')
const busy = ref(false)
const cursors = ref<(string | undefined)[]>([undefined])
let request: AbortController | undefined
let generation = 0
async function load() {
request?.abort()
const current = ++generation
const controller = new AbortController()
request = controller
page.value = null
error.value = ''
busy.value = true
if (!session.user.value) {
error.value = '登录后可读取本题我的提交。'
busy.value = false
return
}
try {
const value = unwrap(
await api.GET('/api/v1/submissions', {
params: {
query: {
limit: 20,
after: cursors.value.at(-1),
problem_version_id: props.problemVersionId,
language: language.value || undefined,
},
},
signal: controller.signal,
}),
)
if (current === generation) page.value = value
} catch (cause) {
if (current === generation) error.value = errorText(cause)
} finally {
if (current === generation) busy.value = false
}
}
function next() {
if (page.value?.next_after) {
cursors.value.push(page.value.next_after)
void load()
}
}
function previous() {
if (cursors.value.length > 1) {
cursors.value.pop()
void load()
}
}
watch(
[() => props.problemVersionId, language, () => session.user.value?.id],
() => {
cursors.value = [undefined]
void load()
},
{ immediate: true, flush: 'sync' },
)
onBeforeUnmount(() => {
generation++
request?.abort()
})
</script>
<template>
<section aria-label="本题我的提交" data-testid="problem-my-submissions">
<h2>本题我的提交</h2>
<p>当前不可变题目版本,仅显示自己的记录。</p>
<label
>语言
<select v-model="language" aria-label="提交语言筛选">
<option value="">全部语言</option>
<option value="c17">C17</option>
<option value="cpp20">C++20</option>
<option value="python313">Python 3.13</option>
<option value="java21">Java 21</option>
</select></label
>
<el-button :loading="busy" @click="load">刷新列表</el-button>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="page && !page.items.length">还没有符合筛选的提交。</p>
<ol v-if="page" class="my-submission-list">
<li v-for="item in page.items" :key="item.submission_id">
<button
class="my-submission-row"
:data-submission-id="item.submission_id"
@click="emit('select', item.submission_id)"
>
<JudgementStatus :state="item.state" :verdict="item.verdict" :language="item.language" />
<span>{{ item.language }}</span
><time>{{ item.created_at }}</time
><span class="small mono">{{ item.submission_id }}</span>
</button>
</li>
</ol>
<el-button :disabled="busy || cursors.length <= 1" @click="previous">上一页</el-button>
<el-button :disabled="busy || !page?.next_after" @click="next">下一页</el-button>
</section>
</template>
<style scoped>
.my-submission-list {
padding: 0;
list-style: none;
}
.my-submission-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
padding: 14px;
border: 1px solid var(--el-border-color);
background: var(--el-bg-color);
color: var(--el-text-color-primary);
text-align: left;
cursor: pointer;
border-radius: 10px;
margin: 8px 0;
}
</style>
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import { 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 './PageHeading.vue'
import PanelCard from './PanelCard.vue'
import JudgementStatus from './JudgementStatus.vue'
import SubmissionPerformance from './SubmissionPerformance.vue'
import { preparationName } from '../composables/judgingLanguage'
const props = defineProps<{ submissionId: string; embedded?: boolean }>()
const route = useRoute()
const id = computed(() => props.submissionId)
const session = useSession()
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)
let request = new AbortController()
let generation = 0
let poll: ReturnType<typeof boundedPoll<components['schemas']['SubmissionDetail']>> | undefined
const terminal = computed(
() => !!submission.value && ['finished', 'failed', 'cancelled'].includes(submission.value.state),
)
function visibility() {
poll?.visible(document.visibilityState === 'visible')
}
function reset() {
const current = ++generation
request.abort()
request = new AbortController()
poll?.dispose()
submission.value = null
source.value = null
error.value = ''
notice.value = ''
sourceError.value = ''
cancelling.value = sourceBusy.value = false
if (!session.user.value) {
error.value = '请登录后读取个人提交。'
return
}
const submissionId = props.submissionId
poll = boundedPoll({
load: async (signal) =>
unwrap(
await api.GET('/api/v1/submissions/{submission_id}', {
params: { path: { submission_id: submissionId } },
signal,
}),
),
terminal: (s) => ['finished', 'failed', 'cancelled'].includes(s.state),
result: (s) => {
if (current === generation) {
submission.value = s
error.value = ''
}
},
error: (cause) => {
if (current === generation) {
submission.value = null
source.value = null
error.value = errorText(cause)
}
},
exhausted: () => {
if (current === generation) notice.value = '自动刷新已暂停,可手动刷新状态。'
},
})
visibility()
poll.start()
}
function refresh() {
error.value = ''
poll?.start()
}
async function cancel() {
if (cancelling.value || !submission.value) return
const current = generation
cancelling.value = true
try {
unwrap(
await api.POST('/api/v1/submissions/{submission_id}/cancel', {
params: { path: { submission_id: props.submissionId } },
headers: csrfHeaders(),
signal: request.signal,
}),
)
if (current === generation) {
notice.value = '取消已受理,等待执行停止与清理确认。'
poll?.start()
}
} catch (cause) {
if (current === generation) error.value = errorText(cause)
} finally {
if (current === generation) cancelling.value = false
}
}
async function readSource() {
if (sourceBusy.value) return
const current = generation
sourceBusy.value = true
sourceError.value = ''
try {
const value = unwrap(
await api.GET('/api/v1/submissions/{submission_id}/source', {
params: { path: { submission_id: props.submissionId } },
signal: request.signal,
}),
)
if (current === generation) source.value = value.source
} catch (cause) {
if (current === generation) sourceError.value = errorText(cause)
} finally {
if (current === generation) sourceBusy.value = false
}
}
watch([() => props.submissionId, () => session.user.value?.id], reset, {
immediate: true,
flush: 'sync',
})
onMounted(() => document.addEventListener('visibilitychange', visibility))
onBeforeUnmount(() => {
generation++
poll?.dispose()
request.abort()
document.removeEventListener('visibilitychange', visibility)
})
</script>
<template>
<PageHeading :title="'提交 ' + id" description="个人提交的持久化评测状态"
><JudgementStatus
v-if="submission"
:state="submission.state"
:verdict="submission.verdict"
:language="submission.language"
/></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
><RouterLink v-if="embedded" :to="'/submissions/' + id"
><el-button>打开完整详情</el-button></RouterLink
>
</div>
<template v-if="submission">
<div class="summary-grid">
<div class="summary-card">
<p>运行 CPU 时间</p>
<strong
>{{ 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>
<strong
>{{
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>尝试 {{ submission.attempts }} 次</small>
</div>
<div class="summary-card">
<p>提交时间</p>
<small>{{ submission.created_at }}</small
><RouterLink :to="'/problems/' + submission.problem_version_id"
>返回提交时的题目版本</RouterLink
>
</div>
</div>
<SubmissionPerformance v-if="terminal" :submission-id="id" />
<PanelCard title="评测详情" icon="submissions">
<p v-if="submission.failure_code" role="alert">系统原因:{{ submission.failure_code }}</p>
<template v-if="submission.result">
<p>
{{ preparationName(submission.language) }}状态:{{ 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">
{{ preparationName(submission.language) }}诊断已截断。
</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>
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { api } from '../api/client'
import { errorText, unwrap } from '../api/transport'
import { useSession } from '../composables/useSession'
import type { components } from '../api/generated/schema'
import { chartPosition, formatMeasurement } from '../lib/performance'
import UiIcon from './UiIcon.vue'
const props = defineProps<{ submissionId: string }>()
const session = useSession()
const data = ref<components['schemas']['SubmissionPerformance'] | null>(null)
const busy = ref(false)
const error = ref('')
const metric = ref<'cpu' | 'memory'>('cpu')
const active = ref<number | null>(null)
let request: AbortController | undefined
let generation = 0
const summary = computed(() => data.value?.[metric.value])
const unit = computed(() => (metric.value === 'cpu' ? 'ms' : 'MiB'))
const selected = computed(() =>
active.value === null ? undefined : summary.value?.buckets[active.value],
)
const end = computed(() => summary.value?.buckets.at(-1)?.end ?? '1')
const ceiling = computed(() => Math.max(1, ...(summary.value?.buckets.map((b) => b.count) ?? [])))
const reasons = {
not_accepted: '本次没有最终通过成绩,无法比较。',
measurement_missing: '本次缺少该指标的可信测量。',
environment_unverified: '这条历史成绩未绑定可验证的测量环境。',
insufficient_samples: '同组其他有效通过记录不足 5 条。',
}
function format(value: string | null) {
return formatMeasurement(value, metric.value === 'cpu' ? 'cpu_microseconds' : 'bytes')
}
async function load() {
if (busy.value || !session.user.value) return
request?.abort()
const controller = new AbortController()
request = controller
const current = ++generation
busy.value = true
data.value = null
error.value = ''
try {
const value = unwrap(
await api.GET('/api/v1/submissions/{submission_id}/performance', {
params: { path: { submission_id: props.submissionId } },
signal: controller.signal,
}),
)
if (generation === current) data.value = value
} catch (cause) {
if (generation === current) error.value = errorText(cause)
} finally {
if (generation === current) busy.value = false
}
}
watch(
[() => props.submissionId, () => session.user.value?.id],
() => {
generation++
request?.abort()
data.value = null
busy.value = false
error.value = ''
active.value = null
void load()
},
{ immediate: true },
)
watch(metric, () => (active.value = null))
onBeforeUnmount(() => {
generation++
request?.abort()
})
</script>
<template>
<section
class="performance-distribution"
aria-label="答题表现分布"
data-testid="submission-performance"
>
<header class="performance-heading">
<div>
<h2><UiIcon name="rankings" />答题表现分布</h2>
<p>同题目版本、语言、工具链与测量环境的通过记录</p>
</div>
<el-button :loading="busy" @click="load">刷新表现</el-button>
</header>
<p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="busy" role="status">正在读取真实评测统计…</p>
<template v-if="data && summary">
<div class="performance-metrics" aria-label="选择分布指标">
<button
v-for="(title, key) in { cpu: '运行 CPU 时间', memory: '运行内存峰值' }"
:key="key"
:aria-pressed="metric === key"
@click="metric = key"
>
{{ title }}
</button>
</div>
<p data-testid="performance-current" :data-raw-value="summary.current">
本次:{{ format(summary.current) }} {{ unit }}
</p>
<p v-if="!summary.available" role="status">{{ reasons[summary.reason!] }}</p>
<template v-else>
<p
data-testid="performance-summary"
:data-sample-count="summary.sample_count"
:data-greater="summary.strictly_slower_count"
:data-tied="summary.tied_count"
>
优于 {{ ((summary.strictly_slower_count! / summary.sample_count) * 100).toFixed(1) }}%
的比较记录; 同值 {{ summary.tied_count }} 条(不计优于)。
</p>
<svg
viewBox="0 0 640 250"
role="img"
:aria-label="`${metric === 'cpu' ? 'CPU 时间' : '内存'}分布,${summary.sample_count} 条记录`"
>
<line x1="40" y1="214" x2="620" y2="214" stroke="currentColor" />
<rect
v-for="(bucket, index) in summary.buckets"
:key="index"
tabindex="0"
:x="40 + chartPosition(bucket.start, end) * 580"
:width="
Math.max(
1,
(chartPosition(bucket.end, end) - chartPosition(bucket.start, end)) * 580 - 2,
)
"
:y="214 - (bucket.count / ceiling) * 170"
:height="(bucket.count / ceiling) * 170"
fill="var(--el-color-primary)"
@mouseenter="active = index"
@focus="active = index"
>
<title>
{{ format(bucket.start) }}–{{ format(bucket.end) }} {{ unit }},{{ bucket.count }} 条
</title>
</rect>
<line
:x1="40 + chartPosition(summary.current!, end) * 580"
:x2="40 + chartPosition(summary.current!, end) * 580"
y1="30"
y2="220"
stroke="var(--el-color-danger)"
stroke-width="2"
/>
<text x="40" y="245">0</text>
<text x="620" y="245" text-anchor="end">{{ format(end) }} {{ unit }}</text>
</svg>
<p v-if="selected" role="status">
{{ format(selected.start) }}–{{ format(selected.end) }} {{ unit }} (上界{{
selected.upper_inclusive ? '包含' : '不含'
}}):{{ selected.count }} 条
</p>
</template>
<p class="small muted">
比较 {{ summary.sample_count }} 条其他有效 AC,按记录数计,不按人数计。 最近最多
{{ data.window_limit }} 条{{ summary.window_truncated ? '(已截断)' : '' }}; 快照
{{ data.as_of
}}<template v-if="summary.oldest_sample_at">,最早 {{ summary.oldest_sample_at }}</template
>。
</p>
</template>
</section>
</template>
<style scoped>
.performance-distribution {
padding: 20px;
border: 1px solid var(--el-border-color);
border-radius: 14px;
margin: 20px 0;
}
.performance-heading {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
}
.performance-heading h2 {
display: flex;
gap: 8px;
align-items: center;
}
.performance-metrics {
display: flex;
gap: 12px;
}
.performance-metrics button {
padding: 10px 14px;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
cursor: pointer;
}
.performance-metrics button[aria-pressed='true'] {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
svg {
width: 100%;
max-height: 290px;
}
svg text {
fill: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
// Integers stay exact. Number conversion is confined to chart coordinates/count ratios.
export function formatMeasurement(value: string | null, unit: 'cpu_microseconds' | 'bytes') {
if (value === null) return '—'
const raw = BigInt(value)
const scale = unit === 'bytes' ? 1048576n : 1000n
const places = unit === 'bytes' ? 6 : 3
const fraction = ((raw % scale) * 10n ** BigInt(places)) / scale
if (raw > 0n && raw < scale && fraction === 0n) return '<0.' + '0'.repeat(places - 1) + '1'
return `${raw / scale}.${String(fraction).padStart(places, '0')}`.replace(/\.?0+$/, '') || '0'
}
export function chartPosition(value: string, end: string) {
const ceiling = BigInt(end)
if (ceiling <= 0n) return 0
return Number((BigInt(value) * 1000000n) / ceiling) / 1000000
}
+222 -136
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api/client'
@@ -12,12 +12,28 @@ import { languageCapability, preparationName } from '../composables/judgingLangu
import CodeEditor from '../components/CodeEditor.vue'
import EmptyState from '../components/EmptyState.vue'
import UiIcon from '../components/UiIcon.vue'
import ProblemSubmissionList from '../components/ProblemSubmissionList.vue'
import SubmissionInspector from '../components/SubmissionInspector.vue'
const route = useRoute()
const router = useRouter()
const id = String(route.params.id)
const problem = ref<components['schemas']['ProblemDetail'] | null>(null)
const { language, code, state, save } = useCodeDraft(id)
const session = useSession()
const statementTab = ref<'statement' | 'submissions'>('statement')
const workbench = ref<'editor' | 'submission'>('editor')
const selectedSubmission = ref<string | null>(null)
function selectSubmission(id: string) {
selectedSubmission.value = id
workbench.value = 'submission'
}
watch(
() => session.user.value?.id,
() => {
selectedSubmission.value = null
workbench.value = 'editor'
},
)
const theme = ref('vs')
const fontSize = ref(14)
const plain = ref(false)
@@ -178,45 +194,67 @@ onBeforeUnmount(() => {
>
<section class="panel statement-panel">
<div class="statement-content">
<div class="problem-title-row">
<div>
<p class="small muted mono">{{ problem.slug }} · 版本 {{ problem.revision }}</p>
<h1>{{ problem.title }}</h1>
</div>
</div>
<div class="problem-limits">
<span>CPU 限制:{{ runLimits ? runLimits.cpu_time_us / 1000 + ' ms' : '未提供' }}</span
><span>墙钟限制:{{ runLimits ? runLimits.wall_time_us / 1000 + ' ms' : '未提供' }}</span
><span
>内存限制:{{ runLimits ? runLimits.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">
<nav class="workspace-tabs" aria-label="题目内容">
<el-button
:aria-pressed="statementTab === 'statement'"
@click="statementTab = 'statement'"
>题面</el-button
>
<el-button
:aria-pressed="statementTab === 'submissions'"
@click="statementTab = 'submissions'"
>本题我的提交</el-button
>
</nav>
<ProblemSubmissionList
v-if="statementTab === 'submissions'"
:problem-version-id="id"
@select="selectSubmission"
/>
<div v-show="statementTab === 'statement'">
<div class="problem-title-row">
<div>
<h2>
样例输入 {{ index + 1 }}
<el-button link @click="copySample(item.input)">复制</el-button>
</h2>
<pre class="sample-block">{{ sample(item.input) }}</pre>
</div>
<div>
<h2>
样例输出 {{ index + 1 }}
<el-button link @click="copySample(item.output)">复制</el-button>
</h2>
<pre class="sample-block">{{ sample(item.output) }}</pre>
<p class="small muted mono">{{ problem.slug }} · 版本 {{ problem.revision }}</p>
<h1>{{ problem.title }}</h1>
</div>
</div>
<h2>提交说明</h2>
<p>
使用标准输入 / 标准输出。支持语言:{{
problem.languages.join(' / ')
}}。隐藏测试数据不会公开。
</p>
<RouterLink to="/submissions" class="text-link">查看个人提交记录</RouterLink>
<div class="problem-limits">
<span>CPU 限制:{{ runLimits ? runLimits.cpu_time_us / 1000 + ' ms' : '未提供' }}</span
><span
>墙钟限制:{{ runLimits ? runLimits.wall_time_us / 1000 + ' ms' : '未提供' }}</span
><span
>内存限制:{{
runLimits ? runLimits.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>
样例输入 {{ index + 1 }}
<el-button link @click="copySample(item.input)">复制</el-button>
</h2>
<pre class="sample-block">{{ sample(item.input) }}</pre>
</div>
<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>
</div>
</section>
@@ -232,108 +270,131 @@ onBeforeUnmount(() => {
@keydown.left.prevent="split = Math.max(30, split - 2)"
@keydown.right.prevent="split = Math.min(70, split + 2)"
/>
<section class="panel editor-panel">
<header class="editor-toolbar">
<span class="small muted">语言</span
><el-select v-model="language" aria-label="编程语言" size="small"
><el-option
v-for="(label, value) in languageLabels"
:key="value"
:label="label"
:value="value" /></el-select
><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-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>
<div class="editor-settings">
<label>主题</label
><el-select v-model="theme" aria-label="编辑器主题"
><el-option label="浅色" value="vs" /><el-option
label="深色"
value="vs-dark" /></el-select
><label>字号</label><el-slider v-model="fontSize" :min="12" :max="22" show-input /></div
></el-popover>
</header>
<div class="editor-area">
<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">
<p v-if="!supported" class="notice-strip">
当前题目版本不支持此语言,草稿仍会保存。请选择题目公布的可用语言。
</p>
<p
v-else-if="capability?.configuration"
class="notice-strip"
data-testid="language-capability"
<section class="panel workbench-panel">
<nav class="workspace-tabs" aria-label="工作台">
<el-button :aria-pressed="workbench === 'editor'" @click="workbench = 'editor'"
>代码编辑器</el-button
>
{{ languageLabels[language] }} · {{ capability.configuration.runtime_version }} ·
{{ preparationName(capability.wire) }}后逐点运行。
<template v-if="capability.wire === 'java21'">
默认包 Main.java,入口为 public static void main(String[])。
</template>
<template v-else-if="capability.wire === 'python313'">
单文件 main.py,仅标准库。
</template>
</p>
<p v-if="submitError" role="alert">
{{ submitError }}
<RouterLink
v-if="!session.user.value"
:to="{ path: '/login', query: { next: route.fullPath } }"
>重新登录</RouterLink
<el-button :aria-pressed="workbench === 'submission'" @click="workbench = 'submission'"
>提交详情</el-button
>
</nav>
<div v-show="workbench === 'editor'" class="editor-panel">
<header class="editor-toolbar">
<span class="small muted">语言</span
><el-select v-model="language" aria-label="编程语言" size="small"
><el-option
v-for="(label, value) in languageLabels"
:key="value"
:label="label"
:value="value" /></el-select
><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-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>
<div class="editor-settings">
<label>主题</label
><el-select v-model="theme" aria-label="编辑器主题"
><el-option label="浅色" value="vs" /><el-option
label="深色"
value="vs-dark" /></el-select
><label>字号</label
><el-slider v-model="fontSize" :min="12" :max="22" show-input /></div
></el-popover>
</header>
<div class="editor-area">
<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">
<p v-if="!supported" class="notice-strip">
当前题目版本不支持此语言,草稿仍会保存。请选择题目公布的可用语言。
</p>
<p
v-else-if="capability?.configuration"
class="notice-strip"
data-testid="language-capability"
>
</p>
<el-input
v-model="testInput"
type="textarea"
:rows="3"
aria-label="自测输入"
placeholder="自测能力暂未开放,输入不会随提交发送"
/>
{{ languageLabels[language] }} · {{ capability.configuration.runtime_version }} ·
{{ preparationName(capability.wire) }}后逐点运行。
<template v-if="capability.wire === 'java21'">
默认包 Main.java,入口为 public static void main(String[])。
</template>
<template v-else-if="capability.wire === 'python313'">
单文件 main.py,仅标准库。
</template>
</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">{{
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"
:loading="busy"
:disabled="!supported"
@click="submit"
>提交代码</el-button
>
</footer>
</div>
<div
v-if="workbench === 'submission'"
class="workbench-detail"
data-testid="workbench-detail"
>
<SubmissionInspector
v-if="selectedSubmission"
:submission-id="selectedSubmission"
embedded
/>
<p v-else>从“本题我的提交”选择一条记录。</p>
</div>
<footer class="editor-buttons">
<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"
:loading="busy"
:disabled="!supported"
@click="submit"
>提交代码</el-button
>
</footer>
</section>
</div>
<EmptyState v-else :title="loading ? '正在加载题目…' : '题目暂时无法读取'" :description="error"
@@ -582,3 +643,28 @@ onBeforeUnmount(() => {
}
}
</style>
<style scoped>
.workbench-panel {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.workspace-tabs {
display: flex;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--el-border-color);
}
.workbench-panel > .editor-panel {
flex: 1;
overflow: hidden;
}
.workbench-detail {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
}
</style>
+4 -210
View File
@@ -1,214 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
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 JudgementStatus from '../components/JudgementStatus.vue'
import { preparationName } from '../composables/judgingLanguage'
import SubmissionInspector from '../components/SubmissionInspector.vue'
const route = useRoute()
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)
})
const id = computed(() => String(route.params.id))
</script>
<template>
<PageHeading :title="'提交 ' + id" description="个人提交的持久化评测状态"
><JudgementStatus
v-if="submission"
:state="submission.state"
:verdict="submission.verdict"
:language="submission.language"
/></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>运行 CPU 时间</p>
<strong
>{{ 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>
<strong
>{{
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>尝试 {{ submission.attempts }} 次</small>
</div>
<div class="summary-card">
<p>提交时间</p>
<small>{{ submission.created_at }}</small
><RouterLink :to="'/problems/' + submission.problem_version_id"
>返回提交时的题目版本</RouterLink
>
</div>
</div>
<PanelCard title="评测详情" icon="submissions">
<p v-if="submission.failure_code" role="alert">系统原因:{{ submission.failure_code }}</p>
<template v-if="submission.result">
<p>
{{ preparationName(submission.language) }}状态:{{ 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">
{{ preparationName(submission.language) }}诊断已截断。
</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>
<template><SubmissionInspector :submission-id="id" /></template>
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { chartPosition, formatMeasurement } from '../src/lib/performance.ts'
test('measurements preserve raw integer precision and distinguish missing from zero', () => {
assert.equal(formatMeasurement(null, 'bytes'), '—')
assert.equal(formatMeasurement('0', 'cpu_microseconds'), '0')
assert.equal(formatMeasurement('10000', 'cpu_microseconds'), '10')
assert.equal(formatMeasurement('9007199254740993', 'cpu_microseconds'), '9007199254740.993')
assert.equal(formatMeasurement('9007199254740992', 'cpu_microseconds'), '9007199254740.992')
assert.equal(formatMeasurement('1', 'bytes'), '<0.000001')
assert.equal(chartPosition('0', '1'), 0)
assert.equal(chartPosition('9223372036854775807', '9223372036854775807'), 1)
})