feat(frontend): share real submission details and performance in workbench
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user