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

@@ -31,47 +31,85 @@ function openHisSubmissions() {
<template>
<div v-if="collabStore.room" class="collab-bar" role="status">
<span class="dot" aria-hidden="true" />
<span class="who">
正在帮 <b>{{ collabStore.room.peerName }}</b> ·
正在帮 {{ collabStore.room.peerName }} ·
{{ LANGUAGE_SHOW_VALUE[collabStore.room.language] }}
</span>
<n-button text class="link" @click="openHisSubmissions">看他交过什么 ↗</n-button>
<span class="note">你改的他马上能看到</span>
<span class="spacer" />
<button type="button" class="ghost" @click="openHisSubmissions">看他交过什么</button>
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
那边只是退回排队 —— 见 store 里 leave 的注释 -->
<n-button size="small" class="end" @click="collabStore.leave('done')">结束协作</n-button>
<button type="button" class="end" @click="collabStore.leave('done')">结束协作</button>
</div>
</template>
<style scoped>
/* 设计稿「上下文条与协作状态」:深蓝一条,顶在工具栏上面 */
.collab-bar {
height: 40px;
flex: none;
box-sizing: border-box;
padding: 0 10px 0 16px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 12px;
border-radius: 6px;
background-color: #2080f0;
gap: 10px;
background-color: #1d4f9c;
color: #fff;
font-size: 14px;
font-size: 13px;
white-space: nowrap;
}
.dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #7fe0a8;
}
.who {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
}
.link {
--n-text-color: #fff !important;
--n-text-color-hover: rgba(255, 255, 255, 0.85) !important;
--n-text-color-pressed: rgba(255, 255, 255, 0.7) !important;
--n-text-color-focus: #fff !important;
text-decoration: underline;
.note {
color: #d5e2f6;
}
.spacer {
flex: 1 1 0;
}
.ghost,
.end {
flex: none;
height: 28px;
border-radius: 4px;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.ghost {
padding: 0 10px;
border: 1px solid #8fb0e0;
background: transparent;
color: #fff;
}
.ghost:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.end {
margin-left: auto;
padding: 0 12px;
border: 0;
background-color: #fff;
color: #1d4f9c;
font-weight: 600;
}
</style>
+244 -68
View File
@@ -7,6 +7,8 @@ import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
import { useLessonStore } from "oj/store/lesson"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useThemeVars } from "naive-ui"
import type { ProblemSet, ProblemSetProblem } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext"
import ProblemChips, { type ChipItem } from "./ProblemChips.vue"
@@ -27,6 +29,20 @@ import ProblemChips, { type ChipItem } from "./ProblemChips.vue"
const CHIPS_FIT = 5
const ctx = useProblemPageContext()
const { isMobile } = useBreakpoints()
const theme = useThemeVars()
const isDark = useDark()
/**
* 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、题单浅蓝、比赛浅黄。
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
*/
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()
const { problem } = storeToRefs(useProblemStore())
const currentId = computed(() => problem.value?._id ?? "")
@@ -85,6 +101,7 @@ const lesson = computed(() => {
chips,
done: list.filter((item) => item.myStatus === "accepted").length,
total: list.length,
position: index + 1,
previous: chips[index - 1]?.to ?? null,
next: chips[index + 1]?.to ?? null,
}
@@ -211,115 +228,274 @@ onSolvedHere(() => {
<template>
<!-- 课堂 -->
<div v-if="lesson" class="context-bar">
<span class="lead">
这节课 <b>做完 {{ lesson.done }}/{{ lesson.total }}</b>
</span>
<ProblemChips :items="lesson.chips" :current="currentId" />
<n-button-group v-if="lesson.total <= CHIPS_FIT" size="small" class="steps">
<n-button :disabled="!lesson.previous" title="上一题" @click="$router.push(lesson.previous!)">
‹
</n-button>
<n-button :disabled="!lesson.next" title="下一题" @click="$router.push(lesson.next!)">
›
</n-button>
</n-button-group>
<div v-if="lesson" class="context-bar lesson">
<span class="lead lesson-lead">这节课 做完 {{ lesson.done }}/{{ lesson.total }}</span>
<!-- 手机上放不下一排题号:只说是第几道,箭头做大(设计稿「手机:题目 / 代码 / 结果」) -->
<span v-if="isMobile" class="meta">这是第 {{ lesson.position }} 道</span>
<ProblemChips v-else :items="lesson.chips" :current="currentId" variant="lesson" />
<div class="spacer" />
<template v-if="isMobile || lesson.total <= CHIPS_FIT">
<component
:is="lesson.previous ? 'router-link' : 'span'"
:to="lesson.previous ?? undefined"
class="step"
:class="{ disabled: !lesson.previous, big: isMobile }"
aria-label="上一题"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M15 6l-6 6 6 6" />
</svg>
</component>
<component
:is="lesson.next ? 'router-link' : 'span'"
:to="lesson.next ?? undefined"
class="step forward"
:class="{ disabled: !lesson.next, big: isMobile }"
aria-label="下一题"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 6l6 6-6 6" />
</svg>
</component>
</template>
</div>
<!-- 题单 -->
<div v-else-if="setBar" class="context-bar">
<router-link :to="setBar.home" class="back" :title="`回到题单:${setBar.title}`">
<span aria-hidden="true">‹</span>
<span class="name">{{ setBar.title }}</span>
<div v-else-if="setBar" class="context-bar problemset">
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
‹ {{ setBar.title }}
</router-link>
<n-progress
class="set-progress"
type="line"
:percentage="setBar.percentage"
:show-indicator="false"
:height="6"
status="success"
/>
<span class="lead">
<b>做完 {{ setBar.done }}/{{ setBar.total }}</b>
<div class="set-progress" aria-hidden="true">
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
</div>
<span class="meta">
做完 {{ setBar.done }}/{{ setBar.total }}
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
</span>
<n-button-group size="small" class="steps">
<n-button :disabled="!setBar.previous" title="上一题" @click="$router.push(setBar.previous!)">
‹
</n-button>
<n-button :disabled="!setBar.next" title="下一题" @click="$router.push(setBar.next!)">
›
</n-button>
</n-button-group>
<div class="spacer" />
<component
:is="setBar.previous ? 'router-link' : 'span'"
:to="setBar.previous ?? undefined"
class="step"
:class="{ disabled: !setBar.previous, big: isMobile }"
aria-label="上一题"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M15 6l-6 6 6 6" />
</svg>
</component>
<component
:is="setBar.next ? 'router-link' : 'span'"
:to="setBar.next ?? undefined"
class="step forward"
:class="{ disabled: !setBar.next, big: isMobile }"
aria-label="下一题"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 6l6 6-6 6" />
</svg>
</component>
</div>
<!-- 比赛 -->
<div v-else-if="contestBar" class="context-bar">
<router-link :to="contestBar.home" class="back" :title="`回到比赛:${contestBar.title}`">
<span aria-hidden="true">‹</span>
<span class="name">{{ contestBar.title }}</span>
</router-link>
<ProblemChips :items="contestBar.chips" :current="currentId" />
<span v-if="contestBar.remaining" class="lead remaining">
还剩 <b>{{ contestBar.remaining }}</b>
</span>
<span v-else class="lead">已结束</span>
<router-link :to="contestBar.rank" class="steps">
<n-button size="small">排名</n-button>
<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" />
<div class="spacer" />
<template v-if="contestBar.remaining">
<span class="meta">还剩</span>
<span class="countdown">{{ contestBar.remaining }}</span>
</template>
<span v-else class="meta">已结束</span>
<router-link :to="contestBar.rank" class="contest-rank">排名</router-link>
</div>
</template>
<style scoped>
.context-bar {
height: 44px;
flex: none;
box-sizing: border-box;
padding: 0 12px 0 20px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 2px;
flex: none;
gap: 6px;
min-width: 0;
font-size: 14px;
font-size: 13px;
white-space: nowrap;
border-bottom: 1px solid;
}
.lesson {
background-color: rgba(24, 160, 88, 0.07);
border-bottom-color: rgba(24, 160, 88, 0.18);
}
.problemset {
gap: 10px;
background-color: rgba(47, 107, 196, 0.06);
border-bottom-color: rgba(47, 107, 196, 0.16);
}
.contest {
gap: 8px;
padding-right: 16px;
background-color: rgba(208, 138, 28, 0.08);
border-bottom-color: rgba(208, 138, 28, 0.2);
}
.lead {
flex: none;
margin-right: 4px;
font-weight: 600;
}
.remaining b {
font-variant-numeric: tabular-nums;
.lesson-lead {
color: v-bind("tone.lessonText");
}
.meta {
flex: none;
color: v-bind("theme.textColor2");
}
.spacer {
flex: 1 1 0;
}
.back {
display: inline-flex;
align-items: center;
gap: 2px;
/* 名字不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
flex: none;
max-width: 160px;
color: inherit;
text-decoration: none;
flex: 0 1 auto;
min-width: 0;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
text-decoration: none;
}
.back:hover {
text-decoration: underline;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
.set-back {
color: v-bind("tone.setText");
}
.contest-back {
max-width: 160px;
color: v-bind("tone.contestText");
}
.set-progress {
flex: 1 1 60px;
min-width: 40px;
flex: none;
width: 110px;
height: 6px;
border-radius: 3px;
background-color: rgba(47, 107, 196, 0.16);
overflow: hidden;
}
.steps {
.set-progress-fill {
height: 100%;
border-radius: 3px;
background-color: v-bind("tone.setBar");
}
.countdown {
flex: none;
margin-left: auto;
font-family: Consolas, Monaco, monospace;
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: v-bind("tone.contestText");
}
.contest-rank {
flex: none;
color: v-bind("tone.contestText");
text-decoration: none;
}
.contest-rank:hover {
text-decoration: underline;
}
/* ‹ ›:不带边框的箭头,「下一题」那个上色 */
.step {
flex: none;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: v-bind("theme.textColor2");
}
.step.big {
width: 44px;
height: 44px;
}
.step.forward {
color: v-bind("tone.lessonText");
}
.problemset .step.forward {
color: v-bind("tone.setText");
}
.step:not(.disabled):hover {
background-color: rgba(128, 128, 128, 0.1);
}
.step.disabled {
opacity: 0.3;
}
</style>
@@ -10,7 +10,7 @@ import { useUserStore } from "shared/store/user"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui"
import { NFlex, useThemeVars } from "naive-ui"
import SubmitCode from "./SubmitCode.vue"
import { useProblemPageContext } from "../composables/problemPageContext"
import { statisticsOpen, useEditorMenu } from "../composables/editorMenu"
@@ -44,6 +44,7 @@ const mode = computed({
})
const { isDesktop } = useBreakpoints()
const theme = useThemeVars()
/**
* 「运行例子」:用题目里的例子试跑(Judge0),结果在左栏「结果」页签。顶替原来题面里
@@ -119,8 +120,8 @@ const toggleHelp = () => {
* 所以次要的按钮收进「更多」、「写代码 / 画流程图」缩短一点(设计文档 5.4)。语言下拉不收:
* 窄于 120px 时图标和「Python」会被挤成两行
*/
const toolbarRef = useTemplateRef<{ $el: HTMLElement }>("toolbarRef")
const { width: toolbarWidth } = useElementSize(() => toolbarRef.value?.$el)
const toolbarRef = useTemplateRef<HTMLElement>("toolbarRef")
const { width: toolbarWidth } = useElementSize(toolbarRef)
const narrow = computed(() => isDesktop.value && toolbarWidth.value > 0 && toolbarWidth.value < 600)
/** 「课堂统计」常驻在工具栏上:桌面、而且放得下 */
const statisticsInline = computed(() => isDesktop.value && !narrow.value)
@@ -152,7 +153,8 @@ const changeLanguage = (v: LANGUAGE) => {
</script>
<template>
<n-flex ref="toolbarRef" align="center" :size="narrow ? 8 : 12">
<!-- 设计稿:语言、运行例子、提交靠左;求助、课堂统计、⋯ 推到最右 -->
<div ref="toolbarRef" class="toolbar" :class="{ narrow }">
<!-- 协作中的老师不会落到画图:求助入口本身就排掉了流程图 -->
<n-radio-group v-if="canDraw && !showCollabBar" v-model:value="mode" :size="buttonSize">
<n-radio-button value="code">{{ narrow ? "代码" : "写代码" }}</n-radio-button>
@@ -174,6 +176,8 @@ const changeLanguage = (v: LANGUAGE) => {
<n-button
v-if="canRunSamples && isDesktop"
:size="buttonSize"
type="primary"
ghost
:loading="samplesRunning"
:disabled="!codeStore.code.value.trim()"
@click="submissionStore.runSamples()"
@@ -185,6 +189,22 @@ const changeLanguage = (v: LANGUAGE) => {
<SubmitCode v-else-if="isDesktop" />
<div class="spacer" />
<n-button
v-if="showHelpButton"
:size="buttonSize"
:type="helpButtonType"
:secondary="collabStore.helpStatus !== 'idle'"
:disabled="collabStore.helpStatus === 'active'"
:title="helpButtonTitle"
class="help"
@click="toggleHelp"
>
<span v-if="collabStore.helpStatus === 'active'" class="dot" aria-hidden="true" />
{{ helpButtonText }}
</n-button>
<n-button
v-if="statisticsInline && userStore.isTeacherOrAbove"
:size="buttonSize"
@@ -200,20 +220,44 @@ const changeLanguage = (v: LANGUAGE) => {
:options="menuOptions"
@select="handleMenuSelect"
>
<n-button :size="buttonSize" :title="narrow ? '更多' : undefined">
{{ narrow ? "⋯" : "更多" }}
<n-button :size="buttonSize" class="more" aria-label="更多:去自测猫、复制代码、重置代码……">
⋯
</n-button>
</n-dropdown>
<n-button
v-if="showHelpButton"
:size="buttonSize"
:type="helpButtonType"
:disabled="collabStore.helpStatus === 'active'"
:title="helpButtonTitle"
@click="toggleHelp"
>
{{ helpButtonText }}
</n-button>
</n-flex>
</div>
</template>
<style scoped>
.toolbar {
height: 48px;
flex: none;
box-sizing: border-box;
padding: 0 14px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.toolbar.narrow {
gap: 6px;
padding: 0 10px;
}
.spacer {
flex: 1 1 0;
}
.more {
width: 34px;
padding: 0;
}
.help .dot {
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 50%;
background-color: v-bind("theme.successColor");
}
</style>
+102 -19
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useLessonStore } from "oj/store/lesson"
/**
@@ -18,45 +19,127 @@ const props = defineProps<{
}>()
const lessonStore = useLessonStore()
const theme = useThemeVars()
// 暗色主题的主色是浅薄荷绿,按钮上的白字看不清,换深色字(和 Naive 自己的主按钮一样)
const isDark = useDark()
const buttonText = computed(() => (isDark.value ? "rgba(0, 0, 0, 0.85)" : "#fff"))
// 一般课堂条早就拉过了,这里只是兜底(旧了才拉)
onMounted(() => lessonStore.load())
const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId))
const label = computed(() => lessonStore.label)
// 设计稿和上下文条都叫「这节课」,不区分是老师布置的还是从同班提交推断的
const label = "这节课"
</script>
<template>
<n-card v-if="lesson" size="small" embedded class="lesson-next">
<n-flex v-if="lesson.next" align="center" justify="space-between" :wrap="false">
<n-text depth="3">{{ label }}还剩 {{ lesson.remaining }} 道</n-text>
<router-link :to="`/problem/${lesson.next.problemDisplayId}`">
<n-button type="primary">
下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}
</n-button>
<!-- 设计稿「答案正确:下一题在结果页签里」:浅绿卡片,一个 44px 的大按钮 -->
<div v-if="lesson" class="lesson-next">
<template v-if="lesson.next">
<div class="text">
<span class="headline">{{ label }}还剩 {{ lesson.remaining }} 道</span>
<span class="detail">
{{ lesson.next.problemDisplayId }}
{{ lesson.next.myStatus === "tried" ? "你做过,还没对" : "你还没做过" }}
</span>
</div>
<router-link :to="`/problem/${lesson.next.problemDisplayId}`" class="next">
<span class="next-label"
>下一题:{{ lesson.next.problemDisplayId }} {{ lesson.next.title }}</span
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</router-link>
</n-flex>
<n-flex v-else align="center" justify="space-between">
<n-text>{{ label }}都做完了,一共 {{ lesson.total }} 道 🎉</n-text>
<router-link to="/problemset">
<n-button secondary>去题单继续练</n-button>
</template>
<template v-else>
<div class="text">
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span class="detail">想接着练,可以去题单里挑一个</span>
</div>
<router-link to="/problemset" class="next secondary">
<span class="next-label">去题单继续练</span>
</router-link>
</n-flex>
</n-card>
</template>
</div>
</template>
<style scoped>
.lesson-next {
margin-top: 12px;
max-width: 560px;
padding: 16px 18px;
border-radius: 8px;
background-color: rgba(24, 160, 88, 0.07);
border: 1px solid rgba(24, 160, 88, 0.25);
display: flex;
flex-direction: column;
gap: 12px;
}
.text {
display: flex;
flex-direction: column;
gap: 4px;
}
.headline {
font-size: 15px;
font-weight: 600;
}
.detail {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.next {
align-self: flex-start;
max-width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 20px;
border-radius: 6px;
background-color: v-bind("theme.primaryColor");
color: v-bind("buttonText");
font-size: 15px;
font-weight: 600;
text-decoration: none;
}
.next:hover {
background-color: v-bind("theme.primaryColorHover");
}
.next:focus-visible {
outline: 2px solid v-bind("theme.primaryColorPressed");
outline-offset: 2px;
}
.next.secondary {
background-color: transparent;
border: 1px solid v-bind("theme.primaryColor");
color: v-bind("theme.primaryColorPressed");
}
/* 题目名可能很长(带 emoji 的、二十几个字的),按钮别把结果面板撑宽 */
.lesson-next :deep(.n-button__content) {
display: block;
max-width: 320px;
.next-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -5,8 +5,9 @@ import type { RouteLocationRaw } from "vue-router"
/**
* 上下文条里的一排题号(课堂、比赛共用)。只放题号,题目名在鼠标停上去时给。
*
* 状态用图标区分,不只靠颜色:✓ 做完(实心绿)/ ! 做过没对(橙)/ 灰底 没做;当前题描边。
* 一节课一般 5 道以内,一行放得下;多了(比赛、老师一次布置了一长串)就横向滚动,
* 状态用图标区分,不只靠颜色:✓ 做完(实心绿)/ 做过没对(课堂是橙色 !,比赛是红色 ✕)/
* 灰底 没做;当前题描边,做完了的当前题实心绿再套一圈。课堂是圆角胶囊、比赛是方角
* (设计稿「上下文条与协作状态」)。一节课一般 5 道以内,一行放得下;多了就横向滚动,
* 当前题保持在视野里。
*/
export interface ChipItem {
@@ -19,17 +20,22 @@ export interface ChipItem {
const props = defineProps<{
items: ChipItem[]
current: string
variant: "lesson" | "contest"
}>()
const theme = useThemeVars()
/**
* 「做完」的实心绿上用什么字色。暗色主题的成功色是浅薄荷绿,白字几乎看不见 ——
* 和 Naive 自己的主按钮一样,暗色下换深色字
*/
/** 「做完」的实心绿上用什么字色:暗色主题的成功色是浅薄荷绿,白字几乎看不见 */
const isDark = useDark()
const doneText = computed(() => (isDark.value ? "rgba(0, 0, 0, 0.85)" : "#fff"))
const STATUS_TEXT = { done: "做完了", tried: "做过,还没对", none: "还没做" } as const
const STATUS_TEXT = computed(
() =>
({
done: "做完了",
tried: props.variant === "contest" ? "交过,还没对" : "做过,还没对",
none: "还没做",
}) as const,
)
const isCurrent = (item: ChipItem) => item.displayId.toLowerCase() === props.current.toLowerCase()
@@ -49,7 +55,8 @@ watch(
</script>
<template>
<div ref="scroller" class="chips">
<!-- 类名带前缀:父组件(ContextBar)的 .lesson / .contest 会作用到子组件根元素上 -->
<div ref="scroller" class="chips" :class="`chips-${variant}`">
<n-tooltip v-for="item in items" :key="item.displayId" :delay="300">
<template #trigger>
<router-link
@@ -59,9 +66,48 @@ watch(
:aria-label="`${item.displayId} ${item.title}(${STATUS_TEXT[item.status]})`"
:aria-current="isCurrent(item) ? 'page' : undefined"
>
<!-- 用字符不用图标:图标是从 Iconify 源现拉的,拉不到时状态就只剩颜色 -->
<span v-if="item.status === 'done'" aria-hidden="true">✓</span>
<span v-else-if="item.status === 'tried'" aria-hidden="true">!</span>
<!-- 内嵌 SVG,不走 Iconify:图标源拉不到时状态就只剩颜色了 -->
<svg
v-if="item.status === 'done'"
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12l5 5 9-10" />
</svg>
<svg
v-else-if="item.status === 'tried' && variant === 'contest'"
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
<svg
v-else-if="item.status === 'tried'"
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M12 7v6" />
<path d="M12 17h.01" />
</svg>
<span>{{ item.displayId }}</span>
</router-link>
</template>
@@ -78,9 +124,13 @@ watch(
flex: 0 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
/* 描边的外圈别被裁掉 */
padding: 3px 2px;
scrollbar-width: none;
/* 当前题外面那一圈别被裁掉 */
padding: 4px 3px;
}
.chips::-webkit-scrollbar {
display: none;
}
.chip {
@@ -89,14 +139,19 @@ watch(
align-items: center;
gap: 3px;
height: 26px;
padding: 0 9px;
box-sizing: border-box;
padding: 0 8px;
border-radius: 13px;
border: 1.5px solid transparent;
font-size: 13px;
font-variant-numeric: tabular-nums;
text-decoration: none;
color: v-bind("theme.textColor2");
background-color: rgba(128, 128, 128, 0.12);
border: 1px solid transparent;
background-color: rgba(128, 128, 128, 0.14);
}
.chips-contest .chip {
border-radius: 4px;
}
.chip.done {
@@ -105,23 +160,36 @@ watch(
}
.chip.tried {
color: v-bind("theme.warningColor");
color: v-bind("theme.warningColorPressed");
background-color: rgba(240, 160, 32, 0.12);
border-color: rgba(240, 160, 32, 0.5);
border: 1px solid rgba(240, 160, 32, 0.45);
}
.chip.current {
border-color: v-bind("theme.primaryColor");
box-shadow: 0 0 0 1px v-bind("theme.primaryColor");
font-weight: 600;
.chips-contest .chip.tried {
color: v-bind("theme.errorColorPressed");
background-color: rgba(208, 48, 80, 0.12);
border-color: transparent;
}
.chip.current:not(.done) {
color: v-bind("theme.primaryColorPressed");
background-color: v-bind("theme.cardColor");
border: 1.5px solid v-bind("theme.primaryColor");
font-weight: 700;
}
.chips-contest .chip.current:not(.done) {
color: v-bind("theme.warningColorPressed");
border-color: v-bind("theme.warningColor");
}
/* 当前题做完了:实心绿,外面再套一圈(中间隔一道底色) */
.chip.done.current {
border-color: v-bind("theme.successColor");
font-weight: 700;
border-color: v-bind("theme.primaryColorPressed");
box-shadow:
0 0 0 2px v-bind("theme.bodyColor"),
0 0 0 3px v-bind("theme.successColor");
0 0 0 2px v-bind("theme.cardColor"),
0 0 0 3.5px v-bind("theme.primaryColorPressed");
}
.chip:hover {
@@ -1,17 +1,16 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
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 { copyToClipboard, getTagColor } from "utils/functions"
import { useSubmissionStore } from "oj/store/submission"
import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import SQLDataTable from "./SQLDataTable.vue"
import SimilarProblems from "./SimilarProblems.vue"
import { useFlowchartStore } from "oj/store/flowchart"
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useUserStore } from "shared/store/user"
import { useProblemPageContext } from "../composables/problemPageContext"
@@ -20,6 +19,7 @@ const ProblemFlowchart = defineAsyncComponent(() => import("./ProblemFlowchart.v
const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue"))
const isDark = useDark()
const theme = useThemeVars()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const ctx = useProblemPageContext()
@@ -46,16 +46,14 @@ const showSimilar = computed(
* 通过」),占掉一屏里最值钱的那几行。题单里不给(入口差异表)。
*/
const myStatus = computed(() => {
if (!ctx.value.statusTag || !userStore.isAuthed) return null
const status = problem.value?.myStatus
if (!ctx.value.statusTag || status === undefined || status === null) return null
// 设计稿:没交过也写一个「还没交过」,状态这一格始终在
if (status === undefined || status === null) return "none"
return status === 0 ? "solved" : "tried"
})
/** 「限时 1 秒」:学生不需要 ms / MB。1000 → 1,1500 → 1.5 */
const timeLimitText = computed(() => {
const ms = problem.value?.timeLimit ?? 0
return `限时 ${Number((ms / 1000).toFixed(2))} 秒`
})
// 不写「限时 1 秒 / 内存 256MB」:中职学生看了只会犯迷糊,超时、超内存判出来时结果页签里自有说明
// ==================== 流程图小节 ====================
// 同一个位置二选一:老师给的参考图(showFlowchart,默认折叠),或者学生自己画到 A/S 的
@@ -68,6 +66,38 @@ const myFlowchartStore = useMyFlowchartStore()
const flowchartStore = useFlowchartStore()
const userStore = useUserStore()
const myFlowchartZoom = ref(false)
/** 参考流程图默认折叠;自己画的那张默认展开、可以收起(设计稿「流程图 A 级之后」) */
const referenceOpen = ref(false)
const myFlowchartOpen = ref(true)
watch(
() => problem.value?._id,
() => {
referenceOpen.value = false
myFlowchartOpen.value = true
},
)
/** 流程图画到 A / S 了:标题下给个「流程图 A 级」 */
const flowchartGrade = computed(() => {
const passed = flowchartStore.scores.filter((row) => isFlowchartPass(row.grade))
if (!passed.length) return ""
return passed.some((row) => row.grade === "S") ? "S" : "A"
})
// 例子旁的「复制」:点完变成「已复制」一秒
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:手机上画不了,但以前在电脑上画好的那张照样该摆出来
@@ -104,11 +134,11 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
<template>
<div v-if="problem">
<header class="problem-head">
<n-flex align="center" :size="10" :wrap="false">
<n-tag :bordered="false">{{ problem._id }}</n-tag>
<h2 class="problemTitle">{{ problem.title }}</h2>
</n-flex>
<n-flex align="center" :size="8" class="problem-meta">
<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"
@@ -117,88 +147,115 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
>
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false" round>
✓ 已解决
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false">
已解决
</n-tag>
<n-tag v-else-if="myStatus === 'tried'" size="small" type="warning" :bordered="false" round>
! 尝试过,还没通过
<n-tag v-else-if="myStatus === 'tried'" size="small" type="warning" :bordered="false">
做过,还没对
</n-tag>
<n-text depth="3" v-if="!isSQL">{{ timeLimitText }}</n-text>
</n-flex>
<n-tag v-else-if="myStatus === 'none'" size="small" :bordered="false">还没交过</n-tag>
<n-tag v-if="flowchartGrade" size="small" type="success" :bordered="false">
流程图 {{ flowchartGrade }} 级
</n-tag>
</div>
</header>
<!-- 代码要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 -->
<div v-if="astRequirements.length > 0" class="requirements">
<n-flex align="center" :size="6" class="requirements-head">
<Icon icon="streamline-ultimate-color:check-button" :width="18"></Icon>
<span>代码要求</span>
<n-text depth="3" class="requirements-note">提交时会逐条检查</n-text>
</n-flex>
<div v-for="[lang, rules] in astRequirements" :key="lang" class="requirements-lang">
<span class="requirements-head">代码要求</span>
<template v-for="[lang, rules] in astRequirements" :key="lang">
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span>
<n-flex :size="6">
<n-tag v-for="(rule, i) in rules" :key="i" :type="KIND_TAG_TYPE[rule.kind]" size="small">
{{ rule.description }}
</n-tag>
</n-flex>
</div>
<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>
</template>
<span class="requirements-note">提交时会逐条检查</span>
</div>
<h3 class="title">
<Icon icon="streamline-ultimate-color:checklist"></Icon>
描述
</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.description"
:theme="isDark ? 'dark' : 'light'"
/>
<template v-if="!isSQL">
<h3 class="title">
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
输入
</h3>
<section class="block">
<h3 class="title">描述</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.inputDescription"
:model-value="problem.description"
:theme="isDark ? 'dark' : 'light'"
/>
</section>
<h3 class="title">
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
输出
</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.outputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
<!-- 输入、输出一般都只有一两句,左右摆开省半屏 -->
<div v-if="!isSQL" class="io">
<section class="block">
<h3 class="title">输入</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.inputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</section>
<section class="block">
<h3 class="title">输出</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.outputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</section>
</div>
<n-collapse v-if="canShowReferenceFlowchart" class="flowchart-section">
<n-collapse-item name="reference">
<template #header>
<span class="title collapse-title">
<Icon icon="vscode-icons:file-type-drawio"></Icon>
参考流程图
</span>
</template>
<!-- 老师给的参考流程图:一行可以点开的条,默认折叠 -->
<section v-if="canShowReferenceFlowchart" class="block">
<button
type="button"
class="fold"
:aria-expanded="referenceOpen"
@click="referenceOpen = !referenceOpen"
>
<svg
class="fold-caret"
:class="{ open: referenceOpen }"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 6l6 6-6 6" />
</svg>
<span class="fold-title">老师给的参考流程图</span>
<span class="fold-note">{{ referenceOpen ? "收起" : "点开看" }}</span>
</button>
<div v-if="referenceOpen" class="fold-body">
<ProblemFlowchart />
</n-collapse-item>
</n-collapse>
</div>
</section>
<section v-else-if="myFlowchartStore.showing" class="flowchart-section">
<n-flex align="center" justify="space-between">
<h3 class="title">
<Icon icon="vscode-icons:file-type-drawio"></Icon>
你画的流程图
</h3>
<!-- 自己画到 A/S 的那张:默认展开一小块,可以放大、可以收起 -->
<section v-else-if="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-flex>
<div class="my-flowchart" role="button" @click="myFlowchartZoom = true">
<n-button text size="small" @click="myFlowchartOpen = !myFlowchartOpen">
{{ myFlowchartOpen ? "收起" : "展开" }}
</n-button>
</div>
<div
v-if="myFlowchartOpen"
class="my-flowchart"
role="button"
aria-label="放大看你画的流程图"
@click="myFlowchartZoom = true"
>
<MyFlowchart compact />
</div>
<n-modal
@@ -216,47 +273,48 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
例子是一张表:例子 N | 输入 | 输出。原来每个例子各占一个小标题加两个框,三个例子
就占掉大半屏;Python 的例子大多一两行,摆成表一眼能对上「这个输入 → 这个输出」
-->
<section v-if="samples.length" class="sample">
<n-flex align="center" justify="space-between" class="sample-head">
<h3 class="title">
<Icon icon="streamline-emojis:microscope"></Icon>
例子
</h3>
<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>
</n-flex>
</div>
<div class="sample-table" role="table" aria-label="例子">
<div class="sample-row sample-header" role="row">
<span role="columnheader"></span>
<span role="columnheader">输入</span>
<span role="columnheader">输出</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>
<div class="sample-cell" role="cell">
<pre class="testcase">{{ sample.input }}</pre>
<span class="sample-copy"><Copy :value="sample.input" /></span>
<span role="columnheader"></span>
<span role="columnheader" class="sample-label">输入</span>
<span role="columnheader" class="sample-label">输出</span>
<template v-for="(sample, index) of samples" :key="index">
<span class="sample-no" role="rowheader">例子 {{ index + 1 }}</span>
<div
v-for="side in ['input', 'output'] as const"
:key="side"
class="sample-cell"
role="cell"
>
<pre class="testcase">{{ sample[side] }}</pre>
<button
type="button"
class="sample-copy"
:aria-label="`复制例子 ${index + 1} 的${side === 'input' ? '输入' : '输出'}`"
@click="copySample(`${index}-${side}`, sample[side])"
>
{{ copiedKey === `${index}-${side}` ? "已复制" : "复制" }}
</button>
</div>
<div class="sample-cell" role="cell">
<pre class="testcase">{{ sample.output }}</pre>
<span class="sample-copy"><Copy :value="sample.output" /></span>
</div>
</div>
</template>
</div>
</section>
</template>
<template v-if="isSQL && sqlDisplay">
<h3 class="title">
<Icon icon="devicon:sqlite"></Icon>
数据表
</h3>
<h3 class="title sql-title">数据表</h3>
<div v-for="t in sqlDisplay.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
@@ -267,10 +325,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
/>
</div>
<h3 class="title">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
期望结果
</h3>
<h3 class="title sql-title">期望结果</h3>
<template v-if="sqlExpectedQuery">
<SQLDataTable
:columns="sqlExpectedQuery.columns"
@@ -294,35 +349,30 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</div>
</template>
<div v-if="problem.hint">
<h3 class="title">
<Icon icon="streamline-emojis:man-tipping-hand-1"></Icon>
提示
</h3>
<section v-if="problem.hint" class="block">
<h3 class="title">提示</h3>
<MdPreview
preview-theme="preview"
:model-value="problem.hint"
:theme="isDark ? 'dark' : 'light'"
/>
</div>
</section>
<div v-if="problem.source">
<h3 class="title">
<Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon>
来源
</h3>
<section v-if="problem.source" class="block">
<h3 class="title">来源</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.source"
:theme="isDark ? 'dark' : 'light'"
/>
</div>
</section>
<SimilarProblems v-if="showSimilar" title="相似题目推荐" />
</div>
</template>
<style scoped>
/* 设计稿「题目页签:还没运行过」 */
.problem-head {
display: flex;
flex-direction: column;
@@ -330,26 +380,64 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
margin-bottom: 12px;
}
.problemTitle {
.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;
}
.problem-meta {
font-size: 13px;
}
.title {
.meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
margin: 20px 0 4px;
}
/* md-editor 的预览自带一圈 padding 和段落外边距,放在小节标题下面显得很散 */
.block {
margin-top: 14px;
}
.block-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.block-head .title {
margin: 0;
}
.block-link {
margin-left: auto;
}
.title {
font-size: 15px;
font-weight: 600;
margin: 0 0 4px;
}
/* md-editor 的预览自带一圈 padding、白底和段落外边距,放在小节标题下面显得很散 */
:deep(.md-editor) {
background: transparent;
}
:deep(.md-editor-preview-wrapper) {
padding: 0;
}
@@ -362,14 +450,23 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
margin-bottom: 0;
}
.io {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
/* 代码要求:一行的浅绿框,硬性的判定条件,放在最前面 */
.requirements {
padding: 10px 12px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid rgba(24, 160, 88, 0.3);
background-color: rgba(24, 160, 88, 0.06);
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
font-size: 13px;
}
.requirements-head {
@@ -377,103 +474,55 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
}
.requirements-note {
font-size: 12px;
font-weight: normal;
}
.requirements-lang {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
color: v-bind("theme.textColor3");
}
.lang-label {
font-weight: 600;
font-size: 13px;
}
.sample {
margin-top: 20px;
}
.sample-head {
margin-bottom: 8px;
}
.sample-head .title {
margin: 0;
}
.sample-table {
display: flex;
flex-direction: column;
border: 1px solid rgba(128, 128, 128, 0.2);
/* 参考流程图:一行可以点开的条 */
.fold {
width: 100%;
height: 38px;
box-sizing: border-box;
padding: 0 12px;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
overflow: hidden;
}
.sample-row {
display: grid;
grid-template-columns: 2.2em minmax(0, 1fr) minmax(0, 1fr);
}
.sample-row + .sample-row {
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
.sample-header {
font-size: 12px;
opacity: 0.7;
background-color: rgba(128, 128, 128, 0.06);
}
.sample-header > span {
padding: 4px 10px;
}
.sample-no {
background-color: rgba(128, 128, 128, 0.04);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.fold:hover {
border-color: v-bind("theme.borderColor");
}
.fold-caret {
flex: none;
transition: transform 0.15s;
}
.fold-caret.open {
transform: rotate(90deg);
}
.fold-title {
font-weight: 600;
}
.fold-note {
font-size: 13px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
background-color: rgba(128, 128, 128, 0.06);
color: v-bind("theme.textColor3");
}
.sample-cell {
position: relative;
min-width: 0;
padding: 6px 30px 6px 10px;
}
.sample-cell + .sample-cell {
border-left: 1px solid rgba(128, 128, 128, 0.2);
}
/* Copy 的根是 n-tooltip,class 挂不上去,所以外面包一层 span 来定位 */
.sample-copy {
position: absolute;
top: 5px;
right: 6px;
display: flex;
}
.testcase {
margin: 0;
font-size: 14px;
white-space: pre;
overflow-x: auto;
font-family: Monaco, Consolas, monospace;
}
.flowchart-section {
margin-top: 20px;
}
.collapse-title {
margin: 0;
.fold-body {
margin-top: 8px;
}
.my-flowchart {
@@ -483,6 +532,67 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
padding: 8px;
}
/* 例子:例子 N | 输入 | 输出,每格右边一个「复制」 */
.sample-table {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1fr);
gap: 6px 8px;
align-items: stretch;
}
.sample-label,
.sample-no {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.sample-no {
display: flex;
align-items: center;
}
.sample-cell {
min-width: 0;
display: flex;
align-items: flex-start;
padding: 2px 4px 2px 10px;
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-family: Consolas, Monaco, monospace;
font-size: 14px;
white-space: pre;
overflow-x: auto;
}
.sample-copy {
flex: none;
height: 24px;
margin-top: 2px;
padding: 0 6px;
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;
}
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
@@ -11,7 +12,8 @@ import { useTeacherCollab } from "../composables/teacherCollab"
/**
* 统计 / 点评 / 我的提交。原来是左栏的三个页签,课上几乎没人点,却和「题目」挤在一排;
* 现在收进抽屉,盖住左栏、编辑器不动。
* 现在收进抽屉:盖住整个左栏(连上下文条、页签行),编辑器不动;抽屉顶上有自己的页签,
* 三样之间直接切(设计稿「我的提交 / 统计 / 点评:抽屉盖住左栏」)。
*
* 抽屉里的内容关上就卸载:再打开时「我的提交」要重新拉,刚交的那次才看得见。
*/
@@ -23,9 +25,21 @@ const open = defineModel<ProblemDrawer | null>({ required: true })
const props = defineProps<{
/** 桌面:挂进左栏(它得是 position: relative);手机:不传,从底下拉起来盖住整屏 */
to?: string
to?: HTMLElement | null
}>()
/** 抽屉顶上的页签,顺序照设计稿:最常看的「我的提交」在前 */
const tabs = computed(() =>
(["submission", "info", "reaction"] as const).filter((key) => ctx.value.drawers.includes(key)),
)
const theme = useThemeVars()
// trap-focus 关着(编辑器还要能点),焦点不在抽屉里时 Naive 自己的 Esc 不生效,这里补一个
useEventListener(document, "keydown", (event: KeyboardEvent) => {
if (event.key === "Escape" && open.value) open.value = null
})
const ctx = useProblemPageContext()
const route = useRoute()
const router = useRouter()
@@ -54,13 +68,8 @@ watch(
},
)
// 协作中的老师看的是学生的提交(ProblemSubmission 里按学生用户名查)
// 协作中的老师看的是学生的提交(ProblemSubmission 里按学生用户名查),页签写「他的提交」
const teacherCollab = useTeacherCollab()
const drawerTitle = computed(() =>
lastOpened.value === "submission" && teacherCollab.value
? "他的提交"
: DRAWER_TITLE[lastOpened.value],
)
/**
* 「看这道题所有人的提交」,原来是工具栏上的「本题提交」。可见条件照旧:
@@ -90,34 +99,118 @@ function goAllSubmissions() {
<template>
<n-drawer
v-model:show="show"
:to="props.to"
:to="props.to ?? undefined"
:placement="props.to ? 'left' : 'bottom'"
:width="props.to ? '100%' : undefined"
:height="props.to ? undefined : '85%'"
:trap-focus="false"
:block-scroll="!props.to"
>
<n-drawer-content :title="drawerTitle" closable :native-scrollbar="false">
<n-drawer-content
:native-scrollbar="false"
:header-style="{ padding: 0 }"
:body-content-style="{ padding: '16px 20px' }"
>
<template #header>
<div class="drawer-head">
<div class="drawer-tabs" role="tablist">
<button
v-for="key in tabs"
:key="key"
type="button"
role="tab"
class="drawer-tab"
:class="{ active: lastOpened === key }"
:aria-selected="lastOpened === key"
@click="open = key"
>
{{ key === "submission" && teacherCollab ? "他的提交" : DRAWER_TITLE[key] }}
</button>
</div>
<button type="button" class="drawer-close" aria-label="关闭" @click="open = null">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</template>
<ProblemInfo v-if="lastOpened === 'info'" />
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
<template v-else>
<ProblemSubmission />
<n-button
v-if="showAllSubmissions"
class="all-submissions"
text
type="primary"
@click="goAllSubmissions"
>
看这道题所有人的提交 ›
</n-button>
<div v-if="showAllSubmissions" class="all-submissions">
<n-button text type="primary" @click="goAllSubmissions">看这道题所有人的提交 ›</n-button>
</div>
</template>
</n-drawer-content>
</n-drawer>
</template>
<style scoped>
.drawer-head {
height: 48px;
box-sizing: border-box;
padding: 0 12px 0 20px;
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.drawer-tabs {
display: flex;
height: 100%;
}
.drawer-tab {
height: 48px;
padding: 0 12px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
font: inherit;
font-size: 14px;
font-weight: normal;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.drawer-tab.active {
border-bottom-color: v-bind("theme.primaryColor");
color: v-bind("theme.textColor1");
font-weight: 600;
}
.drawer-close {
margin-left: auto;
width: 36px;
height: 36px;
border: 0;
border-radius: 4px;
background: transparent;
color: v-bind("theme.textColor2");
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.drawer-close:hover {
background-color: v-bind("theme.hoverColor");
}
.all-submissions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
font-size: 13px;
}
</style>
@@ -91,9 +91,11 @@ const contestID = route.params.contestID || null
// 草稿存在哪个键下(题单入口单独一份),和「重置代码」共用一份算法
const storageKey = useDraftKey()
// 桌面:顶栏 60 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。
// 手机:顶栏两行 + 课堂条 + 页签 + 工具栏约 236px,底部还有「运行例子 / 提交」那条约 64px
const editorHeight = computed(() => {
const base = isDesktop.value ? 133 : 300
return `calc(100vh - ${base + (teacherCollab.value ? 52 : 0)}px)`
const base = isDesktop.value ? 108 : 300
return `calc(100vh - ${base + (teacherCollab.value ? 40 : 0)}px)`
})
function loadCode() {
@@ -160,7 +162,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
</script>
<template>
<n-flex vertical>
<div class="problem-editor">
<CollabBar v-if="teacherCollab" />
<EditorToolbar @change-language="changeLanguage" />
<!--
@@ -180,5 +182,5 @@ provide("flowchartEditorRef", flowchartEditorRef)
:extra-extensions="editorExtensions"
@update:model-value="changeCode"
/>
</n-flex>
</div>
</template>
@@ -1,12 +1,12 @@
<script lang="ts" setup>
import { useThemeVars } from "naive-ui"
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import Pagination from "shared/components/Pagination.vue"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
import { copyToClipboard, parseTime } from "utils/functions"
import type { ProblemRank } from "@oj2/contract"
import type { LANGUAGE, SubmissionListItem } from "utils/types"
@@ -29,6 +29,7 @@ const ctx = useProblemPageContext()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const theme = useThemeVars()
const problemDisplayId = computed(() => String(route.params.problemID ?? ""))
@@ -136,6 +137,23 @@ async function toggle(row: SubmissionListItem) {
}
}
/** 这一页里被题单闸门藏起来的次数 */
const lockedCount = computed(() => submissions.value.filter((row) => !row.showLink).length)
/** 展开的代码先只露 5 行,「一共 11 行」点一下再全摆出来(设计稿) */
const PREVIEW_LINES = 5
const fullCode = reactive<Record<string, boolean>>({})
function preview(id: string) {
const code = loadedOf(id)?.code ?? ""
const lines = code.replace(/\s+$/, "").split("\n")
const more = !fullCode[id] && lines.length > PREVIEW_LINES + 1
return {
text: more ? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n` : code,
more,
total: lines.length,
}
}
function loadedOf(id: string): Loaded | null {
const value = details[id]
return value && typeof value === "object" ? value : null
@@ -208,63 +226,102 @@ watch(query, () => {
:description="peer ? '他还没有交过这道题' : '这道题你还没有交过'"
/>
<ul v-else class="list">
<li v-for="(row, index) in submissions" :key="row.id" class="item">
<button
type="button"
class="row"
:class="{ open: expanded === row.id, locked: !row.showLink }"
:disabled="!row.showLink"
:aria-expanded="row.showLink ? expanded === row.id : undefined"
@click="toggle(row)"
>
<span class="attempt">第 {{ attemptNo(index) }} 次</span>
<SubmissionResultTag :result="row.result" />
<span class="meta">{{ LANGUAGE_SHOW_VALUE[row.language] }}</span>
<span class="meta time">{{ parseTime(row.createTime, "MM-DD HH:mm") }}</span>
<span v-if="row.showLink" class="caret" aria-hidden="true">
{{ expanded === row.id ? "▾" : "▸" }}
</span>
</button>
<!-- 设计稿「我的提交 / 统计 / 点评:抽屉盖住左栏」:一整块带框的列表,行间一条细线 -->
<div v-else-if="submissions.length" class="list">
<template v-for="(row, index) in submissions" :key="row.id">
<div v-if="row.showLink" class="item" :class="{ open: expanded === row.id }">
<button
type="button"
class="row"
:aria-expanded="expanded === row.id"
@click="toggle(row)"
>
<span class="attempt">第 {{ attemptNo(index) }} 次</span>
<span class="verdict" :class="JUDGE_STATUS[row.result]?.type">
{{ JUDGE_STATUS[row.result]?.name ?? "未知" }}
</span>
<span class="meta">
{{ LANGUAGE_SHOW_VALUE[row.language] }} ·
{{ parseTime(row.createTime, "MM-DD HH:mm") }}
</span>
<svg
class="caret"
:class="{ up: expanded === row.id }"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<p v-if="!row.showLink" class="locked-note">
🔒 这是加入题单之前交的,先藏起来了:在题单里做出这道题就解锁,题单过了截止时间也会解锁
</p>
<div v-else-if="expanded === row.id" class="detail">
<n-flex v-if="details[row.id] === 'loading'" justify="center" class="pad">
<n-spin size="small" />
</n-flex>
<n-text v-else-if="details[row.id] === 'failed'" type="error">
代码读不出来,收起来再点开试试
</n-text>
<template v-else-if="loadedOf(row.id)">
<pre class="code">{{ loadedOf(row.id)!.code }}</pre>
<n-flex :size="8" align="center">
<n-button
v-if="!teacherCollab"
size="small"
type="primary"
secondary
:disabled="!canPutBack(row.id)"
:title="
canPutBack(row.id)
? undefined
: `这道题现在不收 ${LANGUAGE_SHOW_VALUE[loadedOf(row.id)!.language]} 了,只能复制`
"
@click="putBack(row.id)"
>
放回编辑器
</n-button>
<n-button size="small" @click="copy(row.id)">复制</n-button>
<n-button size="small" text class="more" @click="openDetail(row.id)">
完整详情 ›
</n-button>
<div v-if="expanded === row.id" class="detail">
<n-flex v-if="details[row.id] === 'loading'" justify="center" class="pad">
<n-spin size="small" />
</n-flex>
</template>
<n-text v-else-if="details[row.id] === 'failed'" type="error">
代码读不出来,收起来再点开试试
</n-text>
<template v-else-if="loadedOf(row.id)">
<pre class="code">{{ preview(row.id).text
}}<button
v-if="preview(row.id).more"
type="button"
class="code-more"
@click="fullCode[row.id] = true"
>…… 一共 {{ preview(row.id).total }} 行,点开看全部</button></pre>
<div class="detail-actions">
<n-button
v-if="!teacherCollab"
size="small"
type="primary"
ghost
:disabled="!canPutBack(row.id)"
:title="
canPutBack(row.id)
? undefined
: `这道题现在不收 ${LANGUAGE_SHOW_VALUE[loadedOf(row.id)!.language]} 了,只能复制`
"
@click="putBack(row.id)"
>
放回编辑器
</n-button>
<n-button size="small" @click="copy(row.id)">复制</n-button>
<n-button size="small" text class="more" @click="openDetail(row.id)">
完整详情 ›
</n-button>
</div>
</template>
</div>
</div>
</li>
</ul>
</template>
<!-- 加入题单之前交的那几次:合成一行,点不开(后端也不给代码) -->
<div v-if="lockedCount" class="locked">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 018 0v3" />
</svg>
<span>
加入题单之前的
{{ lockedCount }} 次提交先藏起来了,在题单里做出这道题就能看(题单过了截止时间也会解锁)
</span>
</div>
</div>
<Pagination
v-if="total > query.limit"
@@ -297,78 +354,86 @@ watch(query, () => {
}
.list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
border: 1px solid rgba(128, 128, 128, 0.2);
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
overflow: hidden;
}
.item + .item,
.item + .locked {
border-top: 1px solid v-bind("theme.dividerColor");
}
.row {
width: 100%;
height: 44px;
box-sizing: border-box;
padding: 0 14px;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
gap: 12px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
}
.row:hover:not(:disabled),
.row.open {
background-color: rgba(128, 128, 128, 0.08);
}
.row.locked {
cursor: default;
opacity: 0.7;
.row:hover,
.item.open .row {
background-color: rgba(128, 128, 128, 0.06);
}
.attempt {
flex: none;
min-width: 4.5em;
font-weight: 600;
width: 4em;
font-size: 13px;
color: v-bind("theme.textColor2");
font-variant-numeric: tabular-nums;
}
.meta {
.verdict {
flex: none;
opacity: 0.7;
min-width: 5em;
}
.time {
margin-left: auto;
.verdict.success {
color: v-bind("theme.successColorPressed");
font-weight: 600;
}
.verdict.error {
color: v-bind("theme.errorColorPressed");
}
.verdict.warning {
color: v-bind("theme.warningColorPressed");
}
.meta {
font-size: 13px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.caret {
margin-left: auto;
flex: none;
width: 1em;
opacity: 0.6;
color: v-bind("theme.textColor3");
transition: transform 0.15s;
}
.locked-note {
margin: 0;
padding: 0 12px 8px;
font-size: 13px;
opacity: 0.7;
.caret.up {
transform: rotate(180deg);
}
.detail {
padding: 4px 12px 12px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.pad {
@@ -376,18 +441,48 @@ watch(query, () => {
}
.code {
margin: 8px 0 10px;
padding: 10px 12px;
max-height: 320px;
margin: 0;
padding: 8px 10px;
max-height: 360px;
overflow: auto;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
font-family: Monaco, Consolas, monospace;
font-size: 14px;
line-height: 1.5;
border-radius: 4px;
background-color: rgba(128, 128, 128, 0.06);
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 20px;
white-space: pre;
}
.code-more {
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.code-more:hover {
color: v-bind("theme.primaryColor");
}
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
}
.locked {
height: 44px;
box-sizing: border-box;
padding: 0 14px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.more {
margin-left: auto;
}
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
/**
* 结果页签里每一块结果的标题:圆形状态图标 + 大字 + 灰色小字,下面可以挂一条测试点进度。
* 设计稿「答案错误」「答案正确」「运行例子」「语法没过」几张画板都是这一个样子:
* 不套整块的彩色提示框,颜色只落在图标和大字上。
*/
const props = defineProps<{
kind: "success" | "error" | "warning" | "info" | "pending"
title: string
sub?: string
/** 测试点进度:通过几个、一共几个 */
progress?: { passed: number; total: number } | null
}>()
const theme = useThemeVars()
const color = computed(() => {
switch (props.kind) {
case "success":
return { icon: theme.value.successColor, text: theme.value.successColorPressed }
case "error":
return { icon: theme.value.errorColor, text: theme.value.errorColorPressed }
case "warning":
return { icon: theme.value.warningColor, text: theme.value.warningColorPressed }
default:
return { icon: theme.value.infoColor, text: theme.value.textColor1 }
}
})
</script>
<template>
<div class="result-header">
<div class="line">
<span class="badge" :class="kind" aria-hidden="true">
<svg
v-if="kind === 'success'"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12l5 5 9-10" />
</svg>
<svg
v-else-if="kind === 'error'"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
<svg
v-else-if="kind === 'pending'"
class="spin"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<svg
v-else
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
>
<path d="M12 6v7" />
<path d="M12 18h.01" />
</svg>
</span>
<span class="title">{{ title }}</span>
<span v-if="sub" class="sub">{{ sub }}</span>
<slot name="extra" />
</div>
<div
v-if="progress && progress.total > 0"
class="progress"
role="img"
:aria-label="`通过 ${progress.passed}/${progress.total} 个测试点`"
>
<div
class="progress-fill"
:style="{ width: `${(progress.passed / progress.total) * 100}%` }"
/>
</div>
</div>
</template>
<style scoped>
.result-header {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
}
.badge {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background-color: v-bind("color.icon");
}
.title {
font-size: 18px;
font-weight: 700;
color: v-bind("color.text");
}
.sub {
color: v-bind("theme.textColor3");
}
.progress {
height: 6px;
border-radius: 3px;
background-color: rgba(208, 48, 80, 0.18);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
background-color: v-bind("theme.successColor");
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -4,6 +4,9 @@ import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import { useCodeStore } from "oj/store/code"
import { useTeacherCollab } from "../composables/teacherCollab"
import { useThemeVars } from "naive-ui"
import { parseTime } from "utils/functions"
import ResultHeader from "./ResultHeader.vue"
/**
* 左栏「结果」页签:三段 —— 提交结果 / 运行例子 / 自己输入。
@@ -31,6 +34,28 @@ const codeStore = useCodeStore()
/** 协作中的老师自己还没交过:先摆学生最近一次交的 */
const teacherCollab = useTeacherCollab()
const theme = useThemeVars()
const { samplesRunAt } = submissionStore.trial
type Segment = "submit" | "samples" | "custom"
const segments = computed<{ value: Segment; label: string; time: string }[]>(() => [
{
value: "submit",
// 语法没过就没交上去:那一段不叫「提交结果」
label: syntaxErrorInfo.value ? "交之前检查" : "提交结果",
time:
!syntaxErrorInfo.value && submission.value?.createTime
? parseTime(submission.value.createTime, "HH:mm")
: "",
},
{
value: "samples",
label: "运行例子",
time: samplesRunAt.value ? parseTime(samplesRunAt.value, "HH:mm") : "",
},
{ value: "custom", label: "自己输入", time: "" },
])
/** 正在画流程图:结果就是 AI 的点评,没有例子可跑 */
const drawing = computed(() => codeStore.code.language === "Flowchart")
@@ -44,21 +69,28 @@ const canTrial = computed(() => {
<template>
<FlowchartResult v-if="drawing" />
<div v-show="!drawing" class="result-pane">
<n-radio-group v-if="canTrial" v-model:value="resultSegment" size="small" class="segments">
<!-- 语法没过就没交上去:那一段不叫「提交结果」 -->
<n-radio-button value="submit">{{
syntaxErrorInfo ? "交之前检查" : "提交结果"
}}</n-radio-button>
<n-radio-button value="samples">运行例子</n-radio-button>
<n-radio-button value="custom">自己输入</n-radio-button>
</n-radio-group>
<!-- 设计稿:三个胶囊,选中的深色实心,带上那次结果的时间 -->
<div v-if="canTrial" class="segments" role="tablist" aria-label="结果">
<button
v-for="segment in segments"
:key="segment.value"
type="button"
role="tab"
class="segment"
:class="{ active: resultSegment === segment.value }"
:aria-selected="resultSegment === segment.value"
@click="resultSegment = segment.value"
>
{{ segment.label }}<template v-if="segment.time"> · {{ segment.time }}</template>
</button>
</div>
<!-- 提交结果用 v-show:切去看运行例子时不卸载,挂着的错误说明在编辑器里标着红 -->
<div v-show="resultSegment === 'submit' || !canTrial">
<n-flex v-if="syntaxErrorInfo" vertical>
<n-alert type="warning" title="代码有语法错误,还没有交上去" />
<template v-if="syntaxErrorInfo">
<ResultHeader kind="warning" title="代码有语法错误,还没有交上去" />
<PythonErrorExplain :err-info="syntaxErrorInfo" />
</n-flex>
</template>
<template v-else-if="submission">
<p v-if="formattedBeforeSubmit" class="formatted">
提交之前自动整理了代码格式(缩进、空格),编辑器里的代码也跟着变了,按 Ctrl+Z 能撤回
@@ -69,7 +101,7 @@ const canTrial = computed(() => {
<n-empty
v-else
class="empty"
description="还没有提交过。写完代码按「提交代码」,结果会出现在这里"
description="还没有提交过。写完代码按「提交」,结果会出现在这里"
/>
</div>
<template v-if="canTrial">
@@ -81,13 +113,33 @@ const canTrial = computed(() => {
<style scoped>
.result-pane {
padding: 4px 0 16px;
padding-bottom: 16px;
}
.segments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.segment {
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 15px;
background-color: rgba(128, 128, 128, 0.12);
color: v-bind("theme.textColor2");
font: inherit;
font-size: 13px;
cursor: pointer;
}
.segment.active {
background-color: v-bind("theme.textColor1");
color: v-bind("theme.cardColor");
}
.formatted {
margin: 0 0 10px;
font-size: 13px;
@@ -8,7 +8,6 @@ import {
submissionCaseResults,
submissionMemoryFormat,
submissionPartialCases,
submissionResultTitle,
submissionTimeFormat,
} from "utils/functions"
import type { Submission } from "utils/types"
@@ -19,6 +18,7 @@ import PythonErrorExplain from "./PythonErrorExplain.vue"
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
import WrongAnswerExplain from "./WrongAnswerExplain.vue"
import LessonNext from "./LessonNext.vue"
import ResultHeader from "./ResultHeader.vue"
import SimilarProblems from "./SimilarProblems.vue"
import { useLessonStore } from "oj/store/lesson"
import { useProblemPageContext } from "../composables/problemPageContext"
@@ -151,34 +151,70 @@ const failedOnSample = computed(() =>
)
/**
* 结果标题(设计文档第 6 节)。比「答案错误 · 通过 1/3 个测试点」多走一步:
* 例子上就错了的说「在例子 N 上就错了」,例子都对的才报测试点;做对了说全对了。
* 结果标题(设计文档第 6 节、设计稿「答案错误 / 答案正确 / 错在隐藏测试点」):
* 大字是判题结果,灰色小字说清楚错在哪 —— 例子上就错了的说「在例子 N 上就错了」、不挂进度;
* 其余照旧报「通过 x/y 个测试点」(一个都没过就不报);做对了说全对了。
* 提交详情页还用通用的 submissionResultTitle
*/
const title = computed(() => {
const header = computed(() => {
const submission = props.submission
if (!submission) return ""
if (submission.result === SubmissionStatus.accepted) return "答案正确 · 所有测试点都对了"
if (!submission) return null
const status = JUDGE_STATUS[submission.result]
if (
submission.result === SubmissionStatus.pending ||
submission.result === SubmissionStatus.judging ||
submission.result === SubmissionStatus.submitting
) {
return { kind: "pending" as const, title: status.title, sub: "", progress: null }
}
if (submission.result === SubmissionStatus.accepted) {
return {
kind: "success" as const,
title: status.title,
sub: "所有测试点都对了",
progress: null,
}
}
if (failedOnSample.value) {
return `答案错误 · 在例子 ${(failedOnSample.value.index ?? 0) + 1} 上就错了`
return {
kind: status.type,
title: status.title,
sub: `在例子 ${(failedOnSample.value.index ?? 0) + 1} 上就错了`,
progress: null,
}
}
const cases = partialCases.value
return {
kind: status.type,
title: status.title,
sub: cases ? `通过 ${cases.passed}/${cases.total} 个测试点` : "",
progress: cases,
}
const cases = hiddenCases.value
if (cases) return `答案错误 · 通过 ${cases.passed}/${cases.total} 个测试点`
return submissionResultTitle(submission)
})
/**
* 例子都对了、错在隐藏的测试点上:一个都没过也要报「通过 0/5」—— 那正说明例子之外的全错了。
* 其余情况沿用 submissionPartialCases(一个都没过就不报)
* 例子都对了、错在隐藏测试点:「去『自己输入』拿 90、80 这些数试试」。数取题目描述里出现的、
* 例子里没有的头两个 —— 分段、判断一类题的边界多半就写在描述里(设计稿「错在隐藏测试点」)。
* SQL 题、比赛里没有「自己输入」
*/
const hiddenCases = computed(() => {
const summary = props.submission?.caseSummary
if (!sampleCheck.value?.passed || !summary || summary.passed >= summary.total) return null
return summary
const submissionStore = useSubmissionStore()
const customRunHint = computed(() => {
if (!sampleCheck.value?.passed || props.peer) return ""
const problem = problemStore.problem
if (!problem || problem.sqlConfig) return ""
// 例子里出现过的数不算:例子已经对了,拿它们再试一遍没用
const inSamples = new Set(
problem.samples.flatMap(
(sample) => `${sample.input} ${sample.output}`.match(/-?\d+(?:\.\d+)?/g) ?? [],
),
)
const numbers = [...new Set(problem.description.match(/-?\d+(?:\.\d+)?/g) ?? [])]
.filter((value) => !inSamples.has(value))
.slice(0, 2)
return numbers.length
? `去「自己输入」拿 ${numbers.join("、")} 这些数试试 ›`
: "去「自己输入」拿特殊的数试试 ›"
})
const progressCases = computed(
() => hiddenCases.value ?? (failedOnSample.value ? null : partialCases.value),
)
// 是否显示AI提示区域。
// 阈值和后端 POST /ai/hint 共用契约里的 HINT_MIN_FAILURES,别在这里写死数字;
@@ -249,16 +285,13 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
<template>
<div v-if="submission">
<n-alert :type="JUDGE_STATUS[submission.result]['type']" :title="title" class="mb-3">
<template v-if="progressCases" #default>
<n-progress
type="line"
status="success"
:percentage="(progressCases.passed / progressCases.total) * 100"
:show-indicator="false"
/>
</template>
</n-alert>
<ResultHeader
v-if="header"
:kind="header.kind"
:title="header.title"
:sub="header.sub"
:progress="header.progress"
/>
<LessonNext
v-if="showLessonNext && problemStore.problem"
:problem-display-id="problemStore.problem._id"
@@ -310,18 +343,33 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
</n-flex>
<!-- AI 提示区域 -->
<template v-if="showAIHint">
<n-card size="small" style="margin-top: 12px; max-width: 480px">
<n-alert v-if="hintError" type="error" :title="hintError" class="mb-3" />
<n-button
v-if="!hintTarget && !hintLoading"
type="primary"
@click="fetchHint(submission.id)"
>
让 AI 分析我的代码
</n-button>
<n-spin v-else-if="hintLoading && !hintTarget" size="small" />
<!--
设计稿:还没问过 AI 时只是一个描边按钮;错在隐藏测试点时旁边给「去自己输入试试」。
问过之后,AI 的回答才放进卡片
-->
<div v-if="(showAIHint && !hintTarget) || customRunHint" class="actions">
<n-button
v-if="showAIHint && !hintTarget"
type="primary"
ghost
:loading="hintLoading"
@click="fetchHint(submission.id)"
>
让 AI 分析我的代码
</n-button>
<n-button
v-if="customRunHint"
text
type="primary"
@click="submissionStore.revealResult('custom')"
>
{{ customRunHint }}
</n-button>
</div>
<n-alert v-if="showAIHint && hintError" type="error" :title="hintError" class="hint-error" />
<template v-if="showAIHint && hintTarget">
<n-card size="small" class="hint-card">
<n-spin v-if="hintLoading && !hintContent" size="small" />
<MdPreview
v-if="hintContent"
:model-value="hintContent"
@@ -379,6 +427,23 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
</template>
<style scoped>
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 12px;
}
.hint-error {
margin-top: 12px;
}
.hint-card {
margin-top: 12px;
max-width: 560px;
}
.msg {
white-space: pre;
word-break: break-all;
@@ -52,14 +52,17 @@ function submit() {
</script>
<template>
<!-- 刚交对:降成绿色描边,让结果页签里的「下一题」是唯一的主按钮(设计稿「答案正确」) -->
<n-button
:size="props.size ?? (isDesktop ? 'medium' : 'small')"
:block="props.block"
:type="justAccepted ? 'default' : 'primary'"
type="primary"
:ghost="justAccepted"
:disabled="buttonState.disabled"
class="submit"
@click="submit"
>
<template #icon>
<template v-if="buttonState.icon" #icon>
<n-icon>
<Icon :icon="buttonState.icon" />
</n-icon>
@@ -67,3 +70,10 @@ function submit() {
{{ buttonState.label }}
</n-button>
</template>
<style scoped>
.submit {
min-width: 72px;
font-weight: 600;
}
</style>
@@ -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>
@@ -23,7 +23,8 @@ export function getSubmitButtonState({
}: SubmitButtonStateInput): SubmitButtonState {
const disabled = !isAuthed || !hasCode || isFormatting || isSubmitting || isJudging || isCooldown
let label = "提交代码"
// 设计稿里就叫「提交」:它旁边是「运行例子」,一个试跑、一个交,不用再说「代码」
let label = "提交"
if (!isAuthed) {
label = "请先登录"
} else if (isFormatting) {
@@ -36,12 +37,8 @@ export function getSubmitButtonState({
label = "正在冷却"
}
const icon =
isFormatting || isSubmitting || isJudging
? "eos-icons:loading"
: isCooldown
? "ph:lightbulb-fill"
: "ph:play-fill"
// 平时不带图标,只在忙的时候转圈
const icon = isFormatting || isSubmitting || isJudging ? "eos-icons:loading" : ""
return { disabled, label, icon }
}
+121 -56
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { errorCode } from "utils/api"
import { getProblem } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -93,6 +94,9 @@ watch(
)
const drawer = ref<DrawerKey | null>(null)
// 抽屉挂进左栏:要等左栏这个元素挂上之后才能当 Teleport 的目标
const leftPane = useTemplateRef<HTMLElement>("leftPane")
const theme = useThemeVars()
// 协作中的老师看的是学生的提交
const teacherCollab = useTeacherCollab()
@@ -234,61 +238,70 @@ onBeforeUnmount(() => {
<template v-if="problem">
<SubmissionEffects />
<StatisticsModal />
<!--
桌面:两栏顶满整个内容区(设计稿「机房(方案 B)」),中间一条 1px 分隔线、可拖动。
布局给内容区留了 16px 内边距,这里用负边距抵掉
-->
<n-split
v-if="isDesktop"
class="problem-split"
direction="horizontal"
:default-size="0.43"
:min="0.2"
:max="0.8"
style="height: calc(100vh - 92px)"
:resize-trigger-size="1"
>
<template #1>
<div class="left-pane">
<div id="problem-left-pane" ref="leftPane" class="left-pane">
<ContextBar />
<div class="tab-row">
<n-tabs
v-model:value="currentTab"
type="segment"
class="page-tabs"
@click="drawer = null"
>
<n-tab name="content">题目</n-tab>
<n-tab name="result"><ResultTabLabel /></n-tab>
</n-tabs>
<n-flex v-if="ctx.drawers.length" :size="6" :wrap="false">
<n-button
<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 />
</button>
</div>
<div v-if="ctx.drawers.length" class="drawer-buttons">
<button
v-for="key in ctx.drawers"
:key="key"
size="small"
:type="drawer === key ? 'primary' : 'default'"
:secondary="drawer === key"
type="button"
class="drawer-button"
@click="toggleDrawer(key)"
>
{{ drawerLabel(key) }}
</n-button>
</n-flex>
</div>
<!--
抽屉挂在页签行下面这一块(position: relative),盖住题面、编辑器不动。
页签行露在外面:同一个按钮再点一下就关、点另一个就换,点「题目 / 结果」也会关
-->
<div id="problem-pane-stack" class="pane-stack">
<!--
两个页签各滚各的:读题读到底下切去看结果,结果不该也停在底下。
v-show 挂在外面这层 div 上 —— 直接挂在 n-scrollbar 上不生效,它自己管根节点的 style
-->
<div v-show="currentTab === 'content'" class="pane-body">
<n-scrollbar content-style="padding-top: 8px">
<ProblemContent />
</n-scrollbar>
</button>
</div>
<div v-if="resultMounted" v-show="currentTab === 'result'" class="pane-body">
<n-scrollbar content-style="padding-top: 8px">
<ResultPane />
</n-scrollbar>
</div>
<ProblemDrawer v-model="drawer" to="#problem-pane-stack" />
</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>
@@ -335,48 +348,100 @@ onBeforeUnmount(() => {
padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
/*
* 分隔条两边各留 12px:左栏页签行右端的「统计 / 点评 / 我的提交」和右栏工具栏最左的
* 「写代码 / 画流程图」都是描边按钮、又在同一高度,原来紧贴着分隔条,看上去连成一排
*/
.problem-split {
margin: -16px;
width: calc(100% + 32px);
height: calc(100vh - 60px);
}
/* 分隔线:看上去 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;
}
.left-pane {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding-right: 12px;
box-sizing: border-box;
}
.right-pane {
height: 100%;
padding-left: 12px;
box-sizing: border-box;
}
/* 「题目 / 结果」:下划线式页签,右边是抽屉的三个胶囊按钮 */
.tab-row {
flex: none;
height: 42px;
box-sizing: border-box;
padding: 0 20px 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 2px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.page-tabs {
width: 180px;
flex: none;
display: flex;
height: 100%;
}
.pane-stack {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
.page-tab {
height: 42px;
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: 4px;
}
.drawer-button {
height: 32px;
padding: 0 11px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 16px;
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.drawer-button:hover {
border-color: v-bind("theme.primaryColorHover");
color: v-bind("theme.primaryColor");
}
.pane-body {
height: 100%;
flex: 1;
min-height: 0;
}
</style>
@@ -0,0 +1,19 @@
/**
* 把题面里的一段 Markdown 摊成一行纯文字,放进结果页签里那种「题目原话:……」的小字里。
* 不是完整的 Markdown 解析:题面用到的就是强调、行内代码、链接、图片、标题、列表、
* 公式这几样,去掉记号、留下字就够了。太长的截断,免得一句提示撑满半屏。
*/
export function markdownToText(markdown: string, max = 120) {
const text = markdown
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/`([^`]*)`/g, "$1")
.replace(/\$\$?([^$]*)\$\$?/g, "$1")
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
.replace(/(\*\*|__|\*|_|~~)(.+?)\1/g, "$2")
.replace(/\s+/g, " ")
.trim()
return text.length > max ? `${text.slice(0, max)}…` : text
}