style(题目): 题目页照设计稿「机房(方案 B)」重排——顶满的两栏、带底色的上下文条、下划线页签、新的结果和题面样式
Deploy / deploy (push) Canceled after 0s
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:
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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user