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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:52:58 +08:00

397 lines
15 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 { 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 { 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'
import { useProblemPageContext } from '../composables/problemPageContext'
import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
const ProblemFlowchart = defineAsyncComponent(() => import('./ProblemFlowchart.vue'))
const FlowchartView = defineAsyncComponent(() => import('shared/components/FlowchartEditor/FlowchartView.vue'))
const MyFlowchart = defineAsyncComponent(() => import('./MyFlowchart.vue'))
const theme = useThemeVars()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const ctx = useProblemPageContext()
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value))
// 相似题推荐:做对了、或者错了 3 次以上才给(比赛、专题里不给,见 problemPageContext)
const showSimilar = computed(() => ctx.value.similar && (problem.value?.myStatus === 0 || problemStore.failCount >= 3))
/**
* 标题下的状态标签。原来是题面最上面两条整宽的提示(「本题已经被你解决啦」「尝试过但还没有
* 通过」),占掉一屏里最值钱的那几行。专题里不给(入口差异表)。
*/
const myStatus = computed(() => {
if (!ctx.value.statusTag || !userStore.isAuthed) return null
const status = problem.value?.myStatus
// 设计稿:没交过也写一个「还没交过」,状态这一格始终在
if (status === undefined || status === null) return 'none'
return status === 0 ? 'solved' : 'tried'
})
// 不写「限时 1 秒 / 内存 256MB」:中职学生看了只会犯迷糊,超时、超内存判出来时结果页签里自有说明
// ==================== 流程图小节 ====================
// 老师给的参考图(showFlowchart)和学生自己画到 A/S 的那张(allowFlowchart)只会有一个:
// 两个开关在后台是互斥的(admin/problem/detail.vue);后端在 allowFlowchart 为真时会把
// mermaidCode 置成 null,不能把标准答案下发给正要自己画图的学生。
const canShowReferenceFlowchart = computed(() => !!problem.value?.showFlowchart && !!problem.value?.mermaidCode)
/**
* 参考图用画流程图那套排法画(设计稿「参考流程图」C)。只有手写 Mermaid、没有结构树的老图
* (恢复老备份才会有)退回 Mermaid
*/
const referenceDoc = computed(() => {
const doc = parseFlowDoc(problem.value?.flowchartData)
return doc?.steps.length ? doc : null
})
/** 点开放大看。手机上弹框是整屏的,按返回先关它 */
const referenceZoom = ref(false)
const { isMobile } = useBreakpoints()
useBackClose(referenceZoom, isMobile)
const myFlowchartStore = useMyFlowchartStore()
const flowchartStore = useFlowchartStore()
const userStore = useUserStore()
const myFlowchartZoom = ref(false)
/** 自己画的那张默认展开、可以收起(设计稿「流程图 A 级之后」) */
const myFlowchartOpen = ref(true)
watch(
() => problem.value?._id,
() => (myFlowchartOpen.value = true),
)
/**
* 标题下的「流程图 X 级」:评完过的里面最好的那一次。画到 A / S 是绿的(算做完了),
* 没画到是橙的 —— 原来只在 A / S 时才写,画到 C 的学生标题下是「还没交过」,像是什么都没交
*/
const flowchartGrade = computed(() => {
const best = flowchartStore.scores.reduce<(typeof flowchartStore.scores)[number] | null>((acc, row) => (!acc || row.score > acc.score ? row : acc), null)
return best?.grade ?? ''
})
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
watch(
() => [problem.value?.id, !!problem.value?.allowFlowchart && ctx.value.entry !== 'contest', userStore.isAuthed] as const,
([id, drawable, authed]) => {
if (id && drawable && authed) flowchartStore.ensureLoaded()
},
{ immediate: true },
)
// 例子只是摆出来(SampleList)。试跑统一走工具栏的「运行例子」和结果页签的「自己输入」
const samples = computed(() => problem.value?.samples ?? [])
const submissionStore = useSubmissionStore()
/** 描述、输入、输出、提示:去掉老师为了隔开敲的空段落(见 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_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">
<span class="pid">{{ problem._id }}</span>
<h1 class="problem-title">{{ problem.title }}</h1>
<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" class="solved"> 已解决 </n-tag>
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
「还没交过」,旁边的「流程图 C 级」已经说了交过什么。
「做过,还没对」用红色:橙色和「中等」撞色,两个标签挨着分不出来 -->
<template v-else-if="!flowchartPassed">
<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 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了。
黄色,和提示同色;每条一行,前面的小图标说是「必须 / 不能 / 次数」哪一类 -->
<section v-if="astRequirements.length > 0" class="block">
<SectionHead icon="rules" tone="amber">语法要求</SectionHead>
<template v-for="[lang, rules] in astRequirements" :key="lang">
<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>
</section>
<section class="block">
<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">
<SectionHead icon="output">输出</SectionHead>
<Markdown html :content="statement.output" />
</section>
</template>
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
<section v-if="!canShowReferenceFlowchart && myFlowchartStore.showing" class="block">
<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>
</template>
</SectionHead>
<div v-if="myFlowchartOpen" class="my-flowchart" role="button" aria-label="放大看你画的流程图" @click="myFlowchartZoom = true">
<MyFlowchart compact />
</div>
<n-modal v-model:show="myFlowchartZoom" preset="card" title="你画的流程图" :style="{ maxWidth: '900px' }">
<MyFlowchart />
</n-modal>
</section>
<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>
</SectionHead>
<SampleList :samples="samples" />
</section>
<SqlProblemData v-if="isSQL && problem.sqlDisplay" :display="problem.sqlDisplay" :order-sensitive="!!problem.sqlConfig?.order_sensitive">
<template #title="{ text }">
<SectionHead :icon="text === '数据表' ? 'table' : 'expected'" class="sql-head">{{ text }}</SectionHead>
</template>
</SqlProblemData>
<!--
老师给的参考流程图:默认摊开,放在例子后面(设计稿「参考流程图」C)。原来在例子前面、
折成一条要点开 —— 摊开放前面的话,长图(1056 有一千来像素高)会把例子挤出第一屏
-->
<section v-if="canShowReferenceFlowchart" class="block">
<SectionHead icon="flowchart">
参考流程图
<template #extra>
<n-button text type="primary" @click="referenceZoom = true">放大看 ›</n-button>
</template>
</SectionHead>
<div
class="reference-flowchart"
role="button"
tabindex="0"
aria-label="放大看参考流程图"
@click="referenceZoom = true"
@keydown.enter="referenceZoom = true"
>
<FlowchartView v-if="referenceDoc" :doc="referenceDoc" />
<ProblemFlowchart v-else />
</div>
<n-modal v-model:show="referenceZoom" preset="card" title="参考流程图" :style="{ maxWidth: '1000px' }">
<FlowchartView v-if="referenceDoc" :doc="referenceDoc" zoomed />
<ProblemFlowchart v-else />
</n-modal>
</section>
<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-content {
container: problem-content / inline-size;
}
/* 题号 | 题名 | ……… | 难度 状态 */
.problem-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
}
.pid {
flex: none;
font-size: 16px;
font-variant-numeric: tabular-nums;
color: v-bind('theme.textColor3');
}
.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: 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: 8px;
border: 1px solid rgba(128, 128, 128, 0.14);
background-color: rgba(128, 128, 128, 0.05);
padding: 8px;
}
.source {
margin-top: 24px;
padding-top: 10px;
border-top: 1px solid v-bind('theme.dividerColor');
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
color: v-bind('theme.textColor3');
}
.source-label {
flex: none;
}
.source-text {
min-width: 0;
font-size: 13px;
line-height: 1.6;
}
</style>