style(题目页): 顶上三层条合成一条、课堂和比赛改成左边竖栏;题面小标题和例子重新设计
Deploy / deploy (push) Successful in 58s

- 课堂、比赛在电脑上挪到左栏最左一条竖栏,只写题号(题名鼠标停上去看),顶上从 148px 降到 104px,
  页签行和编辑器工具栏对齐成一根线;专题条去掉蓝底、改成 40px 细横条
- 工具栏按钮只剩三种分量:实心提交、浅绿运行例子、其余不带框;统计/点评/我的提交改成图标 + 字
- 题面:题号题名难度状态一行;小标题改成线条图标 + 绿字 + 细线(语法要求、提示用黄色);
  去掉空段落、老师写的大标题降成粗体;输入输出上下排;来源缩成底部一行灰字;SQL 去掉图例
- 例子改成「输入 → 输出」:「无」写成「不用输入」、行首空格画成点、长句折行缩进两格写全,
  运行例子后序号上标 ✓ / ✗

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-04 21:52:58 +08:00
1 parent 388f864aec
commit 472ec69328
10 files changed
+967 -450

No files matched your search

+232 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { storeToRefs } from 'pinia'
import type { RouteLocationRaw } from 'vue-router'
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from 'oj/api'
@@ -33,15 +34,16 @@ const ctx = useProblemPageContext()
const { isMobile } = useBreakpoints()
const theme = useThemeVars()
const isDark = useDark()
/** 做完的实心绿圈里的勾:暗色下主题绿很亮,白勾看不清 */
const doneDotText = computed(() => (isDark.value ? 'rgba(0, 0, 0, 0.85)' : '#fff'))
/**
* 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、专题浅蓝、比赛浅黄。
* 课堂、比赛在电脑上是左边的竖栏(课堂浅绿底、比赛浅黄底),专题是一条不带底色的细横条;
* 手机上三种都是横条(课堂浅绿、比赛浅黄)。
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
*/
const tone = computed(() => ({
lessonText: isDark.value ? theme.value.primaryColor : theme.value.primaryColorPressed,
setText: isDark.value ? '#8fb4ee' : '#1d4f9c',
setBar: isDark.value ? '#6f9be0' : '#2f6bc4',
contestText: isDark.value ? '#f2c97d' : '#8a5200',
}))
const userStore = useUserStore()
@@ -241,8 +243,35 @@ onSolvedHere(() => {
</script>
<template>
<!-- 课堂 -->
<div v-if="lesson" class="context-bar lesson">
<!--
课堂(电脑):左栏最左边一条竖栏,只写题号,题名鼠标停上去看(学生认题靠题号)。
不占高度:机房 1366×768 那种屏,顶上每省一行,题面和编辑器就多一行(设计稿「题目页观感」合一版)
-->
<nav v-if="lesson && !isMobile" class="lesson-rail" aria-label="这节课的题">
<div class="rail-head">
<span>这节课</span>
<b class="rail-done">{{ lesson.done }}/{{ lesson.total }}</b>
<span v-if="lessonStore.languageText" class="rail-lang">{{ lessonStore.languageText }}</span>
</div>
<router-link
v-for="(chip, index) in lesson.chips"
:key="chip.displayId"
:to="chip.to"
class="rail-item"
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
:title="chip.note ? `${chip.title}(${chip.note})` : chip.title"
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
>
<span class="rail-dot">
<Icon v-if="chip.status === 'done'" icon="ph:check-bold" :width="14" />
<template v-else>{{ index + 1 }}</template>
</span>
<span class="rail-id">{{ chip.displayId }}</span>
</router-link>
</nav>
<!-- 课堂(手机):一条横的 -->
<div v-else-if="lesson" class="context-bar lesson">
<span class="lead lesson-lead"
>这节课<template v-if="lessonStore.languageText"> · {{ lessonStore.languageText }}</template> 做完 {{ lesson.done }}/{{ lesson.total }}</span
>
@@ -356,7 +385,37 @@ onSolvedHere(() => {
</component>
</div>
<!-- 比赛 -->
<!--
比赛(电脑):和课堂一样是左边一条竖栏 —— 比赛一般 3 到 10 道题,题号一列摆得下。
顶上「‹ 比赛」回比赛页(比赛名鼠标停上去看)、还剩多久;底下「排名」(期中期末进行中不给)
-->
<nav v-else-if="contestBar && !isMobile" class="lesson-rail contest-rail" aria-label="比赛的题">
<router-link :to="contestBar.home" class="rail-back" :title="`回到比赛:${contestBar.title}`">
<Icon icon="ph:caret-left-bold" :width="12" aria-hidden="true" />比赛
</router-link>
<div class="rail-head">
<template v-if="contestBar.remaining">
<span>还剩</span>
<b class="rail-time">{{ contestBar.remaining }}</b>
</template>
<span v-else>{{ contestBar.notStarted ? '还没开始' : '已结束' }}</span>
</div>
<router-link
v-for="chip in contestBar.chips"
:key="chip.displayId"
:to="chip.to"
class="rail-item"
:class="[chip.status, { current: chip.displayId.toLowerCase() === currentId.toLowerCase() }]"
:title="chip.title"
:aria-current="chip.displayId.toLowerCase() === currentId.toLowerCase() ? 'page' : undefined"
>
<!-- 比赛题号本来就短(1、2、A),直接写在圈里;做对了圈涂绿、号还在,认得出是哪道 -->
<span class="rail-dot">{{ chip.displayId }}</span>
</router-link>
<router-link v-if="contestBar.rank" :to="contestBar.rank" class="rail-rank"> <Icon icon="ph:ranking" :width="18" aria-hidden="true" />排名 </router-link>
</nav>
<!-- 比赛(手机) -->
<div v-else-if="contestBar" class="context-bar contest">
<router-link :to="contestBar.home" class="back contest-back" :title="`回到比赛:${contestBar.title}`"> ‹ {{ contestBar.title }} </router-link>
<ProblemChips :items="contestBar.chips" :current="currentId" variant="contest" />
@@ -371,6 +430,165 @@ onSolvedHere(() => {
</template>
<style scoped>
/* 比赛竖栏:浅黄底,和课堂分得开 */
.contest-rail {
background-color: rgba(208, 138, 28, 0.07);
border-right-color: rgba(208, 138, 28, 0.2);
}
.contest-rail .rail-item:hover {
background-color: rgba(208, 138, 28, 0.1);
}
.contest-rail .rail-dot {
font-size: 12px;
overflow: hidden;
}
.rail-back {
display: flex;
align-items: center;
justify-content: center;
gap: 1px;
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: v-bind('tone.contestText');
text-decoration: none;
}
.rail-back:hover {
text-decoration: underline;
}
.rail-time {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: v-bind('tone.contestText');
}
.rail-rank {
margin-top: auto;
padding: 8px 0 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 12px;
color: v-bind('theme.textColor2');
text-decoration: none;
}
.rail-rank:hover {
color: v-bind('tone.contestText');
}
/* ==================== 课堂竖栏(电脑) ==================== */
.lesson-rail {
width: 56px;
flex: none;
box-sizing: border-box;
padding: 8px 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
overflow-y: auto;
scrollbar-width: none;
background-color: rgba(24, 160, 88, 0.06);
border-right: 1px solid rgba(24, 160, 88, 0.14);
}
.rail-head {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
line-height: 1.35;
color: v-bind('theme.textColor3');
}
.rail-done {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: v-bind('tone.lessonText');
}
.rail-lang {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 7px 0;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: v-bind('theme.textColor2');
text-decoration: none;
}
.rail-item:hover {
background-color: rgba(24, 160, 88, 0.08);
}
.rail-item.current {
font-weight: 700;
color: v-bind('tone.lessonText');
background-color: v-bind('theme.bodyColor');
box-shadow: inset 3px 0 v-bind('theme.primaryColor');
}
.rail-id {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-dot {
width: 28px;
height: 28px;
box-sizing: border-box;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
border: 1.5px solid rgba(128, 128, 128, 0.4);
background-color: v-bind('theme.bodyColor');
color: v-bind('theme.textColor2');
}
.done .rail-dot {
border-color: v-bind('theme.successColor');
background-color: v-bind('theme.successColor');
color: v-bind('doneDotText');
}
/* 做过没对:红圈,和题目列表的红「−」一个意思 */
.tried .rail-dot {
border-color: v-bind('theme.errorColor');
color: v-bind('theme.errorColor');
}
.current .rail-dot {
border-width: 2px;
border-color: v-bind('theme.primaryColor');
color: v-bind('tone.lessonText');
}
.current.done .rail-dot {
color: v-bind('doneDotText');
}
.context-bar {
height: 44px;
flex: none;
@@ -390,10 +608,12 @@ onSolvedHere(() => {
border-bottom-color: rgba(24, 160, 88, 0.18);
}
/* 专题:不带底色的一条细横条(设计稿「题目页观感」合一版)。专题可以有几十道题、还有「加入专题」,
竖栏放不下,留在页签行上面 */
.problemset {
height: 40px;
gap: 10px;
background-color: rgba(47, 107, 196, 0.06);
border-bottom-color: rgba(47, 107, 196, 0.16);
border-bottom-color: v-bind('theme.dividerColor');
}
.contest {
@@ -437,7 +657,7 @@ onSolvedHere(() => {
}
.set-back {
color: v-bind('tone.setText');
color: v-bind('theme.textColor1');
}
/* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
@@ -452,14 +672,14 @@ onSolvedHere(() => {
width: 110px;
height: 6px;
border-radius: 3px;
background-color: rgba(47, 107, 196, 0.16);
background-color: rgba(128, 128, 128, 0.16);
overflow: hidden;
}
.set-progress-fill {
height: 100%;
border-radius: 3px;
background-color: v-bind('tone.setBar');
background-color: v-bind('theme.primaryColor');
}
.countdown {
@@ -503,7 +723,7 @@ onSolvedHere(() => {
}
.problemset .step.forward {
color: v-bind('tone.setText');
color: v-bind('theme.textColor1');
}
.step:not(.disabled):hover {
@@ -182,7 +182,9 @@ const changeLanguage = (v: LANGUAGE) => {
<n-select
v-if="!drawing"
v-model:value="codeStore.code.language"
class="language"
style="width: 120px"
:bordered="false"
:size="buttonSize"
:options="languageOptions"
:disabled="showCollabBar"
@@ -190,15 +192,19 @@ const changeLanguage = (v: LANGUAGE) => {
/>
<!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 -->
<span v-if="!drawing" class="divider" aria-hidden="true" />
<!-- 浅绿底:比实心的「提交」轻一档,又比旁边不带框的按钮重(设计稿「题目页观感」合一版) -->
<n-button
v-if="canRunSamples && isDesktop"
:size="buttonSize"
type="primary"
ghost
secondary
:loading="samplesRunning"
:disabled="!codeStore.code.value.trim()"
@click="submissionStore.runSamples()"
>
<template #icon><Icon icon="ph:play-fill" /></template>
运行例子
</n-button>
@@ -215,22 +221,26 @@ const changeLanguage = (v: LANGUAGE) => {
:size="buttonSize"
:type="helpButtonType"
:secondary="teacherIn || collabStore.helpStatus !== 'idle'"
:quaternary="!teacherIn && collabStore.helpStatus === 'idle'"
:title="helpButtonTitle"
class="help"
:class="{ busy: teacherIn }"
@click="toggleHelp"
>
<span v-if="teacherIn" class="dot" aria-hidden="true" />
<Icon v-else-if="collabStore.helpStatus === 'idle'" icon="ph:hand" :width="17" class="help-icon" aria-hidden="true" />
{{ helpButtonText }}
</n-button>
<n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" @click="openStatistics(problem!._id)">
<n-button v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove" :size="buttonSize" quaternary @click="openStatistics(problem!._id)">
课堂统计
</n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;放不下时再加上课堂统计 -->
<n-dropdown v-if="isDesktop && menuOptions.length" trigger="click" :options="menuOptions" @select="handleMenuSelect">
<n-button :size="buttonSize" class="more" aria-label="更多:去自测猫、复制代码、重置代码……"> ⋯ </n-button>
<n-button :size="buttonSize" quaternary class="more" aria-label="更多:去自测猫、复制代码、重置代码……">
<Icon icon="ph:dots-three-bold" :width="18" />
</n-button>
</n-dropdown>
</div>
</template>
@@ -288,6 +298,23 @@ const changeLanguage = (v: LANGUAGE) => {
padding: 0;
}
/* 语言选择不带框,字加粗:它不是一个要常去点的按钮 */
.language :deep(.n-base-selection-label) {
font-weight: 600;
}
.divider {
flex: none;
width: 1px;
height: 20px;
margin: 0 2px;
background-color: v-bind('theme.dividerColor');
}
.help-icon {
margin-right: 4px;
}
.help.busy {
cursor: default;
}
@@ -1,14 +1,18 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { isSqlProblem, parseFlowDoc } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { storeToRefs } from 'pinia'
import { useProblemStore } from 'oj/store/problem'
import { DIFFICULTY } from 'utils/constants'
import { copyToClipboard, getTagColor } from 'utils/functions'
import { getTagColor } from 'utils/functions'
import { useSubmissionStore } from 'oj/store/submission'
import Markdown from 'shared/components/Markdown.vue'
import SqlProblemData from './SqlProblemData.vue'
import SimilarProblems from './SimilarProblems.vue'
import SampleList from './SampleList.vue'
import SectionHead from './SectionHead.vue'
import { tidyStatement } from '../utils/statement'
import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart'
import { useMyFlowchartStore } from 'shared/store/myFlowchart'
import { useUserStore } from 'shared/store/user'
@@ -83,21 +87,6 @@ const flowchartGrade = computed(() => {
})
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
// 例子旁的「复制」:点完变成「已复制」一秒
const message = useMessage()
const copiedKey = ref('')
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, {
immediate: false,
})
async function copySample(key: string, text: string) {
if (await copyToClipboard(text)) {
copiedKey.value = key
resetCopied()
} else {
message.error('复制失败')
}
}
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
watch(
@@ -108,141 +97,96 @@ watch(
{ immediate: true },
)
// 例子只是摆出来。原来每个例子旁有个「测试」按钮(结果只给通过 / 不通过、2 秒后复位),
// 现在试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
// 例子只是摆出来(SampleList)。试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
const samples = computed(() => problem.value?.samples ?? [])
const submissionStore = useSubmissionStore()
const SAMPLE_SIDES = ['input', 'output'] as const
const SAMPLE_SIDE_LABEL = { input: '输入', output: '输出' } as const
/*
* 例子默认摆成「例子 N | 输入 | 输出」的表,可机房那两种屏上左栏只有 550px 左右,每格只放得下
* 二十来个字符,而例子输出最长那行的 p90 就有 28 个字符宽(中文按两个算)—— 一成的题会在
* 半格里横着滚。所以按这道题最长那行量一下:半格放得下就摆表,放不下这道题的例子就整体改成
* 上下排(输入一行、输出一行,各占满宽)。整宽还放不下的二十来道才横着滚。不自动折行:折出来
* 的行学生分不清是不是真换了行,照着它输出就错了。
* 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数。
*/
const SAMPLE_FONT = '14px Consolas, Monaco, monospace'
const SAMPLE_NO_W = 56
const SAMPLE_GAP = 8
const SAMPLE_PAD_L = 10
const SAMPLE_PAD_R = 4
const SAMPLE_COPY_W = 44
const sampleCss = {
font: SAMPLE_FONT,
noW: `${SAMPLE_NO_W}px`,
gap: `${SAMPLE_GAP}px`,
pad: `2px ${SAMPLE_PAD_R}px 2px ${SAMPLE_PAD_L}px`,
copyW: `${SAMPLE_COPY_W}px`,
}
let measureCtx: CanvasRenderingContext2D | null = null
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
const sampleTextWidth = computed(() => {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return 0
measureCtx.font = SAMPLE_FONT
let widest = 0
for (const sample of samples.value) {
for (const side of SAMPLE_SIDES) {
for (const line of sample[side].split('\n')) {
// <pre> 里 tab 按 8 列排
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
if (width > widest) widest = width
}
}
}
return widest
})
const sampleTable = useTemplateRef<HTMLElement>('sampleTable')
const { width: sampleTableWidth } = useElementSize(sampleTable)
/**
* 窄(手机,或桌面左栏拖到最窄):「例子 N」不占一列,改成每个例子头上一行小字。
* 390 宽的手机上那一列连同间距要吃掉 64px,框只剩一半多
*/
const sampleNarrow = computed(() => sampleTableWidth.value > 0 && sampleTableWidth.value < 480)
/** 摆成表时,半格里留给文字的宽度 */
const sampleHalfWidth = computed(
() => (sampleTableWidth.value - (sampleNarrow.value ? 0 : SAMPLE_NO_W + SAMPLE_GAP) - SAMPLE_GAP) / 2 - SAMPLE_PAD_L - SAMPLE_PAD_R - SAMPLE_COPY_W,
)
const samplesStacked = computed(() => sampleTableWidth.value > 0 && sampleTextWidth.value > sampleHalfWidth.value)
/** 描述、输入、输出、提示:去掉老师为了隔开敲的空段落(见 utils/statement) */
const statement = computed(() => ({
description: tidyStatement(problem.value?.description),
input: tidyStatement(problem.value?.inputDescription),
output: tidyStatement(problem.value?.outputDescription),
hint: tidyStatement(problem.value?.hint),
}))
// 文案和配色分类都由后端生成 —— 原来这里有一份 NODE_TARGET_LABELS +
// ruleDescription + ruleTagType,和判题机那份几乎一模一样,见契约
// astRequirementSchema。规则原文(engine / target)不下发给学生。
const KIND_TAG_TYPE = {
require: 'success',
forbid: 'error',
count: 'info',
// 每条规则前面一个小图标说是哪一类,规则的字照后端给的原样写(里面已经有「必须 / 不能」)
const KIND_ICON = {
require: 'ph:check-circle-fill',
forbid: 'ph:prohibit-fill',
count: 'ph:hash-bold',
} as const
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
</script>
<template>
<div v-if="problem" class="problem-content">
<!-- 题号、题名、难度和状态一行(设计稿「题目页观感」定稿)。题名太长折到下一行,标签留在右边 -->
<header class="problem-head">
<div class="title-row">
<span class="pid">{{ problem._id }}</span>
<h1 class="problem-title">{{ problem.title }}</h1>
</div>
<div class="meta">
<n-tag v-if="problem.difficulty" size="small" :bordered="false" :type="getTagColor(problem.difficulty)">
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false" class="solved"> 已解决 </n-tag>
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
「还没交过」,旁边的「流程图 C 级」已经说了交过什么 -->
「还没交过」,旁边的「流程图 C 级」已经说了交过什么。
「做过,还没对」用红色:橙色和「中等」撞色,两个标签挨着分不出来 -->
<template v-else-if="!flowchartPassed">
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag>
<n-tag v-if="myStatus === 'tried'" size="small" type="error" :bordered="false"> 做过,还没对 </n-tag>
<n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
</template>
<n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
</div>
</header>
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 -->
<div v-if="astRequirements.length > 0" class="requirements">
<span class="requirements-head">语法要求</span>
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了。
黄色,和提示同色;每条一行,前面的小图标说是「必须 / 不能 / 次数」哪一类 -->
<section v-if="astRequirements.length > 0" class="block">
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<template v-for="[lang, rules] in astRequirements" :key="lang">
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span>
<n-tag v-for="(rule, i) in rules" :key="`${lang}-${i}`" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false">
{{ rule.description }}
</n-tag>
<div v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</div>
<ul class="rules">
<li v-for="(rule, i) in rules" :key="`${lang}-${i}`" :class="rule.kind">
<Icon :icon="KIND_ICON[rule.kind]" :width="16" class="rule-icon" aria-hidden="true" />
<span>{{ rule.description }}</span>
</li>
</ul>
</template>
<span class="requirements-note">提交时会逐条检查</span>
</div>
<section class="block">
<h3 class="title">描述</h3>
<Markdown html :content="problem.description" />
</section>
<!-- 输入、输出一般都只有一两句,左右摆开省半屏 -->
<div v-if="!isSQL" class="io">
<section class="block">
<h3 class="title">输入</h3>
<Markdown html :content="problem.inputDescription" />
<SectionHead icon="description">描述</SectionHead>
<Markdown html :content="statement.description" />
</section>
<!-- 输入、输出上下排:并排的时候多一两句就挤成窄条(2020 的输入折成三行),旁边一栏空着 -->
<template v-if="!isSQL">
<section class="block">
<SectionHead icon="input">输入</SectionHead>
<Markdown html :content="statement.input" />
</section>
<section class="block">
<h3 class="title">输出</h3>
<Markdown html :content="problem.outputDescription" />
<SectionHead icon="output">输出</SectionHead>
<Markdown html :content="statement.output" />
</section>
</div>
</template>
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
<section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block">
<div class="block-head">
<h3 class="title">你画的流程图</h3>
<n-button text type="primary" size="small" @click="myFlowchartZoom = true"> 放大看 </n-button>
<n-button text type="primary" size="small" @click="myFlowchartOpen = !myFlowchartOpen">
<SectionHead icon="flowchart">
你画的流程图
<template #extra>
<n-button text type="primary" @click="myFlowchartZoom = true">放大看 ›</n-button>
<n-button text type="primary" @click="myFlowchartOpen = !myFlowchartOpen">
{{ myFlowchartOpen ? '收起' : '展开' }}
</n-button>
</div>
</template>
</SectionHead>
<div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true">
<MyFlowchart compact />
</div>
@@ -251,49 +195,19 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal>
</section>
<template v-if="!isSQL">
<!--
例子是一张表:例子 N | 输入 | 输出。原来每个例子各占一个小标题加两个框,三个例子
就占掉大半屏;Python 的例子大多一两行,摆成表一眼能对上「这个输入 → 这个输出」
-->
<section v-if="samples.length" class="block">
<div class="block-head">
<h3 class="title">例子</h3>
<n-button text type="primary" size="small" class="block-link" @click="submissionStore.revealResult('custom')"> 自己输入数据试试 › </n-button>
</div>
<!-- 半格放不下最长那行就整体改成上下排,见 samplesStacked -->
<div ref="sampleTable" class="sample-table" :class="{ stacked: samplesStacked, narrow: sampleNarrow }" role="table" aria-label="例子">
<div v-if="!samplesStacked" class="sample-row" role="row">
<span v-if="!sampleNarrow" role="columnheader"></span>
<span role="columnheader" class="sample-label">输入</span>
<span role="columnheader" class="sample-label">输出</span>
</div>
<div v-for="(sample, index) of samples" :key="index" class="sample-row" role="row">
<span class="sample-no" role="rowheader">例子 {{ index + 1 }}</span>
<template v-for="side in SAMPLE_SIDES" :key="side">
<span v-if="samplesStacked" class="sample-label sample-side">
{{ SAMPLE_SIDE_LABEL[side] }}
</span>
<div class="sample-cell" role="cell">
<pre class="testcase">{{ sample[side] }}</pre>
<button
type="button"
class="sample-copy"
:aria-label="`复制例子 ${index + 1} 的${SAMPLE_SIDE_LABEL[side]}`"
@click="copySample(`${index}-${side}`, sample[side])"
>
{{ copiedKey === `${index}-${side}` ? '已复制' : '复制' }}
</button>
</div>
<section v-if="!isSQL && samples.length" class="block">
<SectionHead icon="samples">
例子
<template #extra>
<n-button text type="primary" @click="submissionStore.revealResult('custom')">自己输入数据试试 ›</n-button>
</template>
</div>
</div>
</SectionHead>
<SampleList :samples="samples" />
</section>
</template>
<SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive">
<template #title="{ text }">
<h3 class="title sql-title">{{ text }}</h3>
<SectionHead :icon="text === '数据表' ? 'table' : 'expected'" class="sql-head">{{ text }}</SectionHead>
</template>
</SqlProblemData>
@@ -302,10 +216,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏
-->
<section v-if="canShowReferenceFlowchart" class="block">
<div class="block-head">
<h3 class="title">参考流程图</h3>
<n-button text type="primary" size="small" class="block-link" @click="referenceZoom = true"> 放大看 </n-button>
</div>
<SectionHead icon="flowchart">
参考流程图
<template #extra>
<n-button text type="primary" @click="referenceZoom = true">放大看 ›</n-button>
</template>
</SectionHead>
<div
class="reference-flowchart"
role="button"
@@ -323,268 +239,158 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-modal>
</section>
<section v-if="problem.hint" class="block">
<h3 class="title">提示</h3>
<Markdown html :content="problem.hint" />
</section>
<section v-if="problem.source" class="block">
<h3 class="title">来源</h3>
<Markdown html :content="problem.source" />
<section v-if="statement.hint" class="block">
<SectionHead icon="hint" tone="amber">提示</SectionHead>
<Markdown html :content="statement.hint" />
</section>
<SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
<!-- 来源不单起一节:学生做题用不上它,缩成最底下一行灰字 -->
<footer v-if="problem.source" class="source">
<span class="source-label">来源</span>
<Markdown html :content="problem.source" class="source-text" />
</footer>
</div>
</template>
<style scoped>
/* 设计稿「题目页签:还没运行过」 */
.problem-head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.title-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.pid {
flex: none;
padding: 2px 8px;
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.12);
font-size: 13px;
color: v-bind('theme.textColor2');
}
.problem-title {
margin: 0;
font-size: 22px;
line-height: 1.3;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
}
.block {
margin-top: 18px;
}
.block-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.block-head .title {
margin: 0;
}
.problem-content {
container: problem-content / inline-size;
}
.block-link {
margin-left: auto;
}
/*
* 小节标题(描述 / 输入 / 输出 / 例子 / 提示……)。画板上是 15px 半粗,比 14px 的正文只大一号,
* 一屏题面扫下来分不出哪是标题(用户 2026-09-29 提出):加粗、大一号,左边一道主色竖条
*/
.title {
font-size: 16px;
font-weight: 700;
line-height: 1.4;
margin: 0 0 6px;
padding-left: 9px;
border-left: 3px solid v-bind('theme.primaryColor');
}
.io {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
/* 窄的时候输入、输出上下排:并排的话长一点的说明挤成一字两行的窄条,旁边一栏空着 */
@container problem-content (max-width: 480px) {
.io {
grid-template-columns: minmax(0, 1fr);
}
}
/* 语法要求:一行的浅绿框,硬性的判定条件,放在最前面 */
.requirements {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid rgba(24, 160, 88, 0.3);
background-color: rgba(24, 160, 88, 0.06);
/* 题号 | 题名 | ……… | 难度 状态 */
.problem-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
font-size: 13px;
align-items: baseline;
gap: 4px 10px;
}
.requirements-head {
font-weight: 600;
}
.requirements-note {
.pid {
flex: none;
font-size: 16px;
font-variant-numeric: tabular-nums;
color: v-bind('theme.textColor3');
}
.lang-label {
.problem-title {
margin: 0;
min-width: 0;
font-size: 22px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
align-self: center;
}
/* 「已解决」实心绿:做完了要一眼看得出 */
.meta .solved {
background-color: v-bind('theme.successColor');
color: #fff;
font-weight: 600;
}
/* 参考流程图:和例子的格子同一种浅灰底 */
.block {
margin-top: 20px;
}
/* 题面里老师自己写的标题(2020 开头的「鸣潮公式」是 h1):降成和正文一样大的粗体,
不然比题名还大,顶在「描述」下面像另起了一道题 */
.problem-content :deep(.oj-markdown :is(h1, h2, h3, h4, h5, h6)) {
margin: 0.8em 0 0.3em;
font-size: 16px;
font-weight: 700;
line-height: 1.5;
}
/* 语法要求:每条一行 */
.lang-label {
margin: 6px 0 2px;
font-size: 14px;
font-weight: 600;
}
.rules {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 16px;
line-height: 1.6;
}
.rules li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.rule-icon {
flex: none;
margin-top: 0.3em;
}
.rules .require .rule-icon {
color: v-bind('theme.successColor');
}
.rules .forbid .rule-icon {
color: v-bind('theme.errorColor');
}
.rules .count .rule-icon {
color: v-bind('theme.infoColor');
}
/* SQL 的「数据表 / 期望结果」和别的小节一样隔开 */
.sql-head {
margin-top: 20px;
}
/* 参考流程图:和例子的输入格同一种浅灰底 */
.reference-flowchart {
cursor: zoom-in;
border-radius: 6px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05);
padding: 16px 12px;
}
.my-flowchart {
cursor: zoom-in;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05);
padding: 8px;
}
/*
* 例子:例子 N | 输入 | 输出,每格右边一个「复制」。
* 放不下时(.stacked)每个例子两行:例子 N | 输入 | 那格,下一行 输出 | 那格
*/
.sample-table {
.source {
margin-top: 24px;
padding-top: 10px;
border-top: 1px solid v-bind('theme.dividerColor');
display: flex;
flex-direction: column;
gap: 6px;
}
.sample-table.stacked {
gap: 12px;
}
.sample-row {
display: grid;
grid-template-columns: v-bind('sampleCss.noW') minmax(0, 1fr) minmax(0, 1fr);
gap: 6px v-bind('sampleCss.gap');
align-items: stretch;
}
.stacked .sample-row {
grid-template-columns: v-bind('sampleCss.noW') 32px minmax(0, 1fr);
}
.sample-label,
.sample-no {
font-size: 12px;
align-items: baseline;
gap: 8px;
font-size: 13px;
color: v-bind('theme.textColor3');
}
.sample-no {
display: flex;
align-items: center;
}
/* 上下排时「例子 N」竖跨输入、输出两行,和「输入」对齐在顶上 */
.stacked .sample-no {
grid-row: span 2;
align-items: flex-start;
}
.stacked .sample-no,
.sample-side {
padding-top: 8px;
line-height: 1.5;
}
/* 窄:「例子 N」占满一行当小标题,下面输入、输出(并排或上下排) */
.narrow .sample-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.narrow.stacked .sample-row {
grid-template-columns: 32px minmax(0, 1fr);
}
.narrow .sample-no,
.narrow.stacked .sample-no {
grid-column: 1 / -1;
grid-row: auto;
padding-top: 4px;
}
.sample-table.narrow {
gap: 10px;
}
.sample-cell {
min-width: 0;
display: flex;
align-items: flex-start;
padding: v-bind('sampleCss.pad');
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
}
.testcase {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 4px 0;
font: v-bind('sampleCss.font');
line-height: 1.5;
white-space: pre;
overflow-x: auto;
}
/* 整宽也放不下、只好横着滚的那二十来道:Windows 上默认那根带箭头的粗滚动条比例子本身还显眼 */
.testcase::-webkit-scrollbar {
height: 6px;
}
.testcase::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.35);
}
.testcase::-webkit-scrollbar-track {
background: transparent;
}
.sample-copy {
.source-label {
flex: none;
width: v-bind('sampleCss.copyW');
height: 24px;
margin-top: 2px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind('theme.primaryColorPressed');
cursor: pointer;
}
.sample-copy:hover {
background-color: rgba(24, 160, 88, 0.1);
}
.sql-title {
margin-top: 14px;
.source-text {
min-width: 0;
font-size: 13px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { copyToClipboard } from 'utils/functions'
import { SubmissionStatus } from 'utils/constants'
import { useCodeStore } from 'oj/store/code'
import { useSubmissionStore } from 'oj/store/submission'
/**
* 题面的例子:每组一行「输入 → 输出」,输出浅绿底(设计稿「题目页观感」ExCard,2026-11-04 定)。
*
* - 输入写的是「无」或者空的(常用题里 12% 的例子)写灰字「不用输入」,不给复制:
* 原来摆成一格可复制的「无」,学生以为要输入一个「无」。
* - 行首的空格画成浅灰点:菱形、三角形那几道靠行首空格对齐,数不清空格就照着排错了。
* 只是画成点,复制出去的还是空格。
* - 半格放不下最长那行,这道题的例子整体改成上下排(输入、↓、输出)。整宽还放不下的那一行
* 才折行,**折下来的部分缩进两格**:原来不折、横着滚,就是怕学生分不清折行和真换行、
* 照着多输出一个换行;缩进之后两者一眼能分开,又不用去找那根细滚动条。
* - 「运行例子」跑过之后,序号上标 ✓ / ✗(还是会跳到「结果」页签,切回来就看得到)。
* 代码改过就不再标:标的是上一份代码的对错,留着会让学生以为改完的还对。
*/
const { samples } = defineProps<{
samples: { input: string; output: string }[]
}>()
const SIDES = ['input', 'output'] as const
const SIDE_LABEL = { input: '输入', output: '输出' } as const
/** 输入写「无」或者空着:这道题不用输入 */
function noInput(text: string) {
const trimmed = text.trim()
return trimmed === '' || trimmed === '无'
}
/** 每一行拆成「行首空格数 + 余下的字」,模板里把行首空格画成点 */
function linesOf(text: string) {
return text.split('\n').map((line) => {
const lead = /^ +/.exec(line)?.[0].length ?? 0
return { lead, rest: line.slice(lead) }
})
}
// ==================== 摆成一行还是上下排 ====================
// 下面几个尺寸 CSS 里也用(v-bind),量宽度和排版是同一套数
const FONT_SIZE = 16
const FONT = `${FONT_SIZE}px Consolas, Monaco, monospace`
const DOT_W = 26
const ARROW_W = 22
const GAP = 6
/** 格子里除了字以外占掉的:左右内边距、复制按钮、按钮前的间距、边框 */
const PANE_CHROME = 10 + 4 + 26 + 4 + 2
let measureCtx: CanvasRenderingContext2D | null = null
/** 例子里最长那一行排出来有多宽(px)。等宽字体里中文、表情照样比英文宽,所以按字体真量 */
const widest = computed(() => {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return 0
measureCtx.font = FONT
let max = 0
for (const sample of samples) {
for (const side of SIDES) {
if (side === 'input' && noInput(sample.input)) continue
for (const line of sample[side].split('\n')) {
const width = measureCtx.measureText(line.replace(/\t/g, ' ')).width
if (width > max) max = width
}
}
}
return max
})
const list = useTemplateRef<HTMLElement>('list')
const { width: listWidth } = useElementSize(list)
const halfText = computed(() => (listWidth.value - DOT_W - ARROW_W - GAP * 3) / 2 - PANE_CHROME)
const stacked = computed(() => listWidth.value > 0 && widest.value > halfText.value)
// ==================== 复制 ====================
const message = useMessage()
const copiedKey = ref('')
const { start: resetCopied } = useTimeoutFn(() => (copiedKey.value = ''), 1000, { immediate: false })
async function copy(key: string, text: string) {
if (await copyToClipboard(text)) {
copiedKey.value = key
resetCopied()
} else {
message.error('复制失败')
}
}
// ==================== 运行例子的 ✓ / ✗ ====================
const submissionStore = useSubmissionStore()
const codeStore = useCodeStore()
const marks = computed<Record<number, 'ok' | 'bad'>>(() => {
const trial = submissionStore.trial
const ran = trial.samplesCode.value
if (trial.samplesRunning.value || !ran) return {}
if (ran.value !== codeStore.code.value || ran.language !== codeStore.code.language) return {}
const out: Record<number, 'ok' | 'bad'> = {}
for (const run of trial.sampleRuns.value) {
out[run.index] = run.result === SubmissionStatus.accepted ? 'ok' : 'bad'
}
return out
})
function markLabel(index: number) {
const mark = marks.value[index]
if (mark === 'ok') return `例子 ${index + 1},运行结果对了`
if (mark === 'bad') return `例子 ${index + 1},运行结果不对`
return `例子 ${index + 1}`
}
const css = {
font: FONT,
dotW: `${DOT_W}px`,
arrowW: `${ARROW_W}px`,
gap: `${GAP}px`,
}
</script>
<template>
<div ref="list" class="sample-list" :class="{ stacked }" role="list" aria-label="例子">
<div v-if="!stacked" class="cols" aria-hidden="true">
<span />
<span>输入</span>
<span />
<span>输出</span>
</div>
<div v-for="(sample, index) of samples" :key="index" class="pair" role="listitem">
<span class="dot" :class="marks[index]" :aria-label="markLabel(index)">
<Icon v-if="marks[index] === 'ok'" icon="ph:check-bold" :width="13" />
<Icon v-else-if="marks[index] === 'bad'" icon="ph:x-bold" :width="12" />
<template v-else>{{ index + 1 }}</template>
</span>
<div class="sides">
<template v-for="side in SIDES" :key="side">
<span v-if="side === 'output'" class="arrow" aria-hidden="true">
<Icon :icon="stacked ? 'ph:arrow-down' : 'ph:arrow-right'" :width="16" />
</span>
<div v-if="side === 'input' && noInput(sample.input)" class="pane in none">不用输入</div>
<div v-else class="pane" :class="side === 'input' ? 'in' : 'out'">
<pre class="text" :aria-label="`例子 ${index + 1} 的${SIDE_LABEL[side]}`"><span
v-for="(line, i) of linesOf(sample[side])"
:key="i"
class="line"
><span v-if="line.lead" class="lead">{{ '·'.repeat(line.lead) }}</span>{{ line.rest }}</span
></pre>
<button type="button" class="copy" :aria-label="`复制例子 ${index + 1} 的${SIDE_LABEL[side]}`" @click="copy(`${index}-${side}`, sample[side])">
<Icon :icon="copiedKey === `${index}-${side}` ? 'ph:check' : 'ph:copy'" :width="15" />
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.sample-list {
display: flex;
flex-direction: column;
gap: 8px;
--pane-in-bg: rgba(128, 128, 128, 0.07);
--pane-in-border: rgba(128, 128, 128, 0.14);
--pane-out-bg: rgba(24, 160, 88, 0.08);
--pane-out-border: rgba(24, 160, 88, 0.26);
--lead: rgba(24, 160, 88, 0.45);
--muted: rgba(128, 128, 128, 0.75);
}
:global(html.dark) .sample-list {
--pane-out-bg: rgba(99, 226, 183, 0.08);
--pane-out-border: rgba(99, 226, 183, 0.26);
--lead: rgba(99, 226, 183, 0.45);
}
.sample-list.stacked {
gap: 12px;
}
.cols,
.pair {
display: grid;
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr);
gap: 0 v-bind('css.gap');
}
.cols {
grid-template-columns: v-bind('css.dotW') minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
margin-bottom: -4px;
font-size: 12px;
line-height: 20px;
color: var(--muted);
}
/* 一行:输入 → 输出;上下排:输入、↓、输出 */
.sides {
display: grid;
grid-template-columns: minmax(0, 1fr) v-bind('css.arrowW') minmax(0, 1fr);
gap: 0 v-bind('css.gap');
align-items: stretch;
min-width: 0;
}
.stacked .sides {
grid-template-columns: minmax(0, 1fr);
}
.dot {
width: 22px;
height: 22px;
margin-top: 7px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
background-color: rgba(128, 128, 128, 0.14);
color: var(--muted);
}
.dot.ok {
background-color: #18a058;
color: #fff;
}
.dot.bad {
background-color: #d03050;
color: #fff;
}
.arrow {
display: flex;
justify-content: center;
padding-top: 10px;
color: var(--muted);
}
.stacked .arrow {
justify-content: flex-start;
padding: 2px 0 2px 14px;
}
.pane {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 4px;
padding: 5px 4px 5px 10px;
border-radius: 6px;
}
.pane.in {
background-color: var(--pane-in-bg);
border: 1px solid var(--pane-in-border);
}
.pane.out {
background-color: var(--pane-out-bg);
border: 1px solid var(--pane-out-border);
}
.pane.none {
align-self: start;
background-color: transparent;
border-style: dashed;
padding-right: 10px;
font-size: 14px;
line-height: 24px;
color: var(--muted);
}
.text {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font: v-bind('css.font');
line-height: 24px;
}
/* 一行一块:整宽还放不下才折,折下来的缩进两格,和真换行分得开 */
.line {
display: block;
min-height: 24px;
padding-left: 2ch;
text-indent: -2ch;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.lead {
color: var(--lead);
}
.copy {
flex: none;
width: 26px;
height: 24px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.copy:hover {
background-color: rgba(128, 128, 128, 0.14);
}
</style>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
/**
* 题面的小节标题:线条图标 + 绿字 + 一根细线拖到头,线尾可以挂一个链接
* (「自己输入数据试试 ›」「放大看 ›」)。设计稿「题目页观感」⑦,2026-11-04 定。
*
* 语法要求、提示用黄色:一个是硬性条件、一个是帮忙的话,扫一眼要能和别的节分开。
* 明暗两套色都在 CSS 里(html.dark),别在这里调 useDark() —— 理由见 ProblemTypeTag。
*/
export type SectionIcon = 'description' | 'input' | 'output' | 'samples' | 'hint' | 'rules' | 'table' | 'expected' | 'flowchart' | 'similar'
const ICON: Record<SectionIcon, string> = {
description: 'ph:book-open',
input: 'ph:sign-in',
output: 'ph:sign-out',
samples: 'ph:flask',
hint: 'ph:lightbulb',
rules: 'ph:code',
table: 'ph:table',
expected: 'ph:check-circle',
flowchart: 'ph:flow-arrow',
similar: 'ph:shuffle-angular',
}
const { icon, tone = 'green' } = defineProps<{
icon: SectionIcon
tone?: 'green' | 'amber'
}>()
</script>
<template>
<div class="section-head" :class="tone">
<h3 class="section-title">
<Icon :icon="ICON[icon]" :width="19" class="section-icon" aria-hidden="true" />
<slot />
</h3>
<span class="section-line" aria-hidden="true" />
<span v-if="$slots.extra" class="section-extra"><slot name="extra" /></span>
</div>
</template>
<style scoped>
.section-head {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
--head-text: #0c6b3b;
--head-icon: #18a058;
--head-line: rgba(24, 160, 88, 0.22);
}
.section-head.amber {
--head-text: #8a5a00;
--head-icon: #c98a00;
--head-line: rgba(201, 138, 0, 0.25);
}
:global(html.dark) .section-head {
--head-text: #63e2b7;
--head-icon: #63e2b7;
--head-line: rgba(99, 226, 183, 0.22);
}
:global(html.dark) .section-head.amber {
--head-text: #f2c97d;
--head-icon: #f2c97d;
--head-line: rgba(242, 201, 125, 0.25);
}
.section-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.5;
letter-spacing: 2px;
white-space: nowrap;
color: var(--head-text);
}
.section-icon {
flex: none;
color: var(--head-icon);
}
.section-line {
flex: 1 1 auto;
min-width: 12px;
height: 1px;
background-color: var(--head-line);
}
.section-extra {
flex: none;
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 14px;
white-space: nowrap;
}
</style>
@@ -19,11 +19,12 @@ import { useTone } from 'shared/composables/tone'
import { problemTypes } from '../utils/problemType'
import ProblemStatus from './ProblemStatus.vue'
import ProblemTypeTag from './ProblemTypeTag.vue'
import SectionHead from './SectionHead.vue'
/**
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
* `place` 只管标题的样子:题面里和「提示」「来源」一样带绿竖条,结果页签里和「还能挑战」对齐。
* `place` 只管标题的样子:题面里用题面的小节标题(SectionHead),结果页签里和「还能挑战」对齐。
*
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)。整行可点,题目名
@@ -67,7 +68,8 @@ function difficultyStyle(row: SimilarRow) {
<template>
<section v-if="list.length" class="similar" :class="props.place">
<h3 class="heading">{{ props.title }}</h3>
<SectionHead v-if="props.place === 'content'" icon="similar">{{ props.title }}</SectionHead>
<h3 v-else class="heading">{{ props.title }}</h3>
<div class="rows">
<router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }">
<span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
@@ -99,15 +101,6 @@ function difficultyStyle(row: SimilarRow) {
margin: 0 0 8px;
}
/* 和 ProblemContent 的小节标题(提示 / 来源)一样 */
.content .heading {
font-size: 16px;
font-weight: 700;
line-height: 1.4;
padding-left: 9px;
border-left: 3px solid v-bind('theme.primaryColor');
}
/* 和结果页签里的「还能挑战」一样 */
.result .heading {
font-size: 15px;
@@ -33,8 +33,6 @@ const changedTables = computed(() => {
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
const noChange = computed(() => 'changed_tables' in props.display.expected && !props.display.expected.changed_tables.length)
const anyChanged = computed(() => changedTables.value.some(({ changes }) => changes && (changes.cells.size > 0 || changes.newRows.size > 0)))
</script>
<template>
@@ -62,7 +60,6 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
</template>
</div>
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
<p v-if="anyChanged" class="sqlNote legend"><span class="swatch" aria-hidden="true"></span>执行后变了的格子</p>
</template>
<style scoped>
@@ -77,22 +74,4 @@ const anyChanged = computed(() => changedTables.value.some(({ changes }) => chan
opacity: 0.65;
margin: 0 0 8px;
}
.legend {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 12px;
height: 12px;
border: 1px solid #f0d48a;
background-color: #fff4d6;
}
html.dark .swatch {
border-color: rgba(240, 180, 40, 0.45);
background-color: rgba(240, 180, 40, 0.18);
}
</style>
+48 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import { errorCode } from 'utils/api'
import { getProblem } from 'oj/api'
@@ -121,6 +122,13 @@ watch(
)
const drawerLabel = (key: DrawerKey) => (key === 'submission' && teacherCollab.value ? '学生的提交' : DRAWER_TITLE[key])
/** 抽屉按钮前的小图标:页签行右边三个不带框的按钮,和左边的页签分得开(原来是三个圆角胶囊,看着像另一组页签) */
const DRAWER_ICON: Record<DrawerKey, string> = {
info: 'ph:chart-bar',
reaction: 'ph:chat-circle',
submission: 'ph:clock-counter-clockwise',
}
function toggleDrawer(key: DrawerKey) {
drawer.value = drawer.value === key ? null : key
}
@@ -314,7 +322,7 @@ onBeforeUnmount(() => {
</div>
<div v-if="ctx.drawers.length" class="drawer-buttons">
<button v-for="key in ctx.drawers" :key="key" type="button" class="drawer-button" @click="toggleDrawer(key)">
{{ drawerLabel(key) }}
<Icon :icon="DRAWER_ICON[key]" :width="16" aria-hidden="true" />{{ drawerLabel(key) }}
</button>
</div>
</div>
@@ -417,26 +425,46 @@ onBeforeUnmount(() => {
cursor: col-resize;
}
/*
* 左栏:课堂的时候最左边是一条竖栏(ContextBar 的 .lesson-rail),竖栏右边从上到下是
* 专题 / 比赛条、页签行、正文。两个页签的正文叠在同一格里,v-show 只显示一个
*/
.left-pane {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
}
.left-pane > :deep(.lesson-rail) {
grid-column: 1;
grid-row: 1 / -1;
}
.left-pane > :deep(.context-bar),
.left-pane > .tab-row {
grid-column: 2;
}
.left-pane > .pane-body {
grid-column: 2;
grid-row: 3;
}
.right-pane {
height: 100%;
}
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个胶囊按钮。
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个按钮(图标 + 字,不带框)。
高度和右栏工具栏、抽屉头部一样是 48(设计稿画的 42):没有上下文条时两边并排,
差 6px 的话两条下边线在分隔线处错成一个台阶 */
.tab-row {
flex: none;
height: 48px;
box-sizing: border-box;
padding: 0 20px 0 12px;
padding: 0 10px 0 8px;
display: flex;
align-items: center;
gap: 2px;
@@ -468,24 +496,32 @@ onBeforeUnmount(() => {
.drawer-buttons {
margin-left: auto;
display: flex;
gap: 4px;
gap: 2px;
}
.drawer-button {
height: 32px;
padding: 0 11px;
border: 1px solid v-bind('theme.borderColor');
border-radius: 16px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit;
font-size: 13px;
font-size: 14px;
color: v-bind('theme.textColor2');
white-space: nowrap;
cursor: pointer;
}
.drawer-button svg {
color: v-bind('theme.textColor3');
}
.drawer-button:hover {
border-color: v-bind('theme.primaryColorHover');
color: v-bind('theme.primaryColor');
background-color: rgba(128, 128, 128, 0.1);
color: v-bind('theme.textColor1');
}
.pane-body {
@@ -0,0 +1,14 @@
/**
* 题面正文(描述 / 输入 / 输出 / 提示)在渲染前去掉空段落。
*
* 库里的题面多是 wangEditor 时代的 HTML,老师为了「隔开一点」敲的空行存成
* `<p><br></p>`、`<p>&nbsp;</p>`,每个都撑出一整行高的缝(2020 一道题就有两处)。
* 段落之间的间距题面自己会给,这些空段落一律去掉。只动「整段什么字都没有」的,
* 段落里带图片、带字的都不碰。
*/
const EMPTY_PARAGRAPH = /<p(?:\s[^>]*)?>(?:\s|&nbsp;|&#160;| |<br\s*\/?>)*<\/p>/gi
export function tidyStatement(html: string | null | undefined): string {
if (!html) return ''
return html.replace(EMPTY_PARAGRAPH, '')
}
@@ -5,6 +5,11 @@
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。**画布管样子,本文管规则**:
版式、颜色、文案照画板,效果图表达不了的规则写在这里。
**2026-11-04 改了一轮「观感」**(用户觉得整体观感不好):同一块画布新开「题目页观感」页,
**外框和题面以那一页为准**(定稿板「定稿:合一版 + 甲 + ⑦」,题面细节看「题面 甲」、例子看
「例子 · 输入 → 输出」、特殊题面看「特殊题面」一行)。改动落在下面第 4.1、4.3、5.1、5.2、5.4 节:
课堂 / 比赛在电脑上挪成左边竖栏,页签行和工具栏并成等高的一条;题面小标题、例子重新设计。
和画板有意不同的几处(都是拍过板的):
- 题面**不写时间和内存限制**(画板上有「限时 1 秒」):用户 2026-09-28 拍板,中职学生看了反而迷惑。
@@ -86,7 +91,9 @@
| 右栏 | 约 786 | 约 740 |
| 编辑器可见行数 | 约 19 行 | 约 28 行 |
竖向:顶栏 56 → 左栏是上下文条 44 + 页签行 42 + 内容;右栏是工具栏 48 + 编辑器。
竖向:顶栏 56 → 左栏是页签行 48 + 内容(专题入口多一条 40 的专题条);右栏是工具栏 48 + 编辑器。
课堂、比赛在电脑上是左栏最左的一条竖栏(宽 56),**不占高度**:原来课堂里顶上要 56 + 44 + 48 = 148,
现在 104,两边的 48 那条对齐成一根线(2026-11-04)。
两栏之间的分隔条**保留、可拖动**(宽 SQL 表、长题面需要)。
### 4.2 为什么不是方案 A(结果放在编辑器下方)
@@ -97,8 +104,9 @@ B 看得到。
### 4.3 方案 B 的结构
- **左栏**:上下文条 → 「题目 / 结果」两个页签 → 内容(可滚动)。页签行右侧是「统计 / 点评 /
我的提交」三个按钮(高 32,带边框,和页签区分开),点开是**抽屉,盖住左栏,编辑器不动**。
- **左栏**:(课堂 / 比赛竖栏 |)上下文条 → 「题目 / 结果」两个页签 → 内容(可滚动)。页签行右侧是
「统计 / 点评 / 我的提交」三个按钮(图标 + 字、不带框;原来是带框的胶囊,看着像另一组页签),
点开是**抽屉,盖住左栏,编辑器不动**。
- **右栏**:工具栏 + 编辑器占满整列。流程图题的工具栏最左是「写代码 / 画流程图」切换。
- **去掉**:顶栏的「双栏 / 自测 / 题目」切换、`EditorForTest`、左栏的另外 4 个页签、
提交按钮下的结果浮层、「上次结果」按钮。
@@ -109,27 +117,43 @@ B 看得到。
说明「这道题是从哪来的」,只有一条,按入口路由决定显示哪种:
| 入口 | 内容 |
|---|---|
| 课堂(这道题在「班里在做」的列表里) | 「这节课 做完 3/5」+ 各题题号芯片 + ‹ › |
| 题单 | 「‹ 题单名」+ 进度条 +「7/12 · 第 8 题」+ ‹ › |
| 比赛 | 「‹ 比赛名」+ 各题题号芯片 +「还剩 38:12」+「排名」 |
| 都不是 | 不显示,页签行顶上去 |
| 入口 | 电脑(2026-11-04 起) | 手机 |
|---|---|---|
| 课堂(这道题在「班里在做」的列表里) | 左边竖栏(浅绿底):「这节课 / 2/4 / 语言」,下面一题一格:圈里写第几道(做完是实心绿勾)、圈下写题号 | 横条:「这节课 做完 3/5」+「这是第 N 道」+ ‹ › |
| 专题 | 页签行上面一条 40 的细横条,不带底色:「‹ 专题名」+ 绿进度条 +「做完 7/12 · 第 8 题」+ ‹ › | 同左,去掉进度条 |
| 比赛 | 左边竖栏(浅黄底):「‹ 比赛」+「还剩 38:12」,下面一题一个圈、圈里写题号,最底下「排名」 | 横条:「‹ 比赛名」+ 题号芯片 + 还剩 + 排名 |
| 都不是 | 不显示,页签行顶上去 | 同左 |
- 芯片**只放题号**,鼠标停上去看题目名;状态用图标区分,**不只靠颜色**:✓ 做完(实心绿)/
! 做过没对(橙)/ 没做(灰)/ 当前题(描边;做完后是实心绿加外圈)。
- 5 道以内一行放得下;**超过 5 道**时题号那一段横向滚动,当前题保持在视野里,去掉 ‹ ›。
- **竖栏只写题号**,题名鼠标停上去看:学生认题靠题号(用户 2026-11-04)。状态不只靠颜色:
做完是实心绿(课堂圈里换成 ✓)/ 做过没对是红圈 / 没做是灰圈 / 当前题是白底 + 左边一道绿 + 绿圈。
- 专题可以有几十道题、还有「加入专题」,竖栏放不下,所以专题留横条。
- 手机横条:5 道以内一行放得下;**超过 5 道**时题号那一段横向滚动,当前题保持在视野里,去掉 ‹ ›。
- 进度一律写「做完 3/5」,不写「3/5」(会被读成「第 3 道」)。
- 通过之后课堂条要重新拉一次(做完数 +1)。
- 未登录时没有这一条(接口要求登录)。
### 5.2 「题目」页签
内容顺序:标题(题号 + 名称)→ 难度、状态标签 → **代码要求**(AST 规则,放最前)→
描述 → 输入 / 输出 → 参考流程图(折叠)或你画的流程图 → 例子 → 提示 → 来源 → 相似题(按原规则出现)。
内容顺序:标题(题号 + 名称 + 难度、状态标签,一行)→ **语法要求**(AST 规则,放最前)→
描述 → 输入 → 输出 → 你画的流程图 → 例子 → 参考流程图 → 提示 → 相似题(按原规则出现)→ 来源。
- 例子改成表格:例子 N | 输入 | 输出,每格带「复制」。去掉每个例子旁的「测试」按钮。
例子标题旁有「自己输入数据试试 ›」,跳到结果页签的「自己输入」。
- **小节标题**(2026-11-04,画布「题目页观感」⑦):线条图标 + 绿色粗体字 + 一根细线拖到头
(`SectionHead.vue`),线尾可以挂链接(「自己输入数据试试 ›」「放大看 ›」)。语法要求、提示用黄色。
原来的「16px 粗体 + 左侧绿竖条」不用了。
- **题面正文**:老师为了隔开敲的空段落(`<p><br></p>`)渲染前去掉(`utils/statement.ts`);
老师自己写的 h1~h6 降成和正文一样大的粗体(2020 开头的「鸣潮公式」原来比题名还大)。
图片照原样占满栏宽、提示照常摊开 —— 缩略图、提示先收起都问过,用户不要。
- **输入、输出上下排**,不再左右并排(多一两句就挤成窄条)。
- **例子:每组一行「输入 → 输出」**,输出浅绿底(`SampleList.vue`)。去掉每个例子旁的「测试」按钮。
- 输入是「无」或空的写灰字「不用输入」、不给复制(常用题里 12% 的例子)。
- 行首空格画成浅灰点(菱形那几道靠它对齐),复制出去还是空格。
- 半格放不下最长那行,这道题的例子整体改成上下排(输入、↓、输出);整宽还放不下的那一行才折,
**折下来的部分缩进两格**。原来不折、横着滚,是怕学生分不清折行和真换行 —— 缩进两格解决的就是这个。
- 点过「运行例子」(照旧跳到结果页签),切回来序号上标 ✓ / ✗;代码一改就不标了。
- **语法要求**每条一行,前面一个小图标说是「必须 / 不能 / 次数」哪一类,文字照后端给的原样;
不再写「提交时会逐条检查」。
- **来源**不单起一节,缩成题面最底下一行灰字。SQL 题去掉「执行后变了的格子」图例(变了的格子照旧标黄)。
- 「做过,还没对」标签用红色(橙色和「中等」撞色);「已解决」实心绿。
- 不写时间 / 内存限制(2026-09-28 去掉了「限时 1 秒」:学生的程序很少碰到限制,写出来反而迷惑)。
- 流程图小节同一个位置,二选一:老师给的参考图(`showFlowchart`,默认折叠)或学生自己画到
A/S 的那张(`allowFlowchart`,默认高约 150px,可「放大看」)。两个开关本来就互斥
@@ -157,6 +181,8 @@ B 看得到。
| 教师 | 语言、运行例子、提交 …… 课堂统计、⋯ |
| 协作中的教师 | 上方加 40px 蓝色协作条(正在帮谁 · 语言 · 看他交过什么 · 结束协作);语言选择禁用 |
- 按钮只有三种分量(2026-11-04):实心的「提交」、浅绿底的「运行例子」(带播放图标)、其余不带框
(语言选择、空闲时的举手求助、课堂统计、⋯)。举手之后的排队 / 老师在帮你两种状态照旧用颜色。
- 答案正确后「提交」降成描边按钮,让结果页签里的「下一题」是唯一的主按钮。
- 「⋯」里:去自测猫(SQL 不给)、复制代码、重置代码(**加确认**;协作中的教师不给)、
编辑题目(仅超管、仅桌面)。没有例子的题「去自测猫」现在会抛错,一并修。