Files
OJ2/apps/web/src/oj/submission/components/FlowchartPane.vue
T
xuyueandClaude Opus 5.5 0f75fd1bd0 fix(提交列表): 流程图右栏窄时题目名被「查看大图」「重新评分」挤成一个字
右栏不到 760 宽时这两个按钮只留图标,字放进悬停提示。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:41:47 +08:00

682 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getFlowchartSubmission } from "oj/api"
import { useMermaid } from "shared/composables/useMermaid"
import { sortFlowchartCriteria } from "utils/constants"
import type { FlowchartSubmission, FlowchartSubmissionListItem } from "utils/types"
import { FlowchartSubmissionStatus } from "utils/types"
import { useTone } from "shared/composables/tone"
import { submissionTimeText } from "../utils"
import FlowchartState from "./FlowchartState.vue"
import UserName from "shared/components/UserName.vue"
import FlowchartCases from "shared/components/FlowchartCases.vue"
import { caseHeadline, caseReport } from "utils/flowchart"
/**
* 流程图模式的右栏:左边画出来的图,右边四项评分和建议。原来要点编号再开一个
* 1000px 宽的弹框(FlowchartScoreDetail),翻全班的图时一样是「开 → 关 → 开」。
*/
const props = defineProps<{
row: FlowchartSubmissionListItem
teacher: boolean
position: { index: number; count: number } | null
/** 手机上的整屏详情:头部折行、图和评分上下排、不提方向键 */
narrow?: boolean
}>()
const emit = defineEmits<{
filterUser: [username: string]
filterProblem: [displayId: string]
openProblem: [row: FlowchartSubmissionListItem]
retry: [id: string]
}>()
const theme = useThemeVars()
const tone = useTone()
const { renderError, renderFlowchart } = useMermaid()
const detail = ref<FlowchartSubmission | null>(null)
const loading = ref(false)
const failed = ref(false)
const cache = new Map<string, FlowchartSubmission>()
const graph = useTemplateRef<HTMLElement>("graph")
const bigGraph = useTemplateRef<HTMLElement>("bigGraph")
const bigOpen = ref(false)
const evaluating = computed(
() =>
props.row.status === FlowchartSubmissionStatus.PENDING ||
props.row.status === FlowchartSubmissionStatus.PROCESSING,
)
/**
* 按 id 缓存,重新评分一开始(状态回到排队 / 评分中)就把这一条扔掉。原来按「id + 状态」
* 缓存,评完 → 排队 → 评分中 → 评完 转一圈回来正好命中重评之前的旧评语
*/
let loadSeq = 0
async function load() {
const row = props.row
const seq = ++loadSeq
failed.value = false
bigOpen.value = false
if (evaluating.value) cache.delete(row.id)
if (!row.showLink) {
detail.value = null
return
}
let res = cache.get(row.id)
if (!res || res.status !== row.status) {
loading.value = true
try {
res = await getFlowchartSubmission(row.id)
if (seq !== loadSeq) return
if (!evaluating.value && res.status === props.row.status) cache.set(row.id, res)
} catch {
if (seq !== loadSeq) return
failed.value = true
// 别让上一条的图留在那儿,旁边却写着「没拉下来」
detail.value = null
if (graph.value) graph.value.innerHTML = ""
return
} finally {
if (seq === loadSeq) loading.value = false
}
}
if (seq !== loadSeq) return
detail.value = res
await nextTick()
await renderFlowchart(graph.value, res.mermaidCode)
}
// 分开写三个来源:列表每次轮询都换一批新的行对象,一个 getter 返回数组的话回调次次都跑
watch([() => props.row.id, () => props.row.status, () => props.row.showLink], load, {
immediate: true,
})
watch(bigOpen, async (open) => {
if (!open || !detail.value) return
await nextTick()
await renderFlowchart(bigGraph.value, detail.value.mermaidCode)
})
const shown = computed(() => (detail.value?.id === props.row.id ? detail.value : null))
/**
* 评分项明细。内容是 AI 模型原样吐出的 JSON,后端不校验形状,只能按约定断言,
* 字段缺失时用 0 / 空串兜底。jsonb 不保留键序,按固定顺序排(逻辑正确性在最前)
*/
const criteria = computed(() => {
const raw = shown.value?.aiCriteriaDetails ?? {}
const parsed = Object.fromEntries(
Object.entries(raw).map(([key, value]) => {
const item = (value ?? {}) as Partial<{ score: number; max: number; comment: string }>
return [key, { score: item.score ?? 0, max: item.max ?? 0, comment: item.comment ?? "" }]
}),
)
return sortFlowchartCriteria(parsed)
})
function barColor(score: number, max: number) {
const pct = max ? score / max : 0
if (pct >= 0.8) return tone("success").solid
if (pct >= 0.6) return tone("info").solid
if (pct >= 0.4) return tone("warning").solid
return tone("error").solid
}
/** 2026-10 起的「走例子」报告;老提交是 null,照旧显示四项分 */
const report = computed(() => caseReport(shown.value?.aiCriteriaDetails))
const openCase = ref<number | null>(null)
watch(
() => shown.value?.id,
() => (openCase.value = null),
)
const suggestions = computed(() =>
(shown.value?.aiSuggestions ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
)
const canRetry = computed(
() =>
props.teacher &&
(props.row.status === FlowchartSubmissionStatus.COMPLETED ||
props.row.status === FlowchartSubmissionStatus.FAILED),
)
</script>
<template>
<section class="pane" :class="{ narrow }">
<div class="head">
<div class="who">
<a
class="user-link"
:href="`/user?name=${encodeURIComponent(row.username)}`"
target="_blank"
>
<UserName :username="row.username" />
</a>
<button
class="filter-pill"
title="只看他的提交"
aria-label="只看他的提交"
@click="emit('filterUser', row.username)"
>
<Icon icon="ph:funnel-simple-bold" :width="12" />
</button>
</div>
<span v-if="!narrow" class="dot">·</span>
<!-- display: contents 的壳:桌面上照旧一行,手机上题目整行另起 -->
<span class="problem-group">
<a
class="problem"
href="#"
:title="`${row.problemDisplayId} ${row.problemTitle}`"
@click.prevent="emit('openProblem', row)"
>
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
class="filter-pill"
title="只看这道题的提交"
aria-label="只看这道题的提交"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="12" />
</button>
</span>
<div class="spacer"></div>
<!-- 右栏窄时这两个只剩图标(字在 title 里),把宽度让给题目名 -->
<n-button
v-if="shown && !renderError && shown.mermaidCode"
class="head-btn"
size="small"
title="查看大图"
@click="bigOpen = true"
>
<template #icon><Icon icon="ph:corners-out" /></template>
查看大图
</n-button>
<n-button
v-if="teacher"
class="head-btn"
size="small"
title="重新评分"
:disabled="!canRetry"
@click="emit('retry', row.id)"
>
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
重新评分
</n-button>
</div>
<div class="meta">
<FlowchartState :status="row.status" :grade="row.aiGrade" />
<span>{{ submissionTimeText(row.createTime, true) }}</span>
<span v-if="row.processingTime">评分用时 {{ row.processingTime.toFixed(1) }} 秒</span>
</div>
<div v-if="!row.showLink" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 -->
<span class="locked-title">这张流程图看不到</span>
<span class="muted"
>别人画的看不到;自己以前画的在题单布置期内先藏着,在题单里做对或者布置期结束就能看到。</span
>
</div>
<div v-else class="body">
<div class="graph-box">
<!-- mermaid 的报错是英文,学生看自己的图也会碰到:先说中文,原文折起来 -->
<div v-if="renderError" class="render-error">
<b>这张流程图画不出来</b>
<span class="muted">可能是画的时候有框没连上,或者文字里有特殊符号。</span>
<n-collapse>
<n-collapse-item title="原始报错" name="raw">
<pre class="raw">{{ renderError }}</pre>
</n-collapse-item>
</n-collapse>
</div>
<div v-show="!renderError" ref="graph" class="graph"></div>
<n-spin v-if="loading && !shown" size="small" />
<span v-if="failed" class="muted"
>没拉下来,<a href="#" @click.prevent="load">再试一次</a></span
>
</div>
<div class="score-box">
<template v-if="evaluating">
<n-spin size="small" />
<span class="muted">AI 正在评分,一般几秒钟</span>
</template>
<template v-else-if="row.status === FlowchartSubmissionStatus.FAILED">
<span class="muted">这次评分失败了{{ teacher ? ",可以点「重新评分」" : "" }}</span>
</template>
<template v-else-if="shown && report">
<b class="headline">{{ caseHeadline(report) }}</b>
<FlowchartCases v-model:selected="openCase" :report="report" />
<div v-if="shown.aiFeedback" class="note">{{ shown.aiFeedback }}</div>
<div v-if="shown.aiSuggestions" class="note">
<b>还可以更好:</b>
<div>{{ shown.aiSuggestions }}</div>
</div>
</template>
<template v-else-if="shown">
<div v-for="[name, item] in criteria" :key="name" class="crit">
<div class="crit-head">
<b>{{ name }}</b>
<div class="spacer"></div>
<span class="num">{{ item.score }} / {{ item.max }}</span>
</div>
<div class="bar">
<div
class="bar-fill"
:style="{
width: `${item.max ? Math.min(100, (item.score / item.max) * 100) : 0}%`,
background: barColor(item.score, item.max),
}"
></div>
</div>
<div v-if="item.comment" class="muted small">{{ item.comment }}</div>
</div>
<div v-if="shown.aiFeedback" class="note">{{ shown.aiFeedback }}</div>
<div v-if="suggestions.length" class="note">
<b>改进建议:</b>
<div v-for="(line, i) in suggestions" :key="i">{{ line }}</div>
</div>
</template>
</div>
</div>
<div v-if="!narrow" class="foot">
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span>
<span class="hint">换上一张 / 下一张</span>
<div class="spacer"></div>
<span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span>
</div>
<n-modal v-model:show="bigOpen" preset="card" title="流程图" style="width: 90vw">
<div ref="bigGraph" class="big-graph"></div>
</n-modal>
</section>
</template>
<style scoped>
.pane {
flex: 1 1 0;
min-width: 0;
min-height: 0;
box-sizing: border-box;
padding: 18px var(--oj-pad-x) 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: v-bind("theme.bodyColor");
container: pane / inline-size;
}
.head {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-size: 16px;
min-width: 0;
}
/* 顶上固定一行:按方向键翻条时这一行高度不能变,不然下面的代码区跟着上下跳。
名字和按钮不缩,题目名太长时省略(全名在 title 里) */
.head .who {
flex-shrink: 0;
}
.head .problem {
flex: 0 1 auto;
}
.narrow {
padding: 12px 14px;
overflow-y: auto;
}
.narrow .head {
flex-wrap: wrap;
row-gap: 8px;
}
.problem-group {
display: contents;
}
/* 手机:第一行名字,第二行题目,第三行「查看大图」「重新评分」 */
.narrow .head .spacer {
display: none;
}
.narrow .problem-group {
flex-basis: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.narrow .problem {
white-space: normal;
}
.narrow .filter-pill {
width: 32px;
height: 28px;
border-radius: 14px;
}
.narrow .meta {
flex-wrap: wrap;
row-gap: 6px;
}
.narrow .body {
flex-direction: column;
flex: none;
}
.narrow .graph-box {
flex: none;
height: 320px;
}
/* 原来还是 flex: 1 1 0 + overflow: auto:外层不定高,评分那一栏被压成一条缝,只露出半行字 */
.narrow .score-box {
flex: none;
overflow: visible;
}
.narrow .locked {
flex: 1 0 auto;
min-height: 280px;
padding: 16px;
text-align: center;
}
.user-link {
display: flex;
min-width: 0;
text-decoration: none;
}
.render-error {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
align-self: stretch;
}
.raw {
margin: 0;
max-height: 140px;
overflow: auto;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.who {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dot,
.problem-id,
.muted {
color: v-bind("theme.textColor3");
}
.problem {
color: v-bind("theme.textColor1");
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
/* 只留漏斗图标(右栏六百来宽,带字的话名字、题目和按钮排不进一行),字在 title 里 */
.filter-pill {
flex: none;
width: 24px;
height: 22px;
padding: 0;
justify-content: center;
border: 1px solid v-bind("tone('success').background");
border-radius: 11px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind("tone('success').color");
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
}
.spacer {
flex: 1 1 0;
}
.meta {
display: flex;
align-items: center;
gap: 12px;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
}
.body {
flex: 1 1 0;
min-height: 0;
display: flex;
gap: 12px;
}
.graph-box,
.score-box,
.locked {
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.graph-box {
flex: 0 0 42%;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: auto;
padding: 8px;
box-sizing: border-box;
}
.graph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.graph :deep(svg) {
max-width: 100%;
max-height: 100%;
}
.headline {
font-size: 16px;
}
.score-box {
flex: 1 1 0;
min-width: 0;
overflow: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.crit {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.crit-head {
display: flex;
align-items: center;
}
.num {
font-variant-numeric: tabular-nums;
}
.bar {
height: 6px;
border-radius: 3px;
background: v-bind("theme.dividerColor");
}
.bar-fill {
height: 6px;
border-radius: 3px;
}
.small {
font-size: 12px;
}
.note {
font-size: 13px;
line-height: 1.6;
border-top: 1px solid v-bind("theme.dividerColor");
padding-top: 10px;
}
.locked {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-style: dashed;
}
.lock-icon {
color: v-bind("theme.textColor3");
opacity: 0.7;
}
.locked-title {
font-size: 16px;
font-weight: 600;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.keys {
display: inline-flex;
gap: 3px;
}
kbd {
min-width: 18px;
height: 18px;
padding: 0 4px;
box-sizing: border-box;
border: 1px solid v-bind("theme.borderColor");
border-radius: 3px;
background: v-bind("theme.cardColor");
font: inherit;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.big-graph {
display: flex;
justify-content: center;
overflow: auto;
max-height: 80vh;
}
/* 桌面右栏窄了(屏窄、浏览器放大、列表没收窄):顶行一行排不下就固定拆成两行 ——
名字和按钮一行、题目一行。按宽度定、不按内容定,翻条时高度照样不变 */
@container pane (max-width: 760px) {
.pane:not(.narrow) .head-btn {
padding: 0 7px;
}
.pane:not(.narrow) .head-btn :deep(.n-button__content) {
display: none;
}
.pane:not(.narrow) .head-btn :deep(.n-button__icon) {
margin: 0;
}
}
@container pane (max-width: 600px) {
.pane:not(.narrow) .head {
flex-wrap: wrap;
row-gap: 8px;
}
.pane:not(.narrow) .dot {
display: none;
}
/* 名字这块占掉按钮左边的全部宽度、从 0 起算:再窄也只是名字被截,按钮不会掉到下一行 */
.pane:not(.narrow) .head .who {
flex: 1 1 0;
overflow: hidden;
}
.pane:not(.narrow) .head .spacer {
display: none;
}
.pane:not(.narrow) .user-link {
flex-shrink: 0;
}
.pane:not(.narrow) .problem-group {
order: 3;
flex-basis: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.pane:not(.narrow) .keys,
.pane:not(.narrow) .hint {
display: none;
}
}
</style>