7 changed files with 780 additions and 65 deletions
@@ -0,0 +1,387 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { Submission } from '../data/preview'
import { findPerformancePreview } from '../data/performancePreview'
import { summarizeDistribution } from '../lib/performance'
import EmptyState from './EmptyState.vue'
import UiIcon from './UiIcon.vue'
const props = defineProps<{ submission: Submission }>()
type Metric = 'time' | 'memory'
const metric = ref<Metric>('time')
const metrics = [
{ key: 'time' as const, title: '运行时间', unit: 'ms', icon: 'clock' },
{ key: 'memory' as const, title: '内存消耗', unit: 'MiB', icon: 'monitor' },
]
const preview = computed(() => findPerformancePreview(props.submission))
const summaries = computed(() => ({
time: summarizeDistribution(preview.value?.time ?? [], props.submission.time),
memory: summarizeDistribution(
preview.value?.memory.map((point) => ({ ...point, value: point.value / 1024 })) ?? [],
props.submission.memory === null ? null : props.submission.memory / 1024,
),
}))
const summary = computed(() => summaries.value[metric.value])
const unit = computed(() => (metric.value === 'time' ? 'ms' : 'MiB'))
const active = ref<number | null>(null)
const selected = computed(() =>
active.value === null ? undefined : summary.value.bins[active.value],
)
const chart = ref<HTMLDivElement>()
const width = ref(640)
let observer: ResizeObserver | undefined
onMounted(() => {
if (!chart.value) return
observer = new ResizeObserver(([entry]) => {
if (entry) width.value = Math.max(280, entry.contentRect.width)
})
observer.observe(chart.value)
})
onBeforeUnmount(() => observer?.disconnect())
watch([metric, () => props.submission.id], () => (active.value = null))
const plotWidth = computed(() => width.value - 62)
const binWidth = computed(() => plotWidth.value / summary.value.bins.length)
const ceiling = computed(() =>
Math.max(5, Math.ceil(Math.max(...summary.value.bins.map((bin) => bin.percent)) / 5) * 5),
)
const ticks = computed(() => Array.from({ length: 5 }, (_, index) => (ceiling.value / 4) * index))
const xTicks = computed(() => {
const count = width.value < 450 ? 3 : 5
return Array.from({ length: count + 1 }, (_, index) => (summary.value.end / count) * index)
})
const x = (value: number) => 44 + (value / summary.value.end) * plotWidth.value
const y = (percent: number) => 224 - (percent / ceiling.value) * 164
function formatValue(value: number, kind?: Metric) {
return Number((kind === 'memory' ? value / 1024 : value).toFixed(2)).toLocaleString('zh-CN')
}
function describeBin(index: number) {
const bin = summary.value.bins[index]!
const end = index === summary.value.bins.length - 1 ? '(含)' : '(不含)'
return `${formatValue(bin.start)}–${formatValue(bin.end)} ${unit.value}${end}:${bin.count} 条,占 ${bin.percent.toFixed(1)}%`
}
</script>
<template>
<section class="performance-distribution" aria-label="答题表现分布">
<header class="performance-heading">
<div>
<h2><UiIcon name="rankings" />答题表现分布</h2>
<p>同题 · {{ submission.language }} · 通过记录</p>
</div>
<span class="performance-preview">示例统计</span>
</header>
<div class="performance-metrics" aria-label="选择分布指标">
<button
v-for="item in metrics"
:key="item.key"
type="button"
:aria-pressed="metric === item.key"
:class="{ selected: metric === item.key }"
@click="metric = item.key"
>
<span class="metric-title"><UiIcon :name="item.icon" />{{ item.title }}</span>
<span class="metric-value">
{{ submission[item.key] === null ? '—' : formatValue(submission[item.key]!, item.key) }}
<small v-if="submission[item.key] !== null">{{ item.unit }}</small>
</span>
<span v-if="preview && summaries[item.key].betterThan !== null" class="metric-percentile">
优于 <strong>{{ summaries[item.key].betterThan!.toFixed(1) }}%</strong> 示例提交
</span>
<span v-else class="metric-unavailable">暂无可比较的统计</span>
</button>
</div>
<div ref="chart" class="performance-chart">
<template v-if="preview && summary.total && summary.current !== null">
<div class="distribution-legend">
<span><i class="legend-bar" />通过记录占比</span>
<span><i class="legend-marker" />当前提交</span>
<span class="distribution-count">{{ summary.total }} 条示例记录</span>
</div>
<svg
:viewBox="`0 0 ${width} 275`"
role="group"
:aria-label="`${metric === 'time' ? '运行时间' : '内存消耗'}分布,共 ${summary.total} 条示例通过记录;当前提交 ${formatValue(summary.current)} ${unit}`"
>
<g v-for="tick in ticks" :key="tick" aria-hidden="true">
<line x1="44" :x2="width - 18" :y1="y(tick)" :y2="y(tick)" class="grid-line" />
<text x="36" :y="y(tick) + 4" text-anchor="end">{{ Number(tick.toFixed(1)) }}%</text>
</g>
<g v-for="(bin, index) in summary.bins" :key="index">
<rect
:x="44 + index * binWidth + 1"
:y="y(bin.percent)"
:width="Math.max(1, binWidth - 3)"
:height="224 - y(bin.percent)"
rx="2"
class="distribution-bar"
:class="{ highlighted: active === index }"
aria-hidden="true"
/>
<rect
:x="44 + index * binWidth"
y="53"
:width="binWidth"
height="175"
fill="transparent"
tabindex="0"
role="img"
:aria-label="describeBin(index)"
class="distribution-hit"
@pointerenter="active = index"
@pointerleave="active = null"
@focus="active = index"
@blur="active = null"
@click="active = index"
>
<title>{{ describeBin(index) }}</title>
</rect>
</g>
<g v-for="tick in xTicks" :key="tick" aria-hidden="true">
<text
:x="x(tick)"
y="250"
:text-anchor="tick === 0 ? 'start' : tick === summary.end ? 'end' : 'middle'"
>
{{ formatValue(tick) }}
</text>
</g>
<g class="current-marker" aria-hidden="true">
<line :x1="x(summary.current)" :x2="x(summary.current)" y1="43" y2="224" />
<circle :cx="x(summary.current)" cy="43" r="5" />
<text
:x="x(summary.current)"
y="29"
:text-anchor="
x(summary.current) < 95
? 'start'
: x(summary.current) > width - 75
? 'end'
: 'middle'
"
>
本次 {{ formatValue(summary.current) }} {{ unit }}
</text>
</g>
<text :x="width - 18" y="272" text-anchor="end" aria-hidden="true">{{ unit }}</text>
</svg>
<p class="distribution-detail" aria-live="polite">
{{
selected && active !== null
? describeBin(active)
: '悬停或聚焦柱形,查看区间记录数与占比'
}}
</p>
<p class="distribution-note">
用量越低,表现越好。同用量记录不计入“优于”比例;本次有
{{ summary.tied }} 条同用量示例记录。
</p>
</template>
<EmptyState
v-else
:title="submission.verdict !== 'AC' ? '通过后可比较答题表现' : '暂无可比较的统计'"
:description="
submission.verdict !== 'AC'
? '分布图只统计通过记录。'
: '当前题目与语言的分布数据尚未接入。'
"
/>
</div>
</section>
</template>
<style scoped>
.performance-distribution {
padding: 26px;
border: 1px solid #e4ebf4;
border-radius: 14px;
background: #fff;
margin-bottom: 20px;
}
.performance-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 22px;
}
.performance-heading h2 {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
color: #455e80;
}
.performance-heading h2 .el-icon {
color: var(--blue);
}
.performance-heading p {
margin-top: 6px;
font-size: 12px;
color: #8490a3;
}
.performance-preview {
background: #f0f6ff;
color: #6195ce;
border-radius: 5px;
padding: 3px 8px;
font-size: 11px;
white-space: nowrap;
}
.performance-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.performance-metrics button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 9px;
border: 1px solid #e4ebf4;
border-radius: 10px;
padding: 18px 20px;
text-align: left;
color: #687f9c;
background: #fafcfe;
}
.performance-metrics button.selected {
background: #eff7ff;
border-color: #8bbdf1;
}
.performance-metrics button:hover {
border-color: #8bbdf1;
}
.performance-metrics button:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 3px;
}
.metric-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.metric-value {
font-size: 28px;
line-height: 1.2;
font-weight: 650;
color: #354f70;
font-variant-numeric: tabular-nums;
}
.metric-value small {
font-size: 12px;
font-weight: 400;
color: #8499b2;
}
.metric-percentile,
.metric-unavailable {
font-size: 11px;
}
.metric-percentile strong {
color: #409eff;
}
.metric-unavailable {
color: #98a8bb;
}
.performance-chart {
margin-top: 22px;
}
.distribution-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
font-size: 11px;
color: #8499b2;
}
.distribution-legend span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.distribution-count {
margin-left: auto;
}
.legend-bar {
width: 8px;
height: 8px;
background: #7ab6f0;
border-radius: 2px;
}
.legend-marker {
width: 8px;
height: 8px;
background: #355d93;
border-radius: 50%;
}
.performance-chart svg {
display: block;
width: 100%;
overflow: visible;
}
.performance-chart svg text {
font-size: 11px;
fill: #8d9eb3;
}
.grid-line {
stroke: #eaf0f7;
}
.distribution-bar {
fill: #7ab6f0;
}
.distribution-bar.highlighted {
fill: #409eff;
}
.distribution-hit:focus {
outline: none;
stroke: #409eff;
stroke-width: 1;
}
.current-marker {
pointer-events: none;
}
.current-marker line {
stroke: #355d93;
stroke-dasharray: 4 4;
}
.current-marker circle {
fill: #355d93;
stroke: #fff;
stroke-width: 2;
}
.current-marker text {
fill: #355d93 !important;
font-weight: 600;
}
.distribution-detail {
min-height: 27px;
color: #617d9f;
font-size: 12px;
padding: 6px 12px;
background: #f5f8fc;
border-radius: 6px;
}
.distribution-note {
margin-top: 10px;
font-size: 11px;
color: #8d9eb3;
}
@media (max-width: 600px) {
.performance-distribution {
padding: 18px 16px;
}
.performance-metrics {
gap: 10px;
}
.performance-metrics button {
padding: 14px 12px;
}
.metric-value {
font-size: 24px;
}
.distribution-count {
margin-left: 0;
}
}
</style>
+104
View File
@@ -0,0 +1,104 @@
import type { DistributionPoint } from '../lib/performance'
import type { Submission } from './preview'
interface PerformancePreview {
problemId: string
language: string
time: DistributionPoint[]
memory: DistributionPoint[]
}
// Fixed display fixtures, independent of real judge results. Memory values use KiB.
const previews: PerformancePreview[] = [
{
problemId: '1001',
language: 'Rust',
time: [
{ value: 0, count: 8 },
{ value: 4, count: 12 },
{ value: 8, count: 25 },
{ value: 12, count: 16 },
{ value: 16, count: 34 },
{ value: 20, count: 46 },
{ value: 24, count: 29 },
{ value: 28, count: 18 },
{ value: 32, count: 13 },
{ value: 40, count: 9 },
{ value: 48, count: 6 },
{ value: 64, count: 4 },
],
memory: [
{ value: 1024, count: 7 },
{ value: 1536, count: 19 },
{ value: 2048, count: 58 },
{ value: 2560, count: 41 },
{ value: 3072, count: 34 },
{ value: 3584, count: 22 },
{ value: 4096, count: 15 },
{ value: 5120, count: 13 },
{ value: 6144, count: 7 },
{ value: 8192, count: 4 },
],
},
{
problemId: '1002',
language: 'Python',
time: [
{ value: 24, count: 3 },
{ value: 32, count: 8 },
{ value: 40, count: 16 },
{ value: 48, count: 28 },
{ value: 56, count: 39 },
{ value: 64, count: 31 },
{ value: 72, count: 24 },
{ value: 80, count: 18 },
{ value: 96, count: 11 },
{ value: 112, count: 6 },
{ value: 128, count: 4 },
],
memory: [
{ value: 5120, count: 6 },
{ value: 6144, count: 18 },
{ value: 7168, count: 46 },
{ value: 8192, count: 53 },
{ value: 9216, count: 31 },
{ value: 10240, count: 19 },
{ value: 12288, count: 9 },
{ value: 14336, count: 6 },
],
},
{
problemId: '1003',
language: 'C++',
time: [
{ value: 16, count: 8 },
{ value: 24, count: 16 },
{ value: 34, count: 21 },
{ value: 40, count: 32 },
{ value: 48, count: 41 },
{ value: 56, count: 28 },
{ value: 64, count: 19 },
{ value: 80, count: 12 },
{ value: 96, count: 7 },
{ value: 120, count: 4 },
],
memory: [
{ value: 2048, count: 9 },
{ value: 3072, count: 24 },
{ value: 4096, count: 51 },
{ value: 5120, count: 47 },
{ value: 6144, count: 29 },
{ value: 8192, count: 17 },
{ value: 10240, count: 8 },
{ value: 12288, count: 3 },
],
},
]
export function findPerformancePreview(submission: Submission) {
if (submission.verdict !== 'AC') return undefined
return previews.find(
(preview) =>
preview.problemId === submission.problemId && preview.language === submission.language,
)
}
+3 -2
View File
@@ -264,7 +264,7 @@ export const verdictLabels: Record<Verdict, string> = {
CE: 'Compile Error',
Pending: '等待评测',
}
export const submissions: {
export interface Submission {
id: string
problemId: string
username: string
@@ -273,7 +273,8 @@ export const submissions: {
time: number | null
memory: number | null
created: string
}[] = [
}
export const submissions: Submission[] = [
{
id: '10248',
problemId: '1001',
+47
View File
@@ -0,0 +1,47 @@
export interface DistributionPoint {
value: number
count: number
}
export function summarizeDistribution(
points: readonly DistributionPoint[],
current: number | null,
) {
const valid = points.filter(
({ value, count }) =>
Number.isFinite(value) && value >= 0 && Number.isSafeInteger(count) && count > 0,
)
const total = valid.reduce((sum, point) => sum + point.count, 0)
const measured = current !== null && Number.isFinite(current) && current >= 0
const slower = measured
? valid.reduce((sum, point) => sum + (point.value > current ? point.count : 0), 0)
: 0
const tied = measured
? valid.reduce((sum, point) => sum + (point.value === current ? point.count : 0), 0)
: 0
const maximum = valid.reduce((max, point) => Math.max(max, point.value), 0)
const extent = Math.max(maximum, measured ? current : 0, 1)
const rawStep = extent / 24
const magnitude = 10 ** Math.floor(Math.log10(rawStep))
const step = [1, 2, 5, 10].find((size) => size * magnitude >= rawStep)! * magnitude
const binCount = Math.max(1, Math.ceil(extent / step))
const bins = Array.from({ length: binCount }, (_, index) => ({
start: index * step,
end: (index + 1) * step,
count: 0,
percent: 0,
}))
for (const point of valid) {
// The last interval includes its upper endpoint; other intervals exclude it.
bins[Math.min(binCount - 1, Math.floor(point.value / step))]!.count += point.count
}
for (const bin of bins) bin.percent = total ? (bin.count / total) * 100 : 0
return {
total,
bins,
end: binCount * step,
betterThan: total && measured ? (slower / total) * 100 : null,
tied,
current: measured ? current : null,
}
}
+173 -63
View File
@@ -2,13 +2,14 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { findProblem, discussions, submissions } from '../data/preview'
import { findProblem, discussions, submissions, type Submission } from '../data/preview'
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'
import SubmissionPerformance from '../components/SubmissionPerformance.vue'
const route = useRoute()
const id = String(route.params.id)
const problem = computed(() => findProblem(id))
@@ -17,12 +18,20 @@ const theme = ref('vs')
const fontSize = ref(14)
const tab = ref('description')
const testTab = ref('input')
const workspaceTab = ref<'code' | 'submission'>('code')
const selectedSubmission = ref<Submission>()
const testInput = ref(problem.value?.sampleInput ?? '')
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))
function viewSubmission(submissionId: string) {
const submission = codeRelated.value.find((item) => item.id === submissionId)
if (!submission) return
selectedSubmission.value = submission
workspaceTab.value = 'submission'
}
const draftText = computed(
() =>
({
@@ -160,10 +169,14 @@ async function copySample(value: string) {
/><el-table-column label="结果" width="100"
><template #default="{ row }"
><VerdictTag :value="row.verdict" /></template></el-table-column
><el-table-column label="详情" width="80"
><el-table-column label="用时" width="90"
><template #default="{ row }">{{
row.time === null ? '—' : row.time + ' ms'
}}</template> </el-table-column
><el-table-column label="详情" width="105"
><template #default="{ row }"
><RouterLink :to="'/submissions/' + row.id" class="text-link"
>查看</RouterLink
><el-button link type="primary" @click="viewSubmission(row.id)"
>查看表现</el-button
></template
></el-table-column
></el-table
@@ -210,67 +223,106 @@ async function copySample(value: string) {
@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-tooltip content="重置模板"
><el-button size="small" aria-label="重置代码模板" @click="resetCode"
><UiIcon name="refresh" /></el-button></el-tooltip
><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"
><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">
<CodeEditor 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
<header class="workspace-view-tabs" aria-label="工作台视图">
<button
type="button"
:aria-pressed="workspaceTab === 'code'"
@click="workspaceTab = 'code'"
>
<UiIcon name="document" />代码编辑器
</button>
<button
type="button"
:disabled="!selectedSubmission"
:aria-pressed="workspaceTab === 'submission'"
@click="workspaceTab = 'submission'"
>
<UiIcon name="rankings" />{{
selectedSubmission ? '提交 #' + selectedSubmission.id : '评测详情'
}}
</button>
</header>
<div v-show="workspaceTab === 'code'" class="editor-workspace">
<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-tooltip content="重置模板"
><el-button size="small" aria-label="重置代码模板" @click="resetCode"
><UiIcon name="refresh" /></el-button></el-tooltip
><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"
><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">
<CodeEditor 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
>
</div>
<footer class="editor-buttons">
<span class="small muted">评测服务待接入</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>
</footer>
</div>
<div
v-if="selectedSubmission"
v-show="workspaceTab === 'submission'"
class="submission-workspace"
>
<header class="workspace-submission-heading">
<div>
<VerdictTag :value="selectedSubmission.verdict" full />
<p>{{ selectedSubmission.username }} · {{ selectedSubmission.created }}</p>
</div>
<RouterLink :to="'/submissions/' + selectedSubmission.id" class="text-link"
>完整详情 →</RouterLink
>
</header>
<SubmissionPerformance :submission="selectedSubmission" />
<p class="notice-strip">当前展示示例评测记录,源码与测试点详情待接入。</p>
</div>
<footer class="editor-buttons">
<span class="small muted">评测服务待接入</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>
</footer>
</section>
</div>
<EmptyState v-else title="题目不存在"
@@ -299,6 +351,64 @@ async function copySample(value: string) {
min-height: 0;
border-radius: 14px;
}
.editor-workspace {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.workspace-view-tabs {
display: flex;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #edf0f5;
}
.workspace-view-tabs button {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: #8396af;
font-size: 12px;
}
.workspace-view-tabs button[aria-pressed='true'] {
color: var(--blue);
background: #edf6ff;
}
.workspace-view-tabs button:disabled {
color: #aab6c5;
}
.workspace-view-tabs button:focus-visible {
outline: 2px solid var(--blue);
}
.submission-workspace {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px;
}
.workspace-submission-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.workspace-submission-heading p {
margin-top: 8px;
font-size: 11px;
color: #93a4ba;
}
.workspace-submission-heading > a {
font-size: 12px;
}
.submission-workspace .performance-distribution {
padding: 20px;
}
.statement-tabs {
padding: 8px 20px 0;
background: #fff;
@@ -6,6 +6,7 @@ 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 SubmissionPerformance from '../components/SubmissionPerformance.vue'
const route = useRoute()
const submission = computed(() => submissions.find((s) => s.id === route.params.id))
</script>
@@ -48,6 +49,7 @@ const submission = computed(() => submissions.find((s) => s.id === route.params.
><small>{{ submission.created }}</small>
</div>
</div>
<SubmissionPerformance :submission="submission" />
<PanelCard title="评测详情" icon="submissions"
><p class="notice-strip">当前展示评测界面的示例记录,实际用例结果、日志和源码尚未接入。</p>
<EmptyState
+64
View File
@@ -0,0 +1,64 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { summarizeDistribution } from '../src/lib/performance.ts'
test('zero usage is measured and ties do not count as better performance', () => {
const summary = summarizeDistribution(
[
{ value: 0, count: 3 },
{ value: 12, count: 2 },
{ value: 24, count: 5 },
],
12,
)
assert.equal(summary.total, 10)
assert.equal(summary.betterThan, 50)
assert.equal(summary.tied, 2)
assert.equal(summarizeDistribution([{ value: 0, count: 3 }], 0).betterThan, 0)
})
test('bin boundaries and the final upper endpoint preserve every sample', () => {
const summary = summarizeDistribution(
[
{ value: 0, count: 2 },
{ value: 5, count: 3 },
{ value: 100, count: 7 },
],
100,
)
assert.equal(summary.bins[0].count, 2)
assert.equal(summary.bins[1].count, 3)
assert.equal(summary.bins.at(-1)!.count, 7)
assert.equal(
summary.bins.reduce((sum, bin) => sum + bin.count, 0),
summary.total,
)
assert.ok(Math.abs(summary.bins.reduce((sum, bin) => sum + bin.percent, 0) - 100) < 1e-9)
})
test('missing measurements and invalid samples produce no percentile', () => {
const summary = summarizeDistribution(
[
{ value: -1, count: 3 },
{ value: NaN, count: 2 },
{ value: Infinity, count: 1 },
{ value: 10, count: -1 },
{ value: 20, count: 1.5 },
{ value: 24, count: 0 },
],
null,
)
assert.equal(summary.total, 0)
assert.equal(summary.betterThan, null)
assert.equal(summary.current, null)
assert.ok(summary.bins.every((bin) => bin.percent === 0))
assert.equal(summarizeDistribution([{ value: 12, count: 2 }], null).betterThan, null)
assert.equal(summarizeDistribution([{ value: 12, count: 2 }], NaN).current, null)
})
test('current measurements outside the cohort remain within the plot', () => {
const summary = summarizeDistribution([{ value: 12, count: 2 }], 140)
assert.ok(summary.end >= 140)
assert.equal(summary.betterThan, 0)
assert.equal(summary.total, 2)
})