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