feat(挑战): 这节课做完了给「还能挑战」,挑战中编辑器顶上一条紫色的,老师看板上看得出谁在挑战
Deploy / deploy (push) Canceled after 0s

做完这节课(或题库里自己做对)之后列出还能挑战的题;去挑战后编辑器顶上写着要求,
没做到标出是哪几行,做到了写本班第几个、全站第几个。课堂看板做到的格子贴一枚紫色筹码、
在挑战的加一圈紫边,题卡片写挑战成功几人。出题页多一块「挑战」,能从做对的代码里找候选。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 08:52:50 +08:00
1 parent 0faf78ad8f
commit 1a173f75d7
19 files changed
+1243 -24

No files matched your search

+10
View File
@@ -1,4 +1,5 @@
import type {
ChallengeList,
ClassLessonRequest,
ContestPasswordRequest,
CreateFlowchartRequest,
@@ -431,6 +432,15 @@ export function getSimilarProblems(problemId: string) {
.then((response) => response.map(toProblemRow))
}
// ==================== 挑战 ====================
/** 几道题、某种语言下的挑战(说法、几人做到、我做到没有),见契约 problemChallengeGroupSchema */
export function getChallenges(problemIds: string[], language: string) {
return api.get<ChallengeList>("challenges", {
params: { problems: problemIds.join(","), language },
})
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
+56 -1
View File
@@ -256,6 +256,17 @@ const groupCount = computed(() => {
for (const student of students.value) count[groupOf(student)]++
return count
})
/**
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 classBoardCellSchema.challenge)。
* 做完了又没在挑战的,就是空着的
*/
const challengingCount = computed(
() =>
students.value.filter(
(student) =>
groupOf(student) === "done" && student.cells.some((cell) => cell.challenge === "doing"),
).length,
)
/** 每组第一个人,在他上面插一行组名 */
const groupStarts = computed(() => {
const starts = new Set<number>()
@@ -298,7 +309,11 @@ const problemStats = computed(() =>
const done = present.value.filter((student) => student.cells[i]?.status === "accepted").length
const wrong = present.value.filter((student) => student.cells[i]?.status === "tried").length
const total = present.value.length
return { problem, done, wrong, none: total - done - wrong, total }
// 挑战成功几人:只有配了挑战的题才写
const challenged = problem.hasChallenges
? present.value.filter((student) => student.cells[i]?.challenge === "done").length
: null
return { problem, done, wrong, none: total - done - wrong, total, challenged }
}),
)
@@ -532,6 +547,9 @@ function lastText(student: ClassBoardStudent) {
<span class="card-foot">
<span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
<span class="muted">没做 {{ stat.none }}</span>
<span v-if="stat.challenged !== null" class="challenged">
挑战成功 {{ stat.challenged }}
</span>
</span>
</button>
</div>
@@ -651,6 +669,9 @@ function lastText(student: ClassBoardStudent) {
<span class="group-label">
{{ GROUP_LABEL[groupOf(student)] }}
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
<span v-if="groupOf(student) === 'done' && challengingCount" class="challenged">
在挑战 {{ challengingCount }}
</span>
</span>
</td>
</tr>
@@ -709,6 +730,7 @@ function lastText(student: ClassBoardStudent) {
v-if="cell.status === 'tried' && cell.latest"
type="button"
class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
@click="openExplain(i, student.userId)"
@@ -727,6 +749,7 @@ function lastText(student: ClassBoardStudent) {
<button
type="button"
class="cell"
:data-challenge="cell.challenge ?? undefined"
:class="{ picked: trailKey === `${student.userId}:${i}` }"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
@@ -747,6 +770,7 @@ function lastText(student: ClassBoardStudent) {
<a
v-else-if="cell.attempts"
class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
target="_blank"
@@ -757,6 +781,7 @@ function lastText(student: ClassBoardStudent) {
<span
v-else
class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
>
@@ -1220,6 +1245,36 @@ a.cell {
cursor: pointer;
}
/* 挑战:做到了贴一枚紫色筹码,在挑战加一圈紫边(设计稿 StkBoard) */
.cell[data-challenge="done"] {
position: relative;
}
.cell[data-challenge="done"]::after {
content: "";
position: absolute;
right: -6px;
top: -6px;
width: 14px;
height: 14px;
border-radius: 50%;
box-sizing: border-box;
background: #5b34c4;
border: 3px solid #b49cf5;
box-shadow: 0 0 0 2px v-bind("theme.cardColor");
}
.cell[data-challenge="doing"] {
box-shadow:
0 0 0 2px v-bind("theme.cardColor"),
0 0 0 4px #8f72e3;
}
.challenged {
color: #6a45d6;
font-weight: 600;
}
.cell.picked {
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
}
+4 -1
View File
@@ -53,9 +53,12 @@ export function cellTitle(
day: string | undefined,
) {
if (cell.status === "accepted") {
return acceptedToday(cell, day)
const when = acceptedToday(cell, day)
? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对`
: "以前就做对了"
if (cell.challenge === "done") return `${when},挑战也做到了`
if (cell.challenge === "doing") return `${when},在挑战`
return when
}
if (cell.status === "tried") {
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { useChallengeStore } from "oj/store/challenge"
import { useChallengeTone } from "../composables/challengeTone"
import ChallengeIcon from "./ChallengeIcon.vue"
/**
* 挑战中:编辑器顶上一条紫色的,写着要求和全站几人做到,随时「不挑战了」(设计稿 StkDoing)。
* 做到了换成「挑战成功」,留着让学生看一眼自己是照什么要求写的,再「回到普通模式」
*/
const challengeStore = useChallengeStore()
const tone = useChallengeTone()
const active = computed(() => challengeStore.active)
const done = computed(() => !!active.value?.mine)
</script>
<template>
<div v-if="active" class="challenge-bar">
<span class="pill">
<svg
v-if="done"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
<ChallengeIcon v-else />
{{ done ? "挑战成功" : "挑战" }}
</span>
<b class="wording">{{ active.wording }}</b>
<span class="spacer" />
<span v-if="!done" class="meta">{{ active.doneCount }} 人做到</span>
<button type="button" class="quit" @click="challengeStore.stop()">
{{ done ? "回到普通模式" : "不挑战了" }}
</button>
</div>
</template>
<style scoped>
.challenge-bar {
height: 44px;
flex-shrink: 0;
box-sizing: border-box;
padding: 0 14px;
display: flex;
align-items: center;
gap: 10px;
background-color: v-bind("tone.soft");
border-bottom: 1px solid v-bind("tone.border");
white-space: nowrap;
min-width: 0;
}
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 9px;
border-radius: 12px;
background-color: v-bind("tone.solid");
color: v-bind("tone.onSolid");
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.wording {
font-size: 15px;
color: v-bind("tone.text");
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex-grow: 1;
}
.meta {
font-size: 13px;
opacity: 0.75;
}
.quit {
height: 28px;
padding: 0 10px;
border: 1px solid v-bind("tone.border");
border-radius: 4px;
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("tone.text");
cursor: pointer;
flex-shrink: 0;
}
/* 手机上一行放不下:说法最要紧,让它折行,人数不写了 */
@media (max-width: 768px) {
.challenge-bar {
height: auto;
min-height: 44px;
padding: 6px 12px;
white-space: normal;
}
.wording {
flex: 1 1 0;
}
.spacer,
.meta {
display: none;
}
}
.quit:focus-visible {
outline: 2px solid v-bind("tone.solid");
outline-offset: 2px;
}
</style>
@@ -0,0 +1,20 @@
<script setup lang="ts">
/** 挑战的标记:一枚筹码(借的是小丑牌的赌注筹码),设计稿「做对后再挑战」 */
withDefaults(defineProps<{ size?: number }>(), { size: 14 })
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="4" />
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" stroke-linecap="round" />
</svg>
</template>
@@ -0,0 +1,201 @@
<script setup lang="ts">
import type { ChallengeList } from "@oj2/contract"
import { getChallenges } from "oj/api"
import { useChallengeStore } from "oj/store/challenge"
import { useChallengeTone } from "../composables/challengeTone"
import ChallengeIcon from "./ChallengeIcon.vue"
/**
* 「还能挑战」:这几道题里我做对了、还没做到的挑战,一条一行,「去挑战」(设计稿 StkDone)。
* 一条都没有就什么都不显示 —— 课堂题多是一两行,能出挑战的只是一部分。
*/
const props = defineProps<{
problemIds: string[]
language: string
/** 变了就重拉:刚做对 / 刚做到一条挑战,列表里要去掉它 */
refreshKey?: string
}>()
const challengeStore = useChallengeStore()
const tone = useChallengeTone()
const groups = ref<ChallengeList>([])
let sequence = 0
watch(
() => [props.problemIds.join(","), props.language, props.refreshKey] as const,
async () => {
const mine = ++sequence
if (!props.problemIds.length || !props.language) {
groups.value = []
return
}
try {
const list = await getChallenges(props.problemIds, props.language)
if (mine === sequence) groups.value = list
} catch {
if (mine === sequence) groups.value = []
}
},
{ immediate: true },
)
const rows = computed(() =>
groups.value.flatMap((group) =>
// 没做对的题不给接挑战:挑战是做对之后的事
group.solved
? group.challenges
.filter((challenge) => !challenge.mine)
.map((challenge) => ({ group, challenge }))
: [],
),
)
</script>
<template>
<section v-if="rows.length" class="challenge-list" aria-label="还能挑战">
<div class="head">
<span class="pill"><ChallengeIcon />挑战</span>
<b>还能挑战</b>
</div>
<div v-for="({ group, challenge }, i) in rows" :key="challenge.id" class="row">
<div class="text">
<div class="problem">
<b>{{ group.problemId }}</b> <span class="title">{{ group.title }}</span>
</div>
<b class="wording">{{ challenge.wording }}</b>
<span class="stat">
{{ group.acCount }} 个做对的人里,{{ challenge.doneCount }} 人做到
</span>
</div>
<button
type="button"
class="go"
:class="{ ghost: i > 0 }"
@click="challengeStore.start(group.problemId, challenge.id)"
>
去挑战
<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>
</button>
</div>
</section>
</template>
<style scoped>
.challenge-list {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 9px;
border-radius: 12px;
background-color: v-bind("tone.solid");
color: v-bind("tone.onSolid");
font-size: 13px;
font-weight: 600;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px 16px;
padding: 12px 14px;
border: 1px solid v-bind("tone.border");
border-radius: 8px;
}
.text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.problem {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title,
.stat {
opacity: 0.7;
}
.stat {
font-size: 13px;
}
.wording {
font-size: 17px;
line-height: 24px;
color: v-bind("tone.text");
}
.go {
display: inline-flex;
align-items: center;
gap: 6px;
height: 40px;
padding: 0 16px;
border-radius: 6px;
border: 1px solid v-bind("tone.solid");
background-color: v-bind("tone.solid");
color: v-bind("tone.onSolid");
font: inherit;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.go.ghost {
background-color: transparent;
color: v-bind("tone.text");
border-color: v-bind("tone.border");
}
.go:focus-visible {
outline: 2px solid v-bind("tone.solid");
outline-offset: 2px;
}
/* 手机上按钮另起一行占满,好按 */
@media (max-width: 768px) {
.row {
grid-template-columns: minmax(0, 1fr);
}
.go {
justify-content: center;
height: 44px;
}
}
</style>
@@ -0,0 +1,194 @@
<script setup lang="ts">
import type { AstResultInfo } from "@oj2/contract"
import { useChallengeStore } from "oj/store/challenge"
import { clearChallengeLines, showChallengeLines } from "oj/problem/utils/errorMark"
import { useChallengeTone } from "../composables/challengeTone"
import ChallengeIcon from "./ChallengeIcon.vue"
/**
* 挑战中交的这一次,挑战成没成(设计稿 StkFail / StkPass)。答案本身是对的 —— 没做到挑战
* 也照样算做对,所以没做到时大字写「答案对了,挑战还差一点」,不是错误的红色。
*/
const props = defineProps<{
passed: boolean
results: AstResultInfo[]
wording: string
}>()
const challengeStore = useChallengeStore()
const tone = useChallengeTone()
const failed = computed(() => props.results.filter((result) => !result.passed))
/** 「print() 至多调用 1 次,现在 3 次」 */
const reason = computed(() =>
failed.value
.map((result) =>
result.actual !== undefined
? `${result.description},现在 ${result.actual} 次`
: result.description,
)
.join(";"),
)
const lines = computed(() => [...new Set(failed.value.flatMap((result) => result.lines ?? []))])
// 编辑器里把那几行标紫。做到了、换了一次提交就清掉
watch(
() => (props.passed ? [] : lines.value),
(value) => (value.length ? showChallengeLines(value) : clearChallengeLines()),
{ immediate: true },
)
onUnmounted(clearChallengeLines)
/** 做到了:本班、全站第几个。刚判完那一刻列表还没重拉,先不显示 */
const mine = computed(() => (props.passed ? (challengeStore.active?.mine ?? null) : null))
</script>
<template>
<div class="challenge-outcome">
<div class="line">
<span class="badge" :class="{ outline: !passed }" aria-hidden="true">
<ChallengeIcon :size="18" />
</span>
<div class="text">
<b class="title">{{ passed ? "挑战成功" : "答案对了,挑战还差一点" }}</b>
<span class="sub">{{ passed ? wording : reason }}</span>
</div>
</div>
<div v-if="!passed" class="chips">
<span class="chip ok">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
测试点全对
</span>
<span v-if="lines.length" class="chip mark">第 {{ lines.join("、") }} 行</span>
</div>
<div v-if="mine" class="tiles">
<div v-if="mine.classRank !== null" class="tile">
<b>第 {{ mine.classRank }} 个</b>
<span>本班做到的</span>
</div>
<div class="tile">
<b>第 {{ mine.siteRank }} 个</b>
<span>全站做到的</span>
</div>
</div>
</div>
</template>
<style scoped>
.challenge-outcome {
display: flex;
flex-direction: column;
gap: 14px;
}
.line {
display: flex;
align-items: center;
gap: 12px;
}
.badge {
width: 34px;
height: 34px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: v-bind("tone.solid");
color: v-bind("tone.onSolid");
}
.badge.outline {
background-color: transparent;
border: 2px solid v-bind("tone.solid");
color: v-bind("tone.solid");
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-size: 22px;
line-height: 28px;
color: v-bind("tone.text");
}
.sub {
font-size: 14px;
opacity: 0.75;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 9px;
border-radius: 13px;
font-size: 13px;
}
.chip.ok {
background-color: rgba(24, 160, 88, 0.12);
color: #18a058;
}
.chip.mark {
background-color: v-bind("tone.soft");
color: v-bind("tone.text");
font-weight: 600;
}
.tiles {
display: flex;
gap: 10px;
}
.tile {
flex: 1;
display: flex;
flex-direction: column;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid v-bind("tone.border");
background-color: v-bind("tone.soft");
}
.tile b {
font-size: 24px;
line-height: 30px;
font-variant-numeric: tabular-nums;
color: v-bind("tone.text");
}
.tile span {
font-size: 13px;
opacity: 0.75;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useLessonStore } from "shared/store/lesson"
import ChallengeList from "./ChallengeList.vue"
/**
* 通过之后告诉学生「这节课的下一题是哪道」。
@@ -16,6 +17,12 @@ import { useLessonStore } from "shared/store/lesson"
const props = defineProps<{
/** 当前这道题的展示题号 */
problemDisplayId: string
/** 刚做对用的语言:挑战按语言分 */
language: string
/** 刚做对的那次提交,换了就重拉「还能挑战」 */
refreshKey: string
/** 这个入口给不给挑战(见 problemPageContext) */
challenges: boolean
}>()
const lessonStore = useLessonStore()
@@ -31,6 +38,11 @@ const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId))
// 设计稿和上下文条都叫「这节课」,不区分是老师布置的还是从同班提交推断的
const label = "这节课"
/** 这节课的几道题:都做完了以后,这几道里还能挑战的列出来(设计稿 StkDone) */
const lessonProblemIds = computed(
() => lessonStore.activity?.problems.map((item) => item.problemDisplayId) ?? [],
)
</script>
<template>
@@ -66,13 +78,18 @@ const label = "这节课"
<template v-else>
<div class="text">
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span class="detail">想接着练,可以去题单里挑一个</span>
<span v-if="!challenges" class="detail">想接着练,可以去题单里挑一个</span>
</div>
<router-link to="/problemset" class="next secondary">
<router-link v-if="!challenges" to="/problemset" class="next secondary">
<span class="next-label">去题单继续练</span>
</router-link>
</template>
</div>
<!-- 做完了:先给这节课里还能挑战的,再给题单(设计稿 StkDone)。挑战一条都没有时只剩题单那句 -->
<template v-if="lesson && !lesson.next && challenges">
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
<router-link to="/problemset" class="more">去题单继续练</router-link>
</template>
</template>
<style scoped>
@@ -129,6 +146,17 @@ const label = "这节课"
outline-offset: 2px;
}
.more {
display: inline-block;
margin-top: 12px;
font-size: 14px;
color: v-bind("theme.primaryColor");
}
.more:hover {
text-decoration: underline;
}
.next.secondary {
background-color: transparent;
border: 1px solid v-bind("theme.primaryColor");
@@ -13,6 +13,8 @@ import { errorMarkExtensions } from "oj/problem/utils/errorMark"
import { editorFocusTracker } from "oj/problem/utils/symbolKeys"
import { hangWrapExtensions } from "shared/extensions/hangIndent"
import EditorToolbar from "./EditorToolbar.vue"
import ChallengeBar from "./ChallengeBar.vue"
import { useChallengeStore } from "oj/store/challenge"
import { useDraftKey } from "../composables/draftKey"
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
import { useProblemPageContext } from "../composables/problemPageContext"
@@ -99,9 +101,14 @@ const storageKey = useDraftKey()
// 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。
// 手机:顶栏两行 + 课堂条 + 页签 + 工具栏约 236px,底部还有「运行例子 / 提交」那条约 64px
// 挑战中编辑器顶上多一条 44 的挑战条(协作中的老师不显示,见模板)
const challengeStore = useChallengeStore()
const showChallengeBar = computed(
() => ctx.value.challenges && !teacherCollab.value && !!challengeStore.active,
)
const editorHeight = computed(() => {
const base = isDesktop.value ? 104 : 300
return `calc(100vh - ${base + (teacherCollab.value ? 40 : 0)}px)`
return `calc(100vh - ${base + (teacherCollab.value ? 40 : 0) + (showChallengeBar.value ? 44 : 0)}px)`
})
function loadCode() {
@@ -220,6 +227,7 @@ onBeforeUnmount(() => flowchartStore.attachEditor(null))
<template>
<div class="problem-editor">
<CollabBar v-if="teacherCollab" />
<ChallengeBar v-if="showChallengeBar" />
<EditorToolbar @change-language="changeLanguage" />
<!--
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
@@ -21,6 +21,9 @@ import WrongAnswerExplain from "./WrongAnswerExplain.vue"
import LessonNext from "./LessonNext.vue"
import ResultHeader from "./ResultHeader.vue"
import SimilarProblems from "./SimilarProblems.vue"
import ChallengeList from "./ChallengeList.vue"
import ChallengeOutcome from "./ChallengeOutcome.vue"
import { useChallengeStore } from "oj/store/challenge"
import { useLessonStore } from "shared/store/lesson"
import { useProblemPageContext } from "../composables/problemPageContext"
import Markdown from "shared/components/Markdown.vue"
@@ -109,6 +112,40 @@ const showSimilar = computed(
!lessonStore.includes(problemStore.problem._id),
)
/**
* 挑战中交的这一次:正在挑战的那条判出来的结果(设计稿 StkFail / StkPass)。只有做对的提交
* 才判挑战;进挑战之前那次做对的不算(见 challenge store 的 baselineSubmissionId)
*/
const challengeStore = useChallengeStore()
const challengeVerdict = computed(() => {
const submission = props.submission
if (!submission || props.peer || !ctx.value.challenges) return null
if (submission.result !== SubmissionStatus.accepted) return null
const id = challengeStore.activeId
if (!id || submission.id === challengeStore.baselineSubmissionId) return null
return submission.statisticInfo?.challenges?.find((verdict) => verdict.id === id) ?? null
})
// 做到了:重拉一次,挑战条换成「挑战成功」、拿到本班 / 全站第几个
watch(
() => (challengeVerdict.value?.passed ? props.submission?.id : null),
(id) => {
if (id) void challengeStore.load()
},
)
/**
* 「还能挑战」看哪几道:这道在这节课里就看这节课的几道(课堂条「这节课都做完了」之后),
* 不在就只看这一道
*/
const challengeProblemIds = computed(() => {
const problem = problemStore.problem
if (!problem) return []
const lesson = lessonStore.activity
return lessonStore.includes(problem._id) && lesson
? lesson.problems.map((item) => item.problemDisplayId)
: [problem._id]
})
const msg = computed(() => {
if (!props.submission) return ""
// 走 CompileErrorExplain / RuntimeErrorExplain 那两张中文卡片
@@ -298,18 +335,44 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
<template>
<div v-if="submission">
<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"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
<!-- 挑战中:答案对了就只说挑战成没成,「下一题」「相似题」都不给,免得挤掉挑战 -->
<template v-if="challengeVerdict && challengeStore.active">
<ChallengeOutcome
:passed="challengeVerdict.passed"
:results="challengeVerdict.results"
:wording="challengeStore.active.wording"
/>
<ChallengeList
v-if="challengeVerdict.passed"
:problem-ids="challengeProblemIds"
:language="submission.language"
:refresh-key="submission.id"
/>
</template>
<template v-else>
<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"
:language="submission.language"
:refresh-key="submission.id"
:challenges="ctx.challenges"
/>
<!-- 不在这节课里(题库入口自己做的):做对之后先给这道题的挑战,再给相似题 -->
<ChallengeList
v-if="showSimilar && ctx.challenges && problemStore.problem"
:problem-ids="[problemStore.problem._id]"
:language="submission.language"
:refresh-key="submission.id"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
</template>
<n-flex
vertical
v-if="
@@ -0,0 +1,24 @@
/**
* 挑战的颜色:紫。课堂条是绿、题单条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。
* Naive 的主题里没有紫,暗色下换浅一档,深紫放在暗底上看不清
*/
export function useChallengeTone() {
const isDark = useDark()
return computed(() =>
isDark.value
? {
solid: "#b49cf5",
onSolid: "#17112b",
text: "#c9b8ff",
soft: "rgba(180, 156, 245, 0.14)",
border: "rgba(180, 156, 245, 0.35)",
}
: {
solid: "#5b34c4",
onSolid: "#ffffff",
text: "#2c1a66",
soft: "#f4f0fe",
border: "#ddd3f6",
},
)
}
@@ -26,6 +26,11 @@ interface EntryRules {
similar: boolean
/** 通过之后给「这节课的下一题」 */
lessonNext: boolean
/**
* 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。题单里通过后 1.5 秒就跳回去,
* 比赛提交不判挑战
*/
challenges: boolean
/** 通过之后 1.5 秒弹强制点评 */
reviewAfterAccepted: boolean
/** 通过之后 1.5 秒回题单 */
@@ -57,6 +62,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
statusTag: true,
similar: true,
lessonNext: true,
challenges: true,
reviewAfterAccepted: true,
backToProblemSet: false,
help: true,
@@ -71,6 +77,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
// 点进相似题就离开了题单
similar: false,
lessonNext: false,
challenges: false,
// 1.5 秒后要跳回题单页,弹了也会被冲掉
reviewAfterAccepted: false,
backToProblemSet: true,
@@ -86,6 +93,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
// 撞上同号公开题时反而会在比赛中把题库列给学生
similar: false,
lessonNext: false,
challenges: false,
reviewAfterAccepted: false,
backToProblemSet: false,
help: false,
+39 -1
View File
@@ -75,6 +75,30 @@ const markField = StateField.define<DecorationSet>({
provide: (field) => EditorView.decorations.from(field),
})
/**
* 挑战没做到时标出是哪几行(「print 最多 1 次」→ 三个 print 所在的三行),紫底,和挑战
* 那条的颜色一致。和错误标记分开存:答案对了、挑战没做到时没有错误可标,两种也不会同时出现
*/
const setChallengeLines = StateEffect.define<number[]>()
const challengeLineDeco = Decoration.line({ class: "cm-challenge-line" })
const challengeField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setChallengeLines)) {
const lines = effect.value.filter((line) => line >= 1 && line <= tr.state.doc.lines)
deco = Decoration.set(
lines.map((line) => challengeLineDeco.range(tr.state.doc.line(line).from)),
)
}
}
return deco
},
provide: (field) => EditorView.decorations.from(field),
})
/**
* 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层,
* 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上
@@ -98,6 +122,11 @@ const theme = EditorView.baseTheme({
textUnderlineOffset: "3px",
backgroundColor: "rgba(208, 48, 80, 0.18)",
},
// 左边再压一道紫线:光标所在那行的底色会盖住行底色,线不会被盖
".cm-challenge-line": {
backgroundColor: "rgba(91, 52, 196, 0.12)",
boxShadow: "inset 3px 0 0 #8f72e3",
},
".cm-punctuation-mark": {
backgroundColor: "rgba(240, 160, 32, 0.35)",
outline: "1px solid rgba(240, 160, 32, 0.8)",
@@ -105,7 +134,7 @@ const theme = EditorView.baseTheme({
},
})
export const errorMarkExtensions = [markField, punctuationField, tracker, theme]
export const errorMarkExtensions = [markField, punctuationField, challengeField, tracker, theme]
/** 题目页主编辑器(没挂着就是 null)。手机上的符号键往它里面插字 */
export function currentEditorView(): EditorView | null {
@@ -145,6 +174,15 @@ export function showErrorMark(
})
}
/** 标出挑战没做到的那几行(行号来自判题时的 AST,学生之后改过代码的话会对不上,不滚过去) */
export function showChallengeLines(lines: number[]) {
current?.dispatch({ effects: setChallengeLines.of(lines) })
}
export function clearChallengeLines() {
current?.dispatch({ effects: setChallengeLines.of([]) })
}
export function clearErrorMark() {
current?.dispatch({ effects: setMark.of(null) })
}
+87
View File
@@ -0,0 +1,87 @@
import { defineStore } from "pinia"
import type { ProblemChallengeGroup } from "@oj2/contract"
import { getChallenges } from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 挑战:做对之后,同一道题加一条写法上的限制再做一遍(契约 challengeSchema)。
*
* 正在挑战哪一条记在路由的 `?challenge=<id>` 上:「去挑战」常常是去另一道题(这节课的
* 别的题),带着 query 跳过去最直接;刷新、返回也还在挑战里。
*
* 这里只管当前这道题的挑战(编辑器顶上那条、结果里的成没成)。课堂条「这节课都做完了」
* 那几道题的列表由 ChallengeList 自己拉。
*/
export const useChallengeStore = defineStore("challenge", () => {
const route = useRoute()
const router = useRouter()
const problemStore = useProblemStore()
const codeStore = useCodeStore()
const { isMobile } = useBreakpoints()
const activeId = computed(() =>
typeof route.query.challenge === "string" ? route.query.challenge : "",
)
/**
* 进挑战那一刻结果页签里挂着的那次提交。它是进挑战之前那次做对的,也判过挑战(没做到),
* 不能拿它说「挑战还差一点」—— 只认之后交的。不用时刻比:机房电脑的钟不一定准
*/
const submissionStore = useSubmissionStore()
const baselineSubmissionId = ref<string | null>(null)
watch(
activeId,
(id) => {
if (id) baselineSubmissionId.value = submissionStore.submission?.id ?? null
},
{ immediate: true },
)
/** 当前这道题、编辑器当前语言下的挑战;没配挑战为 null */
const current = ref<ProblemChallengeGroup | null>(null)
let sequence = 0
async function load() {
const problemId = problemStore.problem?._id
const language = codeStore.code.language
const mine = ++sequence
if (!problemId || language === "Flowchart" || problemStore.problem?.contestId != null) {
current.value = null
return
}
try {
const [group] = await getChallenges([problemId], language)
if (mine === sequence) current.value = group ?? null
} catch {
// 拉不到就没有挑战,不影响做题
if (mine === sequence) current.value = null
}
}
watch([() => problemStore.problem?._id, () => codeStore.code.language], load, {
immediate: true,
})
/** 正在挑战的那条(换了语言、挑战被老师删了就是 null) */
const active = computed(
() => current.value?.challenges.find((item) => item.id === activeId.value) ?? null,
)
function start(problemId: string, challengeId: string) {
// 进挑战先看题和代码:手机上编辑器是单独一个页签
const tab = isMobile.value ? "editor" : "content"
void router.push({
name: "problem",
params: { problemID: problemId },
query: { challenge: challengeId, tab },
})
}
function stop() {
const { challenge: _, ...rest } = route.query
void router.replace({ query: rest })
}
return { activeId, active, current, baselineSubmissionId, load, start, stop }
})