Files
OJ2/apps/web/src/oj/problem/components/ContextBar.vue
T
xuyueandClaude Opus 5.5 5836902c6b style(题目): 运行例子、自己输入、Python 报错、流程图点评这几块结果也照设计稿重排,手机上题目页顶满屏幕
- 运行例子:标题带每个例子的对错胶囊,全对时提示「再按提交」
- 自己输入:说明「这里不判对错」,输出框留出固定高度
- Python 报错:灰底卡片,改法按钮放显眼处
- 流程图点评:画过几次连成一排,大分数 + 等级 + 四项分数条,重新提交后直接显示新的这一次
- 工具栏「写代码 / 画流程图」换成设计稿的分段切换,窄时缩成「代码 / 流程图」
- 设计文档记下和画板有意不同的几处(去掉限时、保留新轮盘等)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:13:23 +08:00

504 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { storeToRefs } from "pinia"
import type { RouteLocationRaw } from "vue-router"
import { getProblemSetDetail, getProblemSetProblems } from "oj/api"
import { useContestStore } from "oj/store/contest"
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"
/**
* 左栏顶上的一条:「这道题是从哪来的」。按入口路由决定是哪一种,最多一条:
*
* - 课堂:这道题在「班里在做」的列表里 —— 做完几道 + 每道的题号 + 上一题 / 下一题
* - 题单:题单名 + 进度 + 第几题 + 上一题 / 下一题
* - 比赛:比赛名 + 每道的题号 + 还剩多久 + 排名
* - 都不是:不显示
*
* 一道题既在这节课里、又是从题单进来的,按入口显示题单条。
* 换题用的都是普通链接,通过之后欠着的那次强制点评由 SubmissionEffects 的路由守卫拦下。
*/
/** 题多于这个数就横向滚动、去掉 ‹ ›:一节课一般 5 道以内,按 5 道设计 */
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 ?? "")
/**
* 刚在这一页上做出来(不是本来就做过):代码通过,或者流程图刚评到 A / S。
* 换题时 id 跟着变,那一次不算;流程图只认这一页上刚评完的(evaluatedSeq)
*/
const flowchartStore = useFlowchartStore()
function onSolvedHere(callback: () => void) {
watch(
() => [problem.value?.id, problem.value?.myStatus] as const,
([id, status], [previousId, previousStatus]) => {
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback()
},
)
watch(
() => flowchartStore.evaluatedSeq,
() => {
if (isFlowchartPass(flowchartStore.latestRating.grade)) callback()
},
)
}
// ==================== 课堂 ====================
const lessonStore = useLessonStore()
watch(
() => ctx.value.entry === "problem" && userStore.isAuthed,
(want) => {
// 接口要求登录;没登录就没有这一条
if (want) lessonStore.load()
},
{ immediate: true },
)
const lesson = computed(() => {
if (ctx.value.entry !== "problem" || !userStore.isAuthed) return null
const list = lessonStore.activity?.problems ?? []
const index = list.findIndex(
(item) => item.problemDisplayId.toLowerCase() === currentId.value.toLowerCase(),
)
if (index < 0) return null
const chips: ChipItem[] = list.map((item) => ({
displayId: item.problemDisplayId,
title: item.title,
status:
item.myStatus === "accepted"
? "done"
: item.myStatus === "tried"
? "tried"
: ("none" as const),
to: `/problem/${item.problemDisplayId}`,
}))
return {
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,
}
})
onSolvedHere(() => {
if (ctx.value.entry !== "problem" || !lessonStore.includes(currentId.value)) return
lessonStore.markAccepted(currentId.value)
lessonStore.load(true)
})
// ==================== 题单 ====================
const problemSet = ref<ProblemSet | null>(null)
const setProblems = ref<ProblemSetProblem[]>([])
watch(
() => ctx.value.problemSetId,
async (id) => {
problemSet.value = null
setProblems.value = []
if (!id) return
try {
const [detail, problems] = await Promise.all([
getProblemSetDetail(Number(id)),
getProblemSetProblems(Number(id)),
])
// 拉的这一会儿已经离开了这个题单
if (ctx.value.problemSetId !== id) return
problemSet.value = detail
setProblems.value = problems
} catch {
// 拉不到就没有题单条,不影响做题
}
},
{ immediate: true },
)
const setBar = computed(() => {
if (!problemSet.value) return null
const list = setProblems.value
const index = list.findIndex(
(item) => item.problem._id.toLowerCase() === currentId.value.toLowerCase(),
)
const link = (item: ProblemSetProblem | undefined): RouteLocationRaw | null =>
item
? {
name: "problemset problem",
params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id },
}
: null
// 进度只数必做题,和题单页的「完成进度 2 / 8(另有 3 道选做)」同一个口径;
// 一道必做都没有的题单才数全部
const required = list.some((item) => item.isRequired)
? list.filter((item) => item.isRequired)
: list
const done = required.filter((item) => item.isCompleted).length
return {
title: problemSet.value.title,
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
done,
total: required.length,
percentage: required.length ? (done / required.length) * 100 : 0,
position: index >= 0 ? index + 1 : null,
previous: index >= 0 ? link(list[index - 1]) : null,
next: index >= 0 ? link(list[index + 1]) : null,
}
})
// 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉
onSolvedHere(() => {
const item = setProblems.value.find(
(row) => row.problem._id.toLowerCase() === currentId.value.toLowerCase(),
)
if (item) item.isCompleted = true
})
// ==================== 比赛 ====================
const contestStore = useContestStore()
// 比赛页(contest/detail.vue)和比赛题目页是两条并列的路由,不会同时挂着:
// 从比赛页点进来时那边已经 clear 过了,这里自己再拉一遍
// 卸载时路由已经是下一页的了,ctx 里的 contestId 不作数,自己记着
let contestLoaded = false
watch(
() => ctx.value.contestId,
(id) => {
if (!id) return
contestLoaded = true
contestStore.init(id).catch(() => {})
},
{ immediate: true },
)
onBeforeUnmount(() => {
// 停掉倒计时的表
if (contestLoaded) contestStore.clear()
})
const contestBar = computed(() => {
const contest = contestStore.contest
if (ctx.value.entry !== "contest" || !contest || String(contest.id) !== ctx.value.contestId)
return null
const chips: ChipItem[] = contestStore.problems.map((row) => ({
displayId: row._id,
title: row.title,
status: row.status === "passed" ? "done" : row.status === "failed" ? "tried" : "none",
to: `/contest/${ctx.value.contestId}/problem/${row._id}`,
}))
return {
title: contest.title,
chips,
remaining: contestStore.remaining,
home: { name: "contest problems", params: { contestID: ctx.value.contestId } },
rank: { name: "contest rank", params: { contestID: ctx.value.contestId } },
}
})
onSolvedHere(() => {
const row = contestStore.problems.find(
(item) => item._id.toLowerCase() === currentId.value.toLowerCase(),
)
if (row) row.status = "passed"
})
</script>
<template>
<!-- 课堂 -->
<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 problemset">
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
‹ {{ setBar.title }}
</router-link>
<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>
<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 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: 6px;
min-width: 0;
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;
}
.lesson-lead {
color: v-bind("tone.lessonText");
}
.meta {
flex: none;
color: v-bind("theme.textColor2");
}
.spacer {
flex: 1 1 0;
}
.back {
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;
}
.set-back {
color: v-bind("tone.setText");
}
/* 比赛名不跟题号那段抢地方:题号多了本来就能横向滚,名字挤成两个字就认不出了 */
.contest-back {
flex: none;
max-width: 160px;
color: v-bind("tone.contestText");
}
.set-progress {
flex: none;
width: 110px;
height: 6px;
border-radius: 3px;
background-color: rgba(47, 107, 196, 0.16);
overflow: hidden;
}
.set-progress-fill {
height: 100%;
border-radius: 3px;
background-color: v-bind("tone.setBar");
}
.countdown {
flex: none;
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>