Files
OJ2/apps/web/src/oj/problem/detail.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

532 lines
19 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 { errorCode } from 'utils/api'
import { getProblem } from 'oj/api'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { storeToRefs } from 'pinia'
import { useProblemStore } from 'oj/store/problem'
import { useMyFlowchartStore } from 'shared/store/myFlowchart'
import { useUserStore } from 'shared/store/user'
import { useSubmissionStore } from 'oj/store/submission'
import { useFlowchartStore } from 'oj/store/flowchart'
import { useCollabStore } from 'shared/store/collab'
import type { RouteLocationNormalized } from 'vue-router'
import NotFound from 'shared/components/NotFound.vue'
// 判完之后该发生的事(烟花、点评、回专题)。静态引入:它得在第一次判完之前就挂好
import SubmissionEffects from './components/SubmissionEffects.vue'
import ResultTabLabel from './components/ResultTabLabel.vue'
import ContextBar from './components/ContextBar.vue'
import { useEditorMenu } from './composables/editorMenu'
import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } from './composables/problemPageContext'
import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
import { useProblemMascotGuide } from './composables/mascotGuide'
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
// 没有协作、也没有错误标记,于是专题里「求助」老师接不上、「把中文标点都换成英文」
// 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节)
const loadProblemEditor = () => import('./components/ProblemEditor.vue')
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
const ProblemContent = defineAsyncComponent(() => import('./components/ProblemContent.vue'))
const ProblemDrawer = defineAsyncComponent(() => import('./components/ProblemDrawer.vue'))
const ResultPane = defineAsyncComponent(() => import('./components/ResultPane.vue'))
const MobileActionBar = defineAsyncComponent(() => import('./components/MobileActionBar.vue'))
const SymbolKeys = defineAsyncComponent(() => import('./components/SymbolKeys.vue'))
interface Props {
problemID: string
contestID?: string
problemSetId?: string
}
const { problemID, contestID = '', problemSetId = '' } = defineProps<Props>()
/** 拉题面失败的原因:null 是还在拉(或拉到了),别的就整页换成 NotFound */
const failure = ref<null | 'missing' | 'not-started' | 'failed'>(null)
const route = useRoute()
const router = useRouter()
const problemStore = useProblemStore()
const myFlowchartStore = useMyFlowchartStore()
const { problem } = storeToRefs(problemStore)
const ctx = useProblemPageContext()
const { isMobile, isDesktop } = useBreakpoints()
/**
* 左栏只有「题目 / 结果」两个页签(手机上编辑器也是一个页签,排在中间)。
* 原来的六个页签里,流程图并进了「题目」,统计 / 点评 / 我的提交收进了抽屉 ——
* 见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 4 节。
*/
const tabOptions = computed(() => (isMobile.value ? ['content', 'editor', 'result'] : ['content', 'result']))
const currentTab = ref('content')
watch(
[() => route.query.tab, () => tabOptions.value],
([rawTab]) => {
const tabs = tabOptions.value
const fallback = tabs[0] ?? 'content'
currentTab.value = tabs.includes(rawTab as string) ? (rawTab as string) : fallback
},
{ immediate: true },
)
watch(currentTab, (tab) => {
if (!tabOptions.value.includes(tab) || route.query.tab === tab) return
router.replace({
query: { ...route.query, tab },
})
})
// 「结果」第一次切过去才挂(它带着 DataTable 和 Markdown 渲染),挂上之后切走也不卸载:
// 挂着的错误说明在编辑器里标着红,AI 提示也在渲染
const resultMounted = ref(false)
watch(
currentTab,
(tab) => {
if (tab === 'result') resultMounted.value = true
},
{ immediate: true },
)
const drawer = ref<DrawerKey | null>(null)
// 抽屉挂进左栏:要等左栏这个元素挂上之后才能当 Teleport 的目标
const leftPane = useTemplateRef<HTMLElement>('leftPane')
const theme = useThemeVars()
/**
* 分隔条两头按像素卡,不按比例:左栏窄于 360 时专题条的「下一题」箭头被裁掉、三个抽屉按钮
* 折成两行;右栏窄于 460 时工具栏收进「⋯」之后也放不下、语言下拉被挤成两行(原来 0.8 在 1280 屏上只给右栏 256)
*/
const { width: windowWidth } = useWindowSize()
const splitMax = computed(() => `${Math.max(360, windowWidth.value - 460)}px`)
// 协作中的老师看的是学生的提交
const teacherCollab = useTeacherCollab()
const collabHere = useCollabHere()
const collabStore = useCollabStore()
// 老师刚接上(或下一个人):左栏先切到「结果」,那里是他最后一次交的为什么错。
// 要演示时老师自己点回「题目」,题目和代码就并排了
watch(
() => teacherCollab.value && collabStore.room,
(room) => {
if (room) {
drawer.value = null
currentTab.value = 'result'
submissionStore.resultSegment = 'submit'
}
},
)
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
}
/**
* 手机页签行的「⋯」:统计 / 点评 / 我的提交,分隔线,再是编辑器的「更多」那几项
* (去自测猫、复制、重置……)。原来后面这几项在「代码」页签的工具栏里,得先切过去才点得到
*/
const editorMenu = useEditorMenu(computed(() => false))
const drawerMenu = computed<DropdownOption[]>(() => {
const drawers: DropdownOption[] = ctx.value.drawers.map((key) => ({
label: drawerLabel(key),
key,
}))
const editor = editorMenu.options.value
if (drawers.length && editor.length) drawers.push({ type: 'divider', key: 'divider' })
return [...drawers, ...editor]
})
function onMobileMenu(key: string) {
if (editorMenu.select(key)) return
toggleDrawer(key as DrawerKey)
}
// 交上去、或者语法检查没过:切到「结果」,抽屉开着就先关掉。提交按钮在编辑器那边,页签归这一页管
const submissionStore = useSubmissionStore()
watch(
() => submissionStore.resultSeq,
() => {
drawer.value = null
currentTab.value = 'result'
},
)
/**
* 学生正在排队求助、或者老师正在帮他时换题:协作是开在这道题上的,换题就断了
* (SyncCodeEditor 的 detach),排队的求助也跟着撤掉。先问一句,别让他一点「下一题」
* 就把正在帮他的老师踢出去。只拦「离开这道题」,切页签改 `?tab=` 不算
*/
const dialog = useDialog()
/**
* 已经同意过要去的地方。通过之后 1.5 秒内点「下一题」:这里先问一次,接着被强制点评拦下
* (SubmissionEffects 的 holdForReview),评完再 push 同一个地址 —— 不记着的话会再问一遍,
* 第二次点「留在这道题」就成了同意换题之后又被留下
*/
let approvedTarget: string | null = null
function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNormalized) {
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
// 协作中的老师:「页面即协作现场」,跳走这一页协作就断了、求助退回排队。所以上下文条、
// 相似题、下一题这些站内链接一律开新标签,这一页留着(设计文档第 9 节)
if (teacherCollab.value) {
window.open(router.resolve(to).href, '_blank')
return false
}
// 老师在任务页上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在
const peeked = collabHere.value && collabStore.room?.mode === 'peek'
if (collabStore.isTeacher || (collabStore.helpStatus === 'idle' && !peeked)) return true
if (approvedTarget === to.fullPath) {
approvedTarget = null
return true
}
const active = collabStore.helpStatus === 'active' || peeked
return new Promise<boolean>((resolve) => {
dialog.warning({
title: peeked ? '老师正在看你的代码' : active ? '老师正在帮你看这道题' : '你正在排队等老师',
content: active ? '换题之后,老师那边就断了。确定要换吗?' : '换题之后这次举手就撤掉了,要在新的题目里重新举手。确定要换吗?',
positiveText: '换题',
negativeText: '留在这道题',
onPositiveClick: () => {
approvedTarget = to.fullPath
resolve(true)
},
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
onMaskClick: () => resolve(false),
})
})
}
onBeforeRouteUpdate(confirmLeavingHelp)
onBeforeRouteLeave(confirmLeavingHelp)
// 到了就作废:没被点评拦下、一次就换过去的,不能留着让下次去同一道题时不再问
watch(
() => route.fullPath,
(path) => {
if (path === approvedTarget) approvedTarget = null
},
)
// 看板娘气泡里的「我错在哪」:切到「结果」页签里出错的那一段
useProblemMascotGuide((segment) => {
drawer.value = null
currentTab.value = 'result'
submissionStore.resultSegment = segment
})
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === 'result' })
// 连着换了几次题,先发的请求后回来的话,地址栏是 C、页面却是 B:只认最后一次
let initSeq = 0
async function init() {
const mine = ++initSeq
// 「我的流程图」是上一道题的。这道题也画到了 A/S 的话,SubmitFlowchart 查完会再亮出来
myFlowchartStore.hide()
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
// 避免等 getProblem 返回后才串行下载,编辑器才迟迟出现
loadProblemEditor()
failure.value = null
try {
const res = await getProblem(problemID, contestID)
if (mine !== initSeq) return
problem.value = res
} catch (err) {
if (mine !== initSeq) return
problem.value = null
const code = errorCode(err)
failure.value = code === 'contest-not-started' ? 'not-started' : code === 'problem-not-found' ? 'missing' : 'failed'
}
}
onMounted(init)
/** 题不存在时回哪:比赛里回比赛,专题里回专题,否则回题目列表 */
const backTo = computed(() =>
contestID
? { to: `/contest/${contestID}`, toLabel: '回到比赛' }
: problemSetId
? { to: `/problemset/${problemSetId}`, toLabel: '回到专题' }
: { to: '/problem', toLabel: '去题目列表' },
)
watch(() => problemID, init)
// 题目详情里的 myStatus / myFailedCount 是按当前用户算的,而登录不重新挂载这个页面 ——
// 会话过期后直接在题目页登录的(机房里最常见的那条路)不补拉一次,AI 提示的解锁进度
// 就还是匿名时的 0,等于白改。只换 problem,不走 init:那里会把整页重来一遍。
watch(
() => useUserStore().isAuthed,
async (authed) => {
if (!authed || !problem.value) return
try {
problem.value = await getProblem(problemID, contestID)
} catch {
// 拉不到就留着现在这份题面,不要把页面清空
}
},
)
onBeforeUnmount(() => {
// 提交状态在 store 里、离开页面也不会自己没:不清的话回到题目页(哪怕换了一道题),
// 结果面板里还是上一次的那条
useSubmissionStore().reset()
// 流程图那边同理,还在评的那次不再跟(照常评完落库)
useFlowchartStore().reset()
problem.value = null
failure.value = null
myFlowchartStore.hide()
})
</script>
<template>
<template v-if="problem">
<SubmissionEffects />
<!--
桌面:两栏顶满整个内容区(设计稿「机房(方案 B)」),中间一条 1px 分隔线、可拖动。
布局给内容区留了 16px 内边距,这里用负边距抵掉
-->
<n-split v-if="isDesktop" class="problem-split" direction="horizontal" :default-size="0.43" min="360px" :max="splitMax" :resize-trigger-size="1">
<template #1>
<div id="problem-left-pane" ref="leftPane" class="left-pane">
<ContextBar />
<div class="tab-row">
<div class="page-tabs" role="tablist" aria-label="题目 / 结果">
<button
type="button"
role="tab"
class="page-tab"
:class="{ active: currentTab === 'content' }"
:aria-selected="currentTab === 'content'"
@click="currentTab = 'content'"
>
题目
</button>
<button
type="button"
role="tab"
class="page-tab"
:class="{ active: currentTab === 'result' }"
:aria-selected="currentTab === 'result'"
@click="currentTab = 'result'"
>
<ResultTabLabel :active="currentTab === 'result'" />
</button>
</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)">
<Icon :icon="DRAWER_ICON[key]" :width="16" aria-hidden="true" />{{ drawerLabel(key) }}
</button>
</div>
</div>
<!-- 两个页签各滚各的:读题读到底下切去看结果,结果不该也停在底下 -->
<div v-show="currentTab === 'content'" class="pane-body">
<n-scrollbar content-style="padding: 16px 20px">
<ProblemContent />
</n-scrollbar>
</div>
<div v-if="resultMounted" v-show="currentTab === 'result'" class="pane-body">
<n-scrollbar content-style="padding: 14px 20px">
<ResultPane />
</n-scrollbar>
</div>
<!-- 抽屉盖住整个左栏(连上下文条和页签行),抽屉顶上有自己的页签可以互相切换;编辑器不动 -->
<ProblemDrawer v-if="leftPane" v-model="drawer" :to="leftPane" />
</div>
</template>
<template #2>
<div class="right-pane">
<ProblemEditor />
</div>
</template>
</n-split>
<!-- Mobile:底部固定「运行例子 / 提交」,内容底下留出它的高度 -->
<div v-else class="mobile">
<ContextBar />
<n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目">
<ProblemContent />
</n-tab-pane>
<!--
编辑器一直挂着(show,不是默认的 if):底部的「提交」在三个页签下都能点,
没切到过「代码」页签的话草稿就没读进来、按钮是灰的;切走就卸载还会丢掉撤销历史
-->
<n-tab-pane name="editor" tab="代码" display-directive="show">
<ProblemEditor />
</n-tab-pane>
<n-tab-pane name="result" :tab="resultTab" display-directive="show:lazy">
<ResultPane />
</n-tab-pane>
<template v-if="drawerMenu.length" #suffix>
<n-dropdown trigger="click" :options="drawerMenu" @select="onMobileMenu">
<n-button size="small" quaternary aria-label="更多:统计、点评、我的提交、复制代码……"> ⋯ </n-button>
</n-dropdown>
</template>
</n-tabs>
<ProblemDrawer v-model="drawer" />
<MobileActionBar />
<SymbolKeys />
</div>
</template>
<NotFound
v-else-if="failure === 'not-started'"
icon="ph:clock"
title="比赛还没有开始"
hint="开始以后才能看题"
:to="`/contest/${contestID}`"
to-label="回到比赛"
/>
<NotFound v-else-if="failure === 'failed'" icon="ph:wifi-slash" title="题目没拉下来">
<n-button type="primary" @click="init()">再试一次</n-button>
</NotFound>
<NotFound v-else-if="failure === 'missing'" title="这道题不存在" hint="可能是题号打错了,或者这道题还没公开" v-bind="backTo" />
</template>
<style scoped>
/* 手机:课堂条贴满屏宽(设计稿「手机:题目 / 代码 / 结果」),下面的内容照旧留 16px */
.mobile {
margin: -16px -16px 0;
padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
.mobile > :deep(.n-tabs) {
padding: 10px 16px 0;
box-sizing: border-box;
}
.problem-split {
margin: -16px;
width: calc(100% + 32px);
height: calc(100vh - 56px);
}
/* 分隔线:看上去 1px,拖的时候热区宽一点 */
.problem-split :deep(.n-split__resize-trigger) {
background-color: v-bind('theme.borderColor');
}
/* 热区挂在分隔线本身上:wrapper 不是定位元素,挂在它上面的 ::before 会盖住整个分栏 */
.problem-split :deep(.n-split__resize-trigger-wrapper) {
position: relative;
}
.problem-split :deep(.n-split__resize-trigger-wrapper)::before {
content: '';
position: absolute;
inset: 0 -4px;
cursor: col-resize;
}
/*
* 左栏:课堂的时候最左边是一条竖栏(ContextBar 的 .lesson-rail),竖栏右边从上到下是
* 专题 / 比赛条、页签行、正文。两个页签的正文叠在同一格里,v-show 只显示一个
*/
.left-pane {
position: relative;
height: 100%;
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 10px 0 8px;
display: flex;
align-items: center;
gap: 2px;
border-bottom: 1px solid v-bind('theme.dividerColor');
}
.page-tabs {
display: flex;
height: 100%;
}
.page-tab {
height: 48px;
padding: 0 12px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
font: inherit;
color: v-bind('theme.textColor3');
cursor: pointer;
}
.page-tab.active {
border-bottom-color: v-bind('theme.primaryColor');
color: v-bind('theme.textColor1');
font-weight: 600;
}
.drawer-buttons {
margin-left: auto;
display: flex;
gap: 2px;
}
.drawer-button {
height: 32px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit;
font-size: 14px;
color: v-bind('theme.textColor2');
white-space: nowrap;
cursor: pointer;
}
.drawer-button svg {
color: v-bind('theme.textColor3');
}
.drawer-button:hover {
background-color: rgba(128, 128, 128, 0.1);
color: v-bind('theme.textColor1');
}
.pane-body {
flex: 1;
min-height: 0;
}
</style>