refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk

- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改
- 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转
- 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today),
  首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts
- 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 22:53:07 +08:00
1 parent 46d3fe2dbe
commit 29c31640b5
82 files changed
+798 -766

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
import type { LessonMode } from '@oj2/contract'
import type { Tone } from 'shared/composables/tone'
import { parseTime } from 'utils/functions'
import type { TaskBoardCell, TaskBoardStudent } from 'utils/types'
/**
* 课堂任务的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
* 和 errorKind.ts 一样单放一个文件(任务页组件只管排版和交互)。
*
* `mode` 是这节课做什么(契约 lessonModeSchema),推断出来的课为 null。先画再写的格子是一条进度:
* 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」
*/
/** 同一道题今天交了这么多次还没过,算卡住 */
export const STUCK_ATTEMPTS = 3
/**
* 没做完的格子卡在哪一步:draw = 在画流程图、code = 在写代码、nodraw = 代码对了但还没画图。
* 只画流程图的课一律是 draw;写代码的课和推断出来的课一律是 code
*/
export type CellStage = 'draw' | 'code' | 'nodraw'
export function cellStage(cell: TaskBoardCell, mode: LessonMode | null): CellStage {
if (mode === 'flowchart') return 'draw'
if (mode !== 'both' || !cell.flowchart) return 'code'
if (cell.flowchart.status === 'tried') return 'draw'
if (cell.flowchart.status === 'none' && cell.codeStatus === 'accepted') return 'nodraw'
return 'code'
}
/** 今天在卡着的那一步上交了几次:先画再写的课里,画了三张才画对的人写代码时不该算卡住 */
export function stageAttempts(cell: TaskBoardCell, mode: LessonMode | null) {
if (mode !== 'both') return cell.attempts
const stage = cellStage(cell, mode)
return stage === 'draw' ? cell.flowchartAttempts : stage === 'code' ? cell.codeAttempts : 0
}
/** 卡住了:没做完,而且在卡着的那一步上今天交了 STUCK_ATTEMPTS 次以上 */
export function isStuck(cell: TaskBoardCell, mode: LessonMode | null) {
return cell.status === 'tried' && stageAttempts(cell, mode) >= STUCK_ATTEMPTS
}
/** 先画再写:图画对了、代码还没交过 —— 空心绿的「图 ✓」 */
function halfDone(cell: TaskBoardCell, mode: LessonMode | null) {
return mode === 'both' && cell.status === 'tried' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none'
}
/** 今天之前就通过了的,颜色淡一档。`day` 是任务页的「今天」(东八区日期) */
export function acceptedToday(cell: TaskBoardCell, day: string | undefined) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day
}
/** 最后一张图的那句:评分中 / 评失败 / 走对几组 */
function drawText(cell: TaskBoardCell, prefix: string) {
const latest = cell.flowchart?.latest
if (!latest) return '✗'
if (latest.status === 0 || latest.status === 1) return '评分中'
if (latest.status === 3) return '评失败'
if (latest.passed !== null && latest.total) return `${prefix}${latest.passed}/${latest.total}`
return '✗'
}
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
export function cellText(cell: TaskBoardCell, mode: LessonMode | null = null) {
if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
if (cell.status === 'tried') {
const stage = cellStage(cell, mode)
if (stage === 'draw') return drawText(cell, mode === 'both' ? '图 ' : '')
if (stage === 'nodraw') return '缺图'
if (halfDone(cell, mode)) return '图 ✓'
const attempts = mode === 'both' ? cell.codeAttempts : cell.attempts
return attempts ? `✗ ${attempts}` : '✗'
}
return ''
}
export function cellStyle(
cell: TaskBoardCell,
day: string | undefined,
colors: {
tone: (kind: Tone) => { color: string; background: string }
errorColor: string
actionColor: string
successColor: string
},
mode: LessonMode | null = null,
) {
if (cell.status === 'accepted') {
const t = colors.tone('success')
return acceptedToday(cell, day) ? { background: t.background, color: t.color } : { background: t.background, color: t.color, opacity: 0.5 }
}
if (cell.status === 'tried') {
if (halfDone(cell, mode)) {
return { background: 'transparent', color: colors.tone('success').color, boxShadow: `inset 0 0 0 1.5px ${colors.successColor}` }
}
if (isStuck(cell, mode)) {
return { background: colors.errorColor, color: '#ffffff' }
}
const t = colors.tone('warning')
return { background: t.background, color: t.color }
}
return { background: colors.actionColor }
}
export function cellTitle(cell: TaskBoardCell, student: TaskBoardStudent, day: string | undefined, mode: LessonMode | null = null) {
if (cell.status === 'accepted') {
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') {
const stage = cellStage(cell, mode)
if (stage === 'draw') {
const latest = cell.flowchart?.latest
if (latest?.status === 0 || latest?.status === 1) return 'AI 正在评这张图'
return latest ? `讲讲 ${student.username} 这张图哪里画错了` : '还没画对'
}
if (stage === 'nodraw') return '代码做对了,流程图还没画'
if (halfDone(cell, mode)) return '流程图画对了,代码还没交'
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
if (mode === 'both' && cell.flowchart?.status === 'accepted') return '流程图画对了,代码以前交过、没做对'
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : '以前交过,没做对'
}
return ''
}
/** 点开做对的格子,方块串旁边那句 */
export function trailSummary(cell: TaskBoardCell, day: string | undefined) {
const total = cell.trail.length
if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对`
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
return acceptedToday(cell, day) ? `今天交了 ${total} 次代码` : `以前就做对了,今天又交了 ${total} 次`
}
@@ -0,0 +1,317 @@
<script lang="ts">
/**
* 弹层顶上那排人里的一个。讲代码给 submissionId;要画流程图的课里卡在画图上的,给他最后
* 那张图的 flowchartId —— 下面就换成图和 AI 的点评
*/
export type ExplainPerson = {
userId: number
username: string
/** 错法的短名(errorKind),挂在名字后面 */
label: string
} & ({ submissionId: string; flowchartId?: undefined } | { flowchartId: string; submissionId?: undefined })
</script>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onKeyStroke } from '@vueuse/core'
import { useThemeVars } from 'naive-ui'
import SubmissionView from 'oj/submission/components/SubmissionView.vue'
import UserName from 'shared/components/UserName.vue'
import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import type { TaskBoardProblem } from 'utils/types'
import FlowchartExplain from './FlowchartExplain.vue'
import ProblemBrief from './ProblemBrief.vue'
/**
* 课堂任务「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
* 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块
* (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。
*
* 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。
* 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样
*
* 手机上(老师拿手机看任务页):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
* 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排
*/
const props = defineProps<{
problem: TaskBoardProblem | null
people: ExplainPerson[]
}>()
const show = defineModel<boolean>('show', { required: true })
const index = defineModel<number>('index', { required: true })
const theme = useThemeVars()
const { isMobile } = useBreakpoints()
// 手机上是整屏的:按返回键关掉它回到任务页,不是退出任务页
useBackClose(show, isMobile)
/**
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
* v-bind 的变量挂在组件根上,传不过去(弹层底色整个透明)
*/
const vars = computed(() => ({
'--card': theme.value.cardColor,
'--text1': theme.value.textColor1,
'--text2': theme.value.textColor2,
'--text3': theme.value.textColor3,
'--divider': theme.value.dividerColor,
'--border': theme.value.borderColor,
'--success': theme.value.successColor,
'--hover': theme.value.hoverColor,
}))
const current = computed(() => props.people[index.value] ?? null)
const showProblem = useStorage('oj_explain_show_problem', false)
/** 一排人横着滑的时候,换到的人要滑进来(← → 换人、或者点开的就是排在后面的) */
const peopleRef = ref<HTMLElement | null>(null)
watch([index, show], async () => {
if (!show.value) return
await nextTick()
peopleRef.value?.querySelector<HTMLElement>('.person.on')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
})
function step(delta: -1 | 1) {
const next = index.value + delta
if (next >= 0 && next < props.people.length) index.value = next
}
onKeyStroke(['ArrowLeft', 'ArrowRight'], (event) => {
if (!show.value || props.people.length < 2) return
if (event.altKey || event.ctrlKey || event.metaKey) return
event.preventDefault()
step(event.key === 'ArrowLeft' ? -1 : 1)
})
</script>
<template>
<n-modal v-model:show="show" :auto-focus="false">
<!-- 无 preset 的 n-modal 直接子节点要是普通元素 -->
<div class="sheet" :class="{ narrow: isMobile }" :style="vars" role="dialog" :aria-label="problem ? `讲 ${problem.problemDisplayId} 的错误` : '讲错误'">
<div class="top">
<span v-if="problem" class="problem">
<b>{{ problem.problemDisplayId }}</b> {{ problem.title }}
<template v-if="people.length > 1"> · 错着的</template>
</span>
<div ref="peopleRef" class="people">
<button v-for="(person, i) in people" :key="person.userId" type="button" class="person" :class="{ on: i === index }" @click="index = i">
<UserName :username="person.username" />
<span class="kind">{{ person.label }}</span>
</button>
</div>
<span v-if="people.length > 1 && !isMobile" class="keys"><kbd>←</kbd><kbd>→</kbd> 换人</span>
<div class="spacer"></div>
<div class="buttons">
<n-button
v-if="problem"
:size="isMobile ? 'small' : 'medium'"
:type="showProblem ? 'primary' : 'default'"
:secondary="showProblem"
@click="showProblem = !showProblem"
>
题目
</n-button>
<n-button v-if="current?.submissionId" :size="isMobile ? 'small' : 'medium'" tag="a" :href="'/submission/' + current.submissionId" target="_blank">
在新页面打开
</n-button>
</div>
<n-button quaternary class="close" aria-label="关闭" @click="show = false">
<template #icon><Icon icon="ph:x-bold" /></template>
</n-button>
</div>
<div class="body" :class="{ split: showProblem && problem }">
<aside v-if="showProblem && problem" class="statement">
<ProblemBrief :display-id="problem.problemDisplayId" />
</aside>
<div class="main">
<FlowchartExplain v-if="current?.flowchartId" :id="current.flowchartId" />
<SubmissionView v-else-if="current?.submissionId" :id="current.submissionId" embedded />
</div>
</div>
</div>
</n-modal>
</template>
<style scoped>
.sheet {
width: min(1240px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
box-sizing: border-box;
padding: 18px 24px 24px;
border-radius: 10px;
background: var(--card);
display: flex;
flex-direction: column;
gap: 14px;
}
.top {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.problem {
flex-shrink: 0;
font-size: 14px;
color: var(--text3);
white-space: nowrap;
}
.problem b {
color: var(--text2);
}
.people {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.person {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid var(--divider);
background: var(--card);
color: var(--text1);
font: inherit;
font-size: 14px;
white-space: nowrap;
cursor: pointer;
}
.person.on {
border-color: var(--success);
background: var(--hover);
}
.kind {
font-size: 12px;
color: var(--text3);
}
.keys {
flex-shrink: 0;
font-size: 12px;
color: var(--text3);
display: inline-flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
kbd {
min-width: 18px;
height: 18px;
padding: 0 4px;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--card);
font: inherit;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.spacer {
flex: 1 1 0;
}
.body.split {
display: grid;
grid-template-columns: minmax(300px, 2fr) minmax(0, 3fr);
gap: 20px;
align-items: start;
}
/* 题目自己滚:讲到代码后半段时题目还在旁边 */
.statement {
position: sticky;
top: 0;
max-height: calc(100vh - 140px);
overflow: auto;
padding-right: 8px;
color: var(--text1);
}
.main {
min-width: 0;
}
.buttons {
display: flex;
gap: 10px;
}
/* ---------- 手机 ---------- */
.sheet.narrow {
width: calc(100vw - 16px);
/* 全站给 .n-modal 的「左右各留 16px」在这里不要 */
max-width: none;
max-height: calc(100vh - 16px);
max-height: calc(100dvh - 16px);
padding: 12px 14px 16px;
gap: 12px;
}
/* 第一行题目 + 关闭,第二行一排人(横着滑),第三行两个按钮 */
.narrow .top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.narrow .problem {
grid-row: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.narrow .close {
grid-row: 1;
grid-column: 2;
margin-right: -6px;
}
.narrow .people {
grid-column: 1 / -1;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.narrow .person {
flex-shrink: 0;
}
.narrow .spacer {
display: none;
}
.narrow .buttons {
grid-column: 1 / -1;
}
/* 题目叠在代码上面,自己滚,别把代码顶出一屏 */
.narrow .body.split {
display: flex;
flex-direction: column;
gap: 14px;
}
.narrow .statement {
position: static;
max-height: 40vh;
padding: 0 0 12px;
border-bottom: 1px solid var(--divider);
}
</style>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { getFlowchartSubmission } from 'oj/api'
import FlowchartNote from 'shared/components/FlowchartNote.vue'
import { useMermaid } from 'shared/composables/useMermaid'
import { useTone } from 'shared/composables/tone'
import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } from 'utils/flowchart'
import type { FlowchartSubmission } from 'utils/types'
/**
* 课堂任务「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
* 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏
* (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。
*
* 挂在 n-modal 里(内容在 body 下),<style> 的 v-bind() 传不进来:颜色走外层弹层给的
* CSS 变量(--card、--divider…),标红的颜色走根节点的内联变量
*/
const props = defineProps<{ id: string }>()
const tone = useTone()
const { renderError, renderFlowchart } = useMermaid()
const detail = ref<FlowchartSubmission | null>(null)
const failed = ref(false)
const graph = useTemplateRef<HTMLElement>('graph')
const openCase = ref<number | null>(null)
const casesOpen = ref(false)
const report = computed(() => caseReport(detail.value?.aiCriteriaDetails))
const vars = computed(() => ({
'--focus-fill': tone('error').background,
'--focus-stroke': tone('error').solid,
}))
let seq = 0
async function load() {
const mine = ++seq
failed.value = false
openCase.value = null
casesOpen.value = false
try {
const res = await getFlowchartSubmission(props.id)
if (mine !== seq) return
detail.value = res
await nextTick()
await renderFlowchart(graph.value, res.mermaidCode)
fitFlowchartGraph(graph.value)
markFlowchartFocus(graph.value, report.value)
} catch {
if (mine !== seq) return
detail.value = null
failed.value = true
}
}
watch(() => props.id, load, { immediate: true })
</script>
<template>
<div class="explain-flow" :style="vars">
<div class="graph-box">
<span v-if="renderError" class="muted">这张流程图画不出来,可能有框没连上</span>
<div v-show="!renderError" ref="graph" class="graph"></div>
<span v-if="failed" class="muted">没拉下来,<a href="#" @click.prevent="load">再试一次</a></span>
</div>
<div v-if="detail" class="note-box">
<template v-if="report">
<b class="headline">{{ flowchartVerdict(report, detail.aiGrade) }}</b>
<FlowchartNote
v-model:selected="openCase"
v-model:open="casesOpen"
:report="report"
:feedback="detail.aiFeedback"
:suggestions="detail.aiSuggestions"
:previous="detail.previousCases"
/>
</template>
<!-- 2026-10 之前的老评法没有走例子的报告,只有一段评语 -->
<template v-else>
<b class="headline">{{ detail.aiGrade ? `${detail.aiGrade} 级` : '还没画对' }}</b>
<div v-if="detail.aiFeedback" class="plain">{{ detail.aiFeedback }}</div>
<div v-if="detail.aiSuggestions" class="plain">{{ detail.aiSuggestions }}</div>
</template>
</div>
</div>
</template>
<style scoped>
.explain-flow {
display: flex;
gap: 14px;
}
/* 图自己画了一层底框,外面不再套边框;高度压在一屏以内,讲的时候不用上下滚 */
.graph-box {
flex: 0 0 60%;
min-width: 0;
height: min(620px, calc(100vh - 190px));
display: flex;
align-items: center;
justify-content: center;
}
.graph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.note-box {
border: 1px solid var(--divider);
border-radius: 8px;
background: var(--card);
}
.graph :deep(svg) {
max-width: 100%;
max-height: 100%;
}
.graph :deep(.pele-node.oj-focus > :not(text)) {
fill: var(--focus-fill) !important;
stroke: var(--focus-stroke) !important;
stroke-width: 3px !important;
}
.note-box {
flex: 1 1 0;
min-width: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
}
.headline {
font-size: 18px;
}
.plain {
white-space: pre-wrap;
line-height: 1.7;
}
.muted {
color: var(--text3);
}
@media (max-width: 768px) {
.explain-flow {
flex-direction: column;
}
.graph-box {
flex: none;
height: 360px;
}
}
</style>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { TaskBoardStudent } from 'utils/types'
/**
* 任务页的「请假」框:勾今天不在的人。规则在任务页那边(只有今天还没交过的人能算请假、
* 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。
*/
const props = defineProps<{
/** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */
candidates: TaskBoardStudent[]
/** 打开时已经标着请假的 */
current: string[]
}>()
const emit = defineEmits<{ save: [usernames: string[]] }>()
const show = defineModel<boolean>('show', { required: true })
const selected = ref<string[]>([])
watch(show, (open) => {
if (open) selected.value = [...props.current]
})
/**
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/
const unseen = (student: TaskBoardStudent | undefined) => !!student && !student.seenAt
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
const sections = computed(() =>
[
{ label: '今天没上线', students: props.candidates.filter(unseen) },
{
label: '今天上过线',
students: props.candidates.filter((student) => !unseen(student)),
},
].filter((section) => section.students.length),
)
function selectAll(list: TaskBoardStudent[]) {
selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])]
}
function save() {
emit('save', selected.value)
show.value = false
}
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="candidates.length ? `今天还没交过的 ${candidates.length} 人里,谁请假了?` : '今天全班都交过题了'"
style="width: 640px; max-width: calc(100vw - 32px)"
>
<n-checkbox-group v-if="candidates.length" v-model:value="selected">
<div v-for="section in sections" :key="section.label" class="leave-section">
<div v-if="sections.length > 1 || unseen(section.students[0])" class="leave-head">
{{ section.label }} {{ section.students.length }} 人
<n-button
v-if="unseen(section.students[0])"
text
type="primary"
:disabled="section.students.every((student) => selected.includes(student.username))"
@click="selectAll(section.students)"
>
全勾上
</n-button>
</div>
<div class="leave-grid">
<n-checkbox v-for="student in section.students" :key="student.userId" :value="student.username" :label="student.username" class="leave-option" />
</div>
</div>
</n-checkbox-group>
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
<template #footer>
<n-flex align="center" :wrap="false">
<n-text depth="3" class="meta" style="flex-grow: 1"> 已选 {{ selected.length }} 人 · 只记在这台电脑上,今天有效 </n-text>
<n-button :disabled="!selected.length" @click="selected = []">全部取消</n-button>
<n-button @click="show = false">取消</n-button>
<n-button type="primary" @click="save">确定</n-button>
</n-flex>
</template>
</n-modal>
</template>
<style scoped>
.leave-section + .leave-section {
margin-top: 18px;
}
.leave-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
font-weight: 600;
}
.leave-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px 8px;
}
.leave-option {
font-size: 15px;
}
.meta {
font-size: var(--oj-fs-sec);
}
</style>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode } from '@oj2/contract'
import { setClassLesson } from 'oj/api'
import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import type { TaskBoard } from 'utils/types'
import { Icon } from '@iconify/vue'
import ProblemPicker from './ProblemPicker.vue'
/**
* 任务页上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
* - `start`:刚上课还没有题时,这页最大的那个框。
*
* 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节。
* `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用
*/
const props = defineProps<{
board: TaskBoard
className: string
variant: 'inline' | 'start'
lessonId: number | null
carry?: boolean
}>()
const emit = defineEmits<{ saved: []; cancel: [] }>()
const message = useMessage()
/**
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
* 上一次布置时选的;选了之后任务页和学生那边的「做完」都只认这个语言交对的
*/
const lessonLanguage = ref<LessonLanguage>('Python')
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
const languageOptions = LESSON_LANGUAGES.map((value) => ({
label: LANGUAGE_SHOW_VALUE[value],
value,
}))
/**
* 这节课做什么:写代码 / 先画再写 / 画流程图。画流程图不用选语言(下拉藏起来,存成 null),
* 要画的两种只能布置打开了画流程图的题(后端会点名拒掉)
*/
const lessonMode = ref<LessonMode>('code')
const modeOptions = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
const needsCode = computed(() => lessonNeeds(lessonMode.value).code)
const lessonInput = ref('')
const saving = ref(false)
/** 改的是老师布置的这一节:框里是它的题;新开一节是空的 */
const editingPlanned = computed(() => props.lessonId !== null && props.lessonId === props.board.lessonId)
function resetLessonInput() {
const keep = editingPlanned.value || props.carry
lessonInput.value = keep ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
lessonLanguage.value = (editingPlanned.value ? props.board.language : null) ?? props.board.lastLanguage ?? 'Python'
lessonMode.value = props.carry ? 'code' : editingPlanned.value ? (props.board.mode ?? 'code') : 'code'
}
resetLessonInput()
// 推断出来的题也带进框里,老师多半只是确认一下
if (props.variant === 'inline' && props.board.source === 'inferred' && !lessonInput.value && props.board.problems.length) {
lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(' ')
}
// 换了班、换了一节要把输入框换成那一节的题、布置时选的语言(15 秒一次的刷新不动它们)。
// 必须写成几个单值来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
// 回调次次都跑,老师打到一半的题号每 15 秒被清一次
watch([() => props.board.className, () => props.board.source, () => props.lessonId, () => props.carry], resetLessonInput)
function inputIds() {
return lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
}
async function saveLesson() {
const ids = inputIds()
saving.value = true
try {
await setClassLesson(props.className, ids, ids.length && needsCode.value ? lessonLanguage.value : null, lessonMode.value, props.lessonId)
message.success(ids.length ? '已布置,学生首页会显示这几道题' : '已清掉')
picking.value = false
emit('saved')
} catch (error) {
message.error(errorMessage(error))
} finally {
saving.value = false
}
}
// ---- 选题弹框:不用记题号,按知识点挑。框里已经敲了的题号带进去,题名能对上的带上题名 ----
const picking = ref(false)
const pickerInitial = ref<{ id: string; title?: string }[]>([])
function openPicker() {
const titles = new Map(props.board.problems.map((p) => [p.problemDisplayId.toLowerCase(), p.title]))
pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) }))
picking.value = true
}
function pickerConfirm(ids: string[]) {
lessonInput.value = ids.join(' ')
saveLesson()
}
</script>
<template>
<div v-if="variant === 'inline'">
<n-select v-model:value="lessonMode" :options="modeOptions" aria-label="这节课做什么" class="mode-select" />
<n-select v-if="needsCode" v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言写" class="lang-select" />
<n-input v-model:value="lessonInput" placeholder="这节课的题号,用空格隔开" clearable autofocus @keyup.enter="saveLesson" />
<n-button @click="openPicker">
<template #icon><Icon icon="ph:list-checks" /></template>
选题
</n-button>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="emit('cancel')">取消</n-button>
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让任务页传进来的 class 落空 -->
<ProblemPicker
v-model:show="picking"
v-model:language="lessonLanguage"
:initial="pickerInitial"
:saving="saving"
@confirm="pickerConfirm"
v-model:lesson-mode="lessonMode"
/>
</div>
<div v-else>
<n-select v-model:value="lessonMode" :options="modeOptions" aria-label="这节课做什么" size="large" class="mode-select" />
<n-select v-if="needsCode" v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言写" size="large" class="lang-select" />
<n-input v-model:value="lessonInput" size="large" placeholder="输入题号,用空格隔开" class="start-input" @keyup.enter="saveLesson" />
<n-button size="large" @click="openPicker">
<template #icon><Icon icon="ph:list-checks" /></template>
选题
</n-button>
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">布置</n-button>
<ProblemPicker
v-model:show="picking"
v-model:language="lessonLanguage"
:initial="pickerInitial"
:saving="saving"
@confirm="pickerConfirm"
v-model:lesson-mode="lessonMode"
/>
</div>
</template>
<style scoped>
.lang-select {
width: 110px;
flex-shrink: 0;
}
.mode-select {
width: 116px;
flex-shrink: 0;
}
.start-input {
flex: 1 1 0;
font-size: 18px;
}
@media (max-width: 768px) {
.start-input {
flex-basis: 100%;
order: -1;
}
}
</style>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { isSqlProblem } from '@oj2/contract'
import { getProblem } from 'oj/api'
import { errorMessage } from 'utils/api'
import type { ProblemDetail } from 'utils/types'
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
const Markdown = defineAsyncComponent(() => import('shared/components/Markdown.vue'))
/**
* 「讲错误」弹层左边的题目(用户要的:演示学生的错误时题目和代码要并排)。
* 只摆讲题用得上的:描述、输入、输出、例子 —— 题目页的状态标签、流程图、相似题都不要。
*/
const props = defineProps<{ displayId: string }>()
const problem = ref<ProblemDetail | null>(null)
const failed = ref('')
// 同一道题 ← → 换人不重拉:弹层开着时一直是这道题
const cache = new Map<string, ProblemDetail>()
watch(
() => props.displayId,
async (id) => {
failed.value = ''
const hit = cache.get(id)
if (hit) {
problem.value = hit
return
}
problem.value = null
try {
const data = await getProblem(id, '')
cache.set(id, data)
if (props.displayId === id) problem.value = data
} catch (error) {
failed.value = errorMessage(error)
}
},
{ immediate: true },
)
const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value))
</script>
<template>
<div class="brief">
<n-text v-if="failed" type="error">{{ failed }}</n-text>
<n-spin v-else-if="!problem" size="small" />
<template v-else>
<section class="block">
<h3 class="title">描述</h3>
<Markdown html :content="problem.description" />
</section>
<template v-if="!isSQL">
<section v-if="problem.inputDescription" class="block">
<h3 class="title">输入</h3>
<Markdown html :content="problem.inputDescription" />
</section>
<section v-if="problem.outputDescription" class="block">
<h3 class="title">输出</h3>
<Markdown html :content="problem.outputDescription" />
</section>
<section v-if="problem.samples.length" class="block">
<h3 class="title">例子</h3>
<div v-for="(sample, i) in problem.samples" :key="i" class="sample">
<div class="io">
<span class="label">例子 {{ i + 1 }} 输入</span>
<pre>{{ sample.input }}</pre>
</div>
<div class="io">
<span class="label">输出</span>
<pre>{{ sample.output }}</pre>
</div>
</div>
</section>
</template>
</template>
</div>
</template>
<style scoped>
.brief {
display: flex;
flex-direction: column;
gap: 14px;
font-size: 16px;
line-height: 1.75;
}
.title {
margin: 0 0 4px;
padding-left: 8px;
border-left: 3px solid var(--success);
font-size: 15px;
}
.sample {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
margin-bottom: 8px;
}
.io {
min-width: 0;
border: 1px solid var(--divider);
border-radius: 6px;
padding: 4px 10px 6px;
}
.label {
font-size: 12px;
color: var(--text3);
}
pre {
margin: 0;
font-family: Consolas, Monaco, monospace;
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -0,0 +1,544 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode, type Tag } from '@oj2/contract'
import { useThemeVars } from 'naive-ui'
import { getProblemList, getProblemTagList } from 'shared/api'
import { useTone } from 'shared/composables/tone'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import type { ProblemRow } from 'utils/types'
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
/**
* 任务页布置任务的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。
* 点「布置」直接存,和在输入框里敲题号回车是一回事
*/
const props = defineProps<{
/** 打开时已经在框里的题(题号 + 知道的话带上题名) */
initial: { id: string; title?: string }[]
saving: boolean
/** pick = 只把选好的题交回去(后台改一节时用),不直接布置 */
mode?: 'assign' | 'pick'
}>()
const show = defineModel<boolean>('show', { required: true })
const language = defineModel<LessonLanguage>('language', { required: true })
/** 这节课做什么(写代码 / 画流程图 / 先画再写),和任务页上那个下拉是同一个 */
const lessonMode = defineModel<LessonMode>('lessonMode', { required: true })
const needs = computed(() => lessonNeeds(lessonMode.value))
/** 只画流程图不看语言 */
const languageFilter = computed(() => (needs.value.code ? language.value : ''))
const emit = defineEmits<{ confirm: [ids: string[]] }>()
const theme = useThemeVars()
const tone = useTone()
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
const LESSON_MODE_OPTIONS = (['code', 'both', 'flowchart'] as const).map((value) => ({ label: LESSON_MODE_LABEL[value], value }))
const languageOptions = LESSON_LANGUAGES.map((value) => ({ label: LANGUAGE_SHOW_VALUE[value], value }))
const SORTS = [
{ label: '最新出的', value: '' },
{ label: '做过的人最多', value: '-submission_number' },
]
const PAGE_SIZE = 100
/** 老师挑的题,必会题是按课堂用得最多挑出来的,放在知识点前面 */
const MUST_DO = '必会题'
const tags = ref<Tag[]>([])
const tag = ref('')
const keyword = ref('')
const sort = ref('')
const page = ref(1)
const rows = ref<ProblemRow[]>([])
const total = ref(0)
const loading = ref(false)
/** 勾的先后就是这节课的顺序 */
const picked = ref<{ id: string; title?: string }[]>([])
const pickedIndex = computed(() => new Map(picked.value.map((p, i) => [p.id.toLowerCase(), i])))
const knowledge = computed(() => tags.value.filter((t) => t.category === 'knowledge').sort((a, b) => b.problemCount - a.problemCount))
const mustDo = computed(() => tags.value.find((t) => t.name === MUST_DO) ?? null)
const allCount = ref(0)
async function loadTags() {
const list = await getProblemTagList(languageFilter.value, needs.value.draw)
tags.value = list
// 换了语言,原来选的知识点在这个语言下可能一道都没有
if (tag.value && !list.some((t) => t.name === tag.value)) tag.value = ''
}
let seq = 0
async function loadRows() {
const mine = ++seq
loading.value = true
try {
const res = await getProblemList((page.value - 1) * PAGE_SIZE, PAGE_SIZE, {
tag: tag.value,
keyword: keyword.value.trim(),
sort: sort.value,
language: languageFilter.value,
...(needs.value.draw ? { flowchart: '1' } : {}),
})
if (mine !== seq) return
rows.value = res.results
total.value = res.total
if (!tag.value && !keyword.value.trim()) allCount.value = res.total
} finally {
if (mine === seq) loading.value = false
}
}
const listRef = ref<HTMLElement | null>(null)
function reload() {
page.value = 1
listRef.value?.scrollTo({ top: 0 })
loadRows()
}
watch(show, (open) => {
if (!open) return
picked.value = props.initial.map((p) => ({ ...p }))
keyword.value = ''
loadTags()
reload()
})
watch([languageFilter, () => needs.value.draw], () => {
if (!show.value) return
loadTags()
reload()
})
// 搜题号多半是记得那道题、不知道它在哪个知识点下,所以搜的是整个题库;点了知识点就不再搜
function pickTag(name: string) {
keyword.value = ''
tag.value = name
}
watch(keyword, (text) => {
if (text.trim()) tag.value = ''
})
watch([tag, sort], reload)
watchDebounced(keyword, reload, { debounce: 300 })
watch(page, () => {
listRef.value?.scrollTo({ top: 0 })
loadRows()
})
function toggle(row: ProblemRow) {
const key = row._id.toLowerCase()
if (pickedIndex.value.has(key)) picked.value = picked.value.filter((p) => p.id.toLowerCase() !== key)
else picked.value = [...picked.value, { id: row._id, title: row.title }]
}
function remove(id: string) {
picked.value = picked.value.filter((p) => p.id !== id)
}
const emptyText = computed(() => {
if (!needs.value.code) return '没有能画流程图的题'
const text = `没有能用 ${LANGUAGE_SHOW_VALUE[language.value]} 做的题`
return needs.value.draw ? `${text}(还要能画流程图)` : text
})
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
function difficultyStyle(row: ProblemRow) {
if (!row.difficulty) return {}
const t = tone(DIFFICULTY_TONE[row.difficulty])
return { color: t.color, background: t.background }
}
// n-modal 把内容挂到 body 下,<style> 里的 v-bind() 传不进去,颜色走根节点的内联变量
const vars = computed(() => ({
// 宽度也写在这里:n-modal 的 preset card 上 scoped 的 class 宽度不生效
width: 'min(1040px, calc(100vw - 32px))',
'--text2': theme.value.textColor2,
'--text3': theme.value.textColor3,
'--divider': theme.value.dividerColor,
'--hover': theme.value.hoverColor,
'--action': theme.value.actionColor,
'--primary': theme.value.primaryColor,
'--on-bg': tone('success').background,
'--on-text': tone('success').color,
}))
</script>
<template>
<n-modal v-model:show="show" preset="card" :style="vars" :bordered="false" :auto-focus="false" title="选题">
<template #header-extra>
<div class="head-extra">
<n-select v-model:value="lessonMode" :options="LESSON_MODE_OPTIONS" aria-label="这节课做什么" size="small" class="mode" />
<template v-if="needs.code">
<span class="muted">用</span>
<n-select v-model:value="language" :options="languageOptions" aria-label="用什么语言写" size="small" class="lang" />
</template>
</div>
</template>
<div class="body">
<nav class="tags">
<button class="tag" :class="{ on: tag === '' && !keyword.trim() }" @click="pickTag('')">
<span>全部</span><span class="count">{{ allCount || '' }}</span>
</button>
<button v-if="mustDo" class="tag" :class="{ on: tag === MUST_DO }" @click="pickTag(MUST_DO)">
<span>{{ MUST_DO }}</span
><span class="count">{{ mustDo.problemCount }}</span>
</button>
<div class="tag-head">知识点</div>
<button v-for="t in knowledge" :key="t.id" class="tag" :class="{ on: tag === t.name }" @click="pickTag(t.name)">
<span>{{ t.name }}</span
><span class="count">{{ t.problemCount }}</span>
</button>
</nav>
<section class="main">
<div class="filters">
<n-input v-model:value="keyword" placeholder="搜题号或题目名" clearable class="search">
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
</n-input>
<n-select v-model:value="sort" :options="SORTS" class="sort" />
</div>
<div ref="listRef" class="list">
<n-spin :show="loading" class="spin">
<n-empty v-if="!loading && !rows.length" :description="emptyText" class="empty" />
<button v-for="row in rows" :key="row.id" type="button" class="row" :class="{ on: pickedIndex.has(row._id.toLowerCase()) }" @click="toggle(row)">
<span class="check">
<template v-if="pickedIndex.has(row._id.toLowerCase())">{{ pickedIndex.get(row._id.toLowerCase())! + 1 }}</template>
</span>
<span class="pid">{{ row._id }}</span>
<span class="title">
<span class="title-text">{{ row.title }}</span>
<ProblemTypeTag v-if="row.allowFlowchart" kind="flowchart" />
<ProblemTypeTag v-if="row.hasAstRules" kind="ast" />
</span>
<span class="diff" :style="difficultyStyle(row)">{{ row.difficulty ?? '' }}</span>
<span class="people">{{ row.solvedUsers }}/{{ row.triedUsers }}</span>
</button>
</n-spin>
</div>
<div v-if="total > PAGE_SIZE" class="pager">
<n-pagination v-model:page="page" :page-size="PAGE_SIZE" :item-count="total" simple />
</div>
</section>
</div>
<template #footer>
<div class="foot">
<div class="picked">
<span v-if="!picked.length" class="muted">还没选题</span>
<span v-for="(p, i) in picked" :key="p.id" class="pick">
<b>{{ i + 1 }}</b>
<span>{{ p.id }}</span>
<span v-if="p.title" class="pick-title">{{ p.title }}</span>
<button type="button" class="x" :aria-label="`去掉 ${p.id}`" @click="remove(p.id)"><Icon icon="ph:x-bold" /></button>
</span>
</div>
<n-button @click="show = false">取消</n-button>
<n-button
type="primary"
:disabled="!picked.length && (mode === 'pick' || !initial.length)"
:loading="saving"
@click="
emit(
'confirm',
picked.map((p) => p.id),
)
"
>
<template v-if="mode === 'pick'">{{ picked.length ? `用这 ${picked.length} 道` : '选好了' }}</template>
<template v-else>{{ picked.length ? `布置这 ${picked.length} 道` : initial.length ? '清掉' : '布置' }}</template>
</n-button>
</div>
</template>
</n-modal>
</template>
<style scoped>
.head-extra {
display: flex;
align-items: center;
gap: 8px;
}
.lang {
width: 110px;
}
.mode {
width: 116px;
}
.muted {
color: var(--text3);
}
.body {
display: flex;
gap: 16px;
height: min(560px, calc(100vh - 260px));
}
/* ---- 左栏:知识点 ---- */
/* 全部 + 必会题 + 十几个知识点要整列放下、不出滚动条:36px 一行时差 40px,「总是显示滚动条」
的电脑上冒出一根。32px 一行在 1366×768(框高 508px)上是 493px */
.tags {
flex: 0 0 168px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
padding-right: 12px;
border-right: 1px solid var(--divider);
}
.tag-head {
margin: 8px 10px 2px;
font-size: 13px;
color: var(--text3);
}
.tag {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
text-align: left;
cursor: pointer;
flex-shrink: 0;
}
.tag:hover {
background: var(--hover);
}
.tag.on {
background: var(--on-bg);
color: var(--on-text);
font-weight: 600;
}
.count {
font-size: 13px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.tag.on .count {
color: inherit;
}
/* ---- 右边:题 ---- */
.main {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.filters {
display: flex;
gap: 8px;
}
.search {
flex: 1 1 0;
}
.sort {
width: 150px;
}
.list {
flex: 1 1 0;
overflow-y: auto;
}
.spin {
min-height: 100%;
}
.empty {
padding: 60px 0;
}
.row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
height: 44px;
padding: 0 10px;
border: 0;
border-bottom: 1px solid var(--divider);
background: none;
color: inherit;
font: inherit;
font-size: 15px;
text-align: left;
cursor: pointer;
}
.row:hover {
background: var(--hover);
}
.row.on {
background: var(--on-bg);
}
.check {
flex: 0 0 22px;
height: 22px;
border: 1.5px solid var(--text3);
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: #fff;
}
.row.on .check {
border-color: var(--primary);
background: var(--primary);
}
.pid {
flex: 0 0 56px;
font-variant-numeric: tabular-nums;
color: var(--text2);
}
.title {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff {
flex: 0 0 auto;
padding: 1px 8px;
border-radius: 4px;
font-size: 13px;
}
.people {
flex: 0 0 76px;
text-align: right;
font-size: 14px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.pager {
display: flex;
justify-content: flex-end;
}
/* ---- 底下:选好的,按先后 ---- */
.foot {
display: flex;
align-items: center;
gap: 8px;
}
.picked {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pick {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 4px 0 10px;
border-radius: 6px;
background: var(--action);
font-size: 14px;
font-variant-numeric: tabular-nums;
max-width: 260px;
}
.pick b {
color: var(--text3);
}
.pick-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text2);
}
.x {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 0;
border-radius: 4px;
background: none;
color: var(--text3);
cursor: pointer;
flex-shrink: 0;
}
.x:hover {
background: var(--hover);
color: inherit;
}
@media (max-width: 768px) {
.body {
flex-direction: column;
height: calc(100vh - 280px);
}
.tags {
flex: 0 0 auto;
flex-direction: row;
overflow-x: auto;
padding: 0 0 8px;
border-right: 0;
border-bottom: 1px solid var(--divider);
}
.tag-head {
display: none;
}
.people {
display: none;
}
.foot {
flex-wrap: wrap;
}
.picked {
flex-basis: 100%;
}
}
</style>
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui'
import { useTone } from 'shared/composables/tone'
import UserName from 'shared/components/UserName.vue'
import { attemptTone, cachedSubmission } from 'oj/statistics/attempts'
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions'
import type { TaskBoardCell, TaskBoardProblem, Submission } from 'utils/types'
/**
* 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码
* (设计稿「课堂看板重设计」· 做对的格子也看得到轨迹)。
*
* 默认停在做对前最后错的那一次 —— 要讲的一般是「他最后是改了哪儿才对的」。
* 停上去就换,不用点:老师在几格之间来回比;点也行(手机上没有悬停)
*/
const props = defineProps<{
username: string
problem: TaskBoardProblem
cell: TaskBoardCell
summary: string
/** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */
listHref: string
}>()
defineEmits<{ close: [] }>()
const tone = useTone()
const theme = useThemeVars()
const picked = ref(props.cell.acceptedTry ? Math.max(props.cell.acceptedTry - 2, 0) : Math.max(props.cell.trail.length - 1, 0))
const item = computed(() => props.cell.trail[picked.value] ?? props.cell.trail.at(-1))
const code = reactive<Record<string, Submission | 'loading' | 'failed'>>({})
watch(
() => item.value?.id,
async (id) => {
if (!id || (code[id] && code[id] !== 'failed')) return
code[id] = 'loading'
try {
code[id] = await cachedSubmission(id)
} catch {
code[id] = 'failed'
}
},
{ immediate: true },
)
const shown = computed(() => {
const value = item.value && code[item.value.id]
return typeof value === 'object' ? value : null
})
const squares = computed(() =>
props.cell.trail.map((row) => ({
...row,
color: tone(attemptTone(row.result)).solid,
})),
)
</script>
<template>
<div class="trail">
<div class="head">
<UserName :username="username" />
<span class="problem">
<b>{{ problem.problemDisplayId }}</b>
<span class="muted">{{ problem.title }}</span>
</span>
<div class="spacer"></div>
<n-button quaternary size="tiny" aria-label="收起" @click="$emit('close')">
<template #icon><Icon icon="ph:x-bold" /></template>
</n-button>
</div>
<div class="line">
<div class="squares">
<button
v-for="(row, index) in squares"
:key="row.id"
type="button"
class="square"
:class="{ on: index === picked }"
:style="{ background: row.color }"
:aria-label="`第 ${index + 1} 次:${JUDGE_STATUS[row.result]?.name ?? ''}`"
@mouseenter="picked = index"
@click="picked = index"
></button>
</div>
<span class="summary">{{ summary }}</span>
</div>
<div v-if="item" class="code">
<div class="code-head">
<b :style="{ color: tone(attemptTone(item.result)).color }">
{{ JUDGE_STATUS[item.result]?.name }}
</b>
<span class="muted">{{ parseTime(item.createTime, 'HH:mm') }} · 第 {{ picked + 1 }} 次</span>
</div>
<n-code v-if="shown" class="code-body" :code="shown.code" :language="LANGUAGE_FORMAT_VALUE[shown.language]" show-line-numbers />
<span v-else-if="code[item.id] === 'failed'" class="muted">代码没拉下来,点下面打开看</span>
<span v-else class="muted">代码加载中…</span>
</div>
<div class="links">
<a v-if="item" :href="`/submission/${item.id}`" target="_blank">打开这次提交 ↗</a>
<a :href="listHref" target="_blank">在提交列表看这 {{ cell.trail.length }} 次 ↗</a>
</div>
</div>
</template>
<style scoped>
.trail {
display: flex;
flex-direction: column;
gap: 12px;
font-size: 14px;
}
.head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-size: 15px;
white-space: nowrap;
}
.problem {
display: flex;
gap: 6px;
min-width: 0;
font-size: 14px;
}
.problem .muted {
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.line {
display: flex;
align-items: center;
gap: 14px;
}
.squares {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.square {
flex: none;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 4px;
cursor: pointer;
}
.square.on {
box-shadow:
0 0 0 2px v-bind('theme.cardColor'),
0 0 0 4px v-bind('theme.textColor1');
}
.summary {
white-space: nowrap;
font-variant-numeric: tabular-nums;
color: v-bind('theme.textColor2');
}
.code {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 6px;
background: v-bind('theme.actionColor');
}
.code-head {
display: flex;
align-items: center;
gap: 10px;
}
.code-body {
max-height: 320px;
overflow: auto;
font-size: 15px;
}
.links {
display: flex;
gap: 18px;
}
.links a {
color: v-bind('theme.primaryColor');
text-decoration: none;
}
.links a:hover {
text-decoration: underline;
}
.muted {
color: v-bind('theme.textColor3');
}
</style>
File diff suppressed because it is too large. Load diff