style(题目): 题目页照设计稿「机房(方案 B)」重排——顶满的两栏、带底色的上下文条、下划线页签、新的结果和题面样式
Deploy / deploy (push) Canceled after 0s

原来实施时只照了设计文档,没对照设计稿,一路套 Naive 现成的分段页签、提示框、单选组,
功能对了、样子差很多。这次逐块对着设计稿的画板改:

- 两栏顶满内容区,中间一条 1px 分隔线;上下文条、页签行、工具栏下面各一道分隔线
- 上下文条:课堂浅绿、题单浅蓝、比赛浅黄三种底色;‹ › 是不带边框的箭头;比赛的题号是方角、
  做错的是红色 ✕;手机上不摆一排题号,写「这节课 做完 2/5 · 这是第 3 道」配 44px 的箭头
- 「题目 / 结果」是下划线页签,右边「统计 / 点评 / 我的提交」是胶囊按钮
- 抽屉盖住整个左栏(连上下文条、页签行),顶上有自己的「我的提交 / 统计 / 点评」页签,Esc 能关
- 结果页签:三个分段是胶囊,选中的深色实心、带时间(「提交结果 · 10:42」);标题是圆形图标 +
  大字 + 灰字(「答案错误 在例子 1 上就错了」),不再套整块彩色提示框;答错的对比三栏并排、
  只把不一样的那几个字标红;「题目要求的输出」是一行小字;「让 AI 分析我的代码」是描边按钮;
  答对后的「下一题」是浅绿卡片里 44px 的大按钮
- 工具栏:语言、运行例子(描边)、提交(实心,就叫「提交」)靠左,求助、⋯ 推到最右;
  交对之后「提交」变成绿色描边;协作条换成设计稿的深蓝
- 题面:标题下「简单 · 还没交过」,流程图过关的加「流程图 A 级」;代码要求压成一行;
  小标题不带图标;输入、输出左右两栏;参考流程图是一行可以点开的条;例子表带「例子 1」和「复制」
- 去掉「限时 1 秒」:执行时间、内存对中职学生只会添迷惑(和设计稿不同,用户定的)
- 我的提交:一整块带框的列表,结果用彩色文字;展开的代码先露 5 行;题单锁住的几次合成一行
- 错在隐藏测试点时「去自己输入」给的数跳过例子里出现过的

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-28 20:04:20 +08:00
1 parent 83b6b7a81a
commit 1fd9bf67c2
17 files changed
+1800 -706

No files matched your search

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useThemeVars } from "naive-ui"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import { markdownToText } from "oj/problem/utils/plainText"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import {
classifyDiff,
@@ -34,16 +32,21 @@ const props = defineProps<{
full?: boolean
}>()
const isDark = useDark()
const theme = useThemeVars()
const { problem } = storeToRefs(useProblemStore())
const submissionStore = useSubmissionStore()
/** 题目要求的输出(题面「输出」那一节的原文,Markdown)。SQL 题没有这一节 */
const requirement = computed(() =>
props.full && !problem.value?.sqlConfig ? (problem.value?.outputDescription ?? "").trim() : "",
)
/** 自己输入走 Judge0,SQL 跑不了 */
const canCustomRun = computed(() => props.full && !problem.value?.sqlConfig)
/**
* 卡片末尾那行「题目原话」(设计稿「答案错误」「错在隐藏测试点」):
* 例子上就错了 —— 摆题面「输出」那一节,格式错、漏了一行这类,对着原话一看就明白;
* 例子都对了、错在隐藏测试点 —— 摆「描述」,边界条件多半写在那里。SQL 题两节都不摆
*/
const requirement = computed(() => {
if (!props.full || !problem.value || problem.value.sqlConfig) return null
const source = props.check.passed ? problem.value.description : problem.value.outputDescription
const text = markdownToText(source ?? "", props.check.passed ? 160 : 120)
if (!text) return null
return { label: props.check.passed ? "题目原话" : "题目要求的输出", text }
})
/** 后端每段截到 2000 字,到了上限就是被截过 */
const TEXT_LIMIT = 2000
@@ -64,6 +67,40 @@ const diffLine = computed(() =>
firstDifferentLine(props.check.expected ?? "", props.check.output ?? ""),
)
/**
* 不一样的那一行,拆成「相同的开头 / 不同的中间 / 相同的结尾」三段,只把中间那段标红
* (设计稿:多出来的「等级:」、错成的「E」、多出来的「333333333333」)。
* 标在「你的输出」上;你的输出那段是空的(少了东西)就标在正确输出上,告诉他少了什么
*/
function splitDiff(expected: string, output: string) {
const e = [...expected]
const o = [...output]
let head = 0
while (head < e.length && head < o.length && e[head] === o[head]) head++
let tail = 0
while (
tail < e.length - head &&
tail < o.length - head &&
e[e.length - 1 - tail] === o[o.length - 1 - tail]
)
tail++
const cut = (chars: string[]) => ({
before: chars.slice(0, head).join(""),
marked: chars.slice(head, chars.length - tail).join(""),
after: chars.slice(chars.length - tail).join(""),
})
const mine = cut(o)
const theirs = cut(e)
return mine.marked ? { output: mine, expected: null } : { output: null, expected: theirs }
}
const diffParts = computed(() => {
if (!onSample.value || diffLine.value < 0) return null
const expected = lines(props.check.expected ?? "")[diffLine.value] ?? ""
const output = lines(props.check.output ?? "")[diffLine.value] ?? ""
return splitDiff(expected, output)
})
/**
* 只差空白的时候把空白画出来,不然两边看着一模一样。
*
@@ -83,28 +120,17 @@ function lines(text: string) {
</script>
<template>
<n-card embedded class="explain-card">
<n-flex v-if="check.passed" vertical :size="10">
<div class="explain">
题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。
</div>
<n-button
v-if="canCustomRun"
text
type="primary"
class="custom"
@click="submissionStore.revealResult('custom')"
>
去自己输入试试 ›
</n-button>
</n-flex>
<n-flex v-else vertical :size="12">
<div class="explain">
<div class="explain-card">
<p v-if="check.passed" class="explain">
题目里的例子都对了,没通过的是隐藏的测试点。想想特殊情况:最大的数、最小的数、0、负数,或者题目里专门提到的情况。
</p>
<template v-else>
<p class="explain">
<b>例子 {{ (check.index ?? 0) + 1 }} 没有通过{{ plain ? "。" : ":" }}</b
><template v-if="!plain">{{ hint }}</template>
</div>
</p>
<div class="blocks">
<div class="block input">
<div class="block">
<div class="label">输入</div>
<div class="text">
<div
@@ -118,111 +144,84 @@ function lines(text: string) {
<div class="block">
<div class="label">正确输出</div>
<div class="text">
<div
v-for="(line, i) in lines(check.expected ?? '')"
:key="i"
class="line"
:class="{ diff: onSample && i === diffLine }"
v-text="line"
/>
<template v-for="(line, i) in lines(check.expected ?? '')" :key="i">
<div v-if="i === diffLine && diffParts?.expected" class="line">
<span v-text="diffParts.expected.before" /><span
class="diff"
v-text="diffParts.expected.marked"
/><span v-text="diffParts.expected.after" />
</div>
<div v-else class="line" v-text="line" />
</template>
</div>
</div>
<div class="block">
<div class="label">你的输出</div>
<div v-if="check.output" class="text">
<div
v-for="(line, i) in lines(check.output)"
:key="i"
class="line"
:class="{ diff: onSample && i === diffLine }"
v-text="line"
/>
<template v-for="(line, i) in lines(check.output)" :key="i">
<div v-if="i === diffLine && diffParts?.output" class="line">
<span v-text="diffParts.output.before" /><span
class="diff"
v-text="diffParts.output.marked"
/><span v-text="diffParts.output.after" />
</div>
<div v-else class="line" v-text="line" />
</template>
</div>
<div v-else class="text empty">(什么都没有输出)</div>
<n-text v-if="check.output && check.output.length >= TEXT_LIMIT" depth="3" class="cut">
<div v-if="check.output && check.output.length >= TEXT_LIMIT" class="cut">
输出太长,只显示了前面一部分
</n-text>
</div>
</div>
</div>
</n-flex>
<div v-if="requirement" class="requirement">
<div class="label">题目要求的输出</div>
<MdPreview
preview-theme="vuepress"
:model-value="requirement"
:theme="isDark ? 'dark' : 'light'"
/>
</div>
</n-card>
</template>
<p v-if="requirement" class="requirement">{{ requirement.label }}:{{ requirement.text }}</p>
</div>
</template>
<style scoped>
/* 设计稿「答案错误」:一块浅灰卡片,三栏并排的白底小框 */
.explain-card {
max-width: 560px;
}
.custom {
align-self: flex-start;
}
.requirement {
margin-top: 14px;
padding-top: 10px;
border-top: 1px dashed rgba(128, 128, 128, 0.3);
}
/* md-editor 的预览自带白底、一圈 padding 和段落外边距,放在灰卡片里像一块补丁 */
.requirement :deep(.md-editor) {
background: transparent;
}
.requirement :deep(.md-editor-preview-wrapper) {
padding: 0;
}
.requirement :deep(.md-editor-preview > :first-child) {
margin-top: 0;
}
.requirement :deep(.md-editor-preview > :last-child) {
margin-bottom: 0;
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind("theme.dividerColor");
display: flex;
flex-direction: column;
gap: 10px;
}
.explain {
font-size: 16px;
margin: 0;
line-height: 1.7;
}
.blocks {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.block {
min-width: 0;
}
.block.input {
grid-column: 1 / -1;
padding: 6px 10px 8px;
border-radius: 5px;
background-color: v-bind("theme.cardColor");
border: 1px solid v-bind("theme.dividerColor");
}
.label {
font-size: 13px;
opacity: 0.7;
margin-bottom: 4px;
font-size: 12px;
color: v-bind("theme.textColor3");
margin-bottom: 3px;
}
.text {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
padding: 8px 10px;
border-radius: 4px;
font-family: Consolas, Monaco, monospace;
font-size: 14px;
line-height: 1.6;
max-height: 200px;
overflow: auto;
background-color: rgba(128, 128, 128, 0.1);
}
/* 每行用 v-text 填,不写 {{ line }}:模板里标签两边的换行缩进会被 Vue 压成一个空格,
@@ -233,14 +232,26 @@ function lines(text: string) {
}
.diff {
background-color: rgba(208, 48, 80, 0.2);
background-color: rgba(208, 48, 80, 0.18);
color: v-bind("theme.errorColorPressed");
}
.empty {
opacity: 0.6;
color: v-bind("theme.textColor3");
font-family: inherit;
font-size: 13px;
}
.cut {
margin-top: 4px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.requirement {
margin: 0;
font-size: 13px;
color: v-bind("theme.textColor2");
line-height: 1.6;
}
</style>