Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad35865c0 | ||
|
|
62132324da |
@@ -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>
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -264,7 +264,7 @@ export const verdictLabels: Record<Verdict, string> = {
|
|||||||
CE: 'Compile Error',
|
CE: 'Compile Error',
|
||||||
Pending: '等待评测',
|
Pending: '等待评测',
|
||||||
}
|
}
|
||||||
export const submissions: {
|
export interface Submission {
|
||||||
id: string
|
id: string
|
||||||
problemId: string
|
problemId: string
|
||||||
username: string
|
username: string
|
||||||
@@ -273,7 +273,8 @@ export const submissions: {
|
|||||||
time: number | null
|
time: number | null
|
||||||
memory: number | null
|
memory: number | null
|
||||||
created: string
|
created: string
|
||||||
}[] = [
|
}
|
||||||
|
export const submissions: Submission[] = [
|
||||||
{
|
{
|
||||||
id: '10248',
|
id: '10248',
|
||||||
problemId: '1001',
|
problemId: '1001',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 { useCodeDraft, codeTemplates, languageLabels } from '../composables/useCodeDraft'
|
||||||
import CodeEditor from '../components/CodeEditor.vue'
|
import CodeEditor from '../components/CodeEditor.vue'
|
||||||
import DifficultyTag from '../components/DifficultyTag.vue'
|
import DifficultyTag from '../components/DifficultyTag.vue'
|
||||||
import VerdictTag from '../components/VerdictTag.vue'
|
import VerdictTag from '../components/VerdictTag.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
import UiIcon from '../components/UiIcon.vue'
|
import UiIcon from '../components/UiIcon.vue'
|
||||||
|
import SubmissionPerformance from '../components/SubmissionPerformance.vue'
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
const problem = computed(() => findProblem(id))
|
const problem = computed(() => findProblem(id))
|
||||||
@@ -17,12 +18,20 @@ const theme = ref('vs')
|
|||||||
const fontSize = ref(14)
|
const fontSize = ref(14)
|
||||||
const tab = ref('description')
|
const tab = ref('description')
|
||||||
const testTab = ref('input')
|
const testTab = ref('input')
|
||||||
|
const workspaceTab = ref<'code' | 'submission'>('code')
|
||||||
|
const selectedSubmission = ref<Submission>()
|
||||||
const testInput = ref(problem.value?.sampleInput ?? '')
|
const testInput = ref(problem.value?.sampleInput ?? '')
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
const split = ref(50)
|
const split = ref(50)
|
||||||
const workspace = ref<HTMLDivElement>()
|
const workspace = ref<HTMLDivElement>()
|
||||||
const codeRelated = computed(() => submissions.filter((s) => s.problemId === id))
|
const codeRelated = computed(() => submissions.filter((s) => s.problemId === id))
|
||||||
const relatedDiscussion = computed(() => discussions.filter((d) => d.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(
|
const draftText = computed(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
@@ -160,10 +169,14 @@ async function copySample(value: string) {
|
|||||||
/><el-table-column label="结果" width="100"
|
/><el-table-column label="结果" width="100"
|
||||||
><template #default="{ row }"
|
><template #default="{ row }"
|
||||||
><VerdictTag :value="row.verdict" /></template></el-table-column
|
><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 }"
|
><template #default="{ row }"
|
||||||
><RouterLink :to="'/submissions/' + row.id" class="text-link"
|
><el-button link type="primary" @click="viewSubmission(row.id)"
|
||||||
>查看</RouterLink
|
>查看表现</el-button
|
||||||
></template
|
></template
|
||||||
></el-table-column
|
></el-table-column
|
||||||
></el-table
|
></el-table
|
||||||
@@ -210,6 +223,26 @@ async function copySample(value: string) {
|
|||||||
@keydown.right.prevent="split = Math.min(70, split + 2)"
|
@keydown.right.prevent="split = Math.min(70, split + 2)"
|
||||||
/>
|
/>
|
||||||
<section class="panel editor-panel">
|
<section class="panel editor-panel">
|
||||||
|
<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">
|
<header class="editor-toolbar">
|
||||||
<span class="small muted">语言</span
|
<span class="small muted">语言</span
|
||||||
><el-select v-model="language" aria-label="编程语言" size="small"
|
><el-select v-model="language" aria-label="编程语言" size="small"
|
||||||
@@ -234,7 +267,8 @@ async function copySample(value: string) {
|
|||||||
><el-option label="浅色" value="vs" /><el-option
|
><el-option label="浅色" value="vs" /><el-option
|
||||||
label="深色"
|
label="深色"
|
||||||
value="vs-dark" /></el-select
|
value="vs-dark" /></el-select
|
||||||
><label>字号</label><el-slider v-model="fontSize" :min="12" :max="22" show-input /></div
|
><label>字号</label
|
||||||
|
><el-slider v-model="fontSize" :min="12" :max="22" show-input /></div
|
||||||
></el-popover>
|
></el-popover>
|
||||||
</header>
|
</header>
|
||||||
<div class="editor-area">
|
<div class="editor-area">
|
||||||
@@ -271,6 +305,24 @@ async function copySample(value: string) {
|
|||||||
><el-button size="small" type="success" plain disabled>自测运行</el-button
|
><el-button size="small" type="success" plain disabled>自测运行</el-button
|
||||||
><el-button size="small" type="primary" disabled>提交代码</el-button>
|
><el-button size="small" type="primary" disabled>提交代码</el-button>
|
||||||
</footer>
|
</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>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState v-else title="题目不存在"
|
<EmptyState v-else title="题目不存在"
|
||||||
@@ -299,6 +351,64 @@ async function copySample(value: string) {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: 14px;
|
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 {
|
.statement-tabs {
|
||||||
padding: 8px 20px 0;
|
padding: 8px 20px 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import PageHeading from '../components/PageHeading.vue'
|
|||||||
import PanelCard from '../components/PanelCard.vue'
|
import PanelCard from '../components/PanelCard.vue'
|
||||||
import VerdictTag from '../components/VerdictTag.vue'
|
import VerdictTag from '../components/VerdictTag.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import SubmissionPerformance from '../components/SubmissionPerformance.vue'
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const submission = computed(() => submissions.find((s) => s.id === route.params.id))
|
const submission = computed(() => submissions.find((s) => s.id === route.params.id))
|
||||||
</script>
|
</script>
|
||||||
@@ -48,6 +49,7 @@ const submission = computed(() => submissions.find((s) => s.id === route.params.
|
|||||||
><small>{{ submission.created }}</small>
|
><small>{{ submission.created }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SubmissionPerformance :submission="submission" />
|
||||||
<PanelCard title="评测详情" icon="submissions"
|
<PanelCard title="评测详情" icon="submissions"
|
||||||
><p class="notice-strip">当前展示评测界面的示例记录,实际用例结果、日志和源码尚未接入。</p>
|
><p class="notice-strip">当前展示评测界面的示例记录,实际用例结果、日志和源码尚未接入。</p>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user