布置时多一个「做什么」:写代码 / 先画再写 / 画流程图(只能选打开了画流程图的题)。 - 先画再写:一道题图画到 A/S、代码也交对才算做完;学生画一道写一道、全画完再写都行, 打开题目自动停在该做的那一步(图没画对停画布,画对了换到布置的语言) - 也能分开布置:先一节画流程图,看板上「用这几道题写代码」再开一节;学生那边把今天 几节课的要求合起来,还没画完的照样看得到要画 - 看板格子是一条进度:图 1/6 → 空心「图 ✓」→ 代码 ✗ → ✓,先写代码没画图的写「缺图」; 卡在画图上的点开讲那张图(要改的框标红 + AI 的哪里错/怎么改),按走对几组归堆 - 写代码的课不再拿画对的流程图顶替做完;数据统计里画流程图的课数流程图提交 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
71323d9b63
commit
38a331c2b7
33 files changed
+4987
-209
No files matched your search
@@ -57,6 +57,7 @@ import {
|
||||
type SubmissionStatistics,
|
||||
type SubmissionLessons,
|
||||
type LessonLanguage,
|
||||
type LessonMode,
|
||||
type SubmissionStatisticsGrid,
|
||||
type TodaySubmissionStatistics,
|
||||
} from '@oj2/contract'
|
||||
@@ -264,11 +265,12 @@ export function getClassLesson(className?: string) {
|
||||
* 给这个班布置今天的一节课,空数组 = 清掉这一节。`lessonId` 为 null 是今天新开一节
|
||||
* (一个班一天可以上几节内容不同的课),否则改那一节
|
||||
*/
|
||||
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, lessonId: number | null) {
|
||||
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) {
|
||||
return api.put<null>('classroom/lesson', {
|
||||
className,
|
||||
problemDisplayIds,
|
||||
language,
|
||||
mode,
|
||||
...(lessonId === null ? { newLesson: true } : { lessonId }),
|
||||
} satisfies ClassLessonRequest)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import LessonForm from './components/LessonForm.vue'
|
||||
import TrailPanel from './components/TrailPanel.vue'
|
||||
import { errorKind, type ErrorKind } from 'utils/errorKind'
|
||||
import { cellStyle as cellStyleOf, cellText, cellTitle as cellTitleOf, STUCK_ATTEMPTS, trailSummary } from './boardCells'
|
||||
import { LESSON_MODE_LABEL } from '@oj2/contract'
|
||||
import { cellStage, cellStyle as cellStyleOf, cellText as cellTextOf, cellTitle as cellTitleOf, isStuck, trailSummary } from './boardCells'
|
||||
|
||||
/**
|
||||
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
|
||||
@@ -114,8 +115,14 @@ onUnmounted(pause)
|
||||
|
||||
// ---------- 这节课的题:上课时当场填 ----------
|
||||
|
||||
/** 顶上那条布置框开着没有:edit = 改这一节,new = 今天再开一节 */
|
||||
const editing = ref<false | 'edit' | 'new'>(false)
|
||||
/**
|
||||
* 顶上那条布置框开着没有:edit = 改这一节,new = 今天再开一节,
|
||||
* carry = 再开一节、带上这一节的题改成写代码(这一节是画流程图时才有)
|
||||
*/
|
||||
const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
|
||||
|
||||
/** 这节课做什么;推断出来的课为 null(代码交对或流程图画对都算) */
|
||||
const mode = computed(() => board.value?.mode ?? null)
|
||||
|
||||
/**
|
||||
* 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久,
|
||||
@@ -192,7 +199,7 @@ function groupOf(student: ClassBoardStudent): Group {
|
||||
const cells = student.cells
|
||||
if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done'
|
||||
if (!student.lastSubmitAt) return 'idle'
|
||||
if (cells.some((cell) => cell.status === 'tried' && cell.attempts >= STUCK_ATTEMPTS)) return 'stuck'
|
||||
if (cells.some((cell) => isStuck(cell, mode.value))) return 'stuck'
|
||||
return 'working'
|
||||
}
|
||||
|
||||
@@ -296,25 +303,55 @@ interface WrongPerson {
|
||||
student: ClassBoardStudent
|
||||
cell: ClassBoardCell
|
||||
kind: ErrorKind
|
||||
/** 点名字讲的是哪一份:代码(cell.latest)还是最后一张图(cell.flowchart.latest);null = 没有可讲的 */
|
||||
explain: 'code' | 'draw' | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡在画流程图上的人按最后一张图归堆:一组都没走对的、对了一部分的分开讲;还在评、评失败的、
|
||||
* 老评法(数不出几组)各一堆
|
||||
*/
|
||||
function drawKind(cell: ClassBoardCell): ErrorKind {
|
||||
const latest = cell.flowchart?.latest
|
||||
if (!latest) return { key: 'draw-none', label: '还没画对', line: null }
|
||||
if (latest.status === 0 || latest.status === 1) return { key: 'draw-wait', label: 'AI 在评', line: null }
|
||||
if (latest.status === 3) return { key: 'draw-failed', label: '评分失败', line: null }
|
||||
if (latest.passed === null) return { key: 'draw-old', label: '还没画对', line: null }
|
||||
return latest.passed > 0 ? { key: 'draw-some', label: '图对了一部分', line: null } : { key: 'draw-zero', label: '图一组都没走对', line: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 没做对的人:今天交过代码的按最后一次提交归错法。另外两种没有今天的代码可讲,单独成堆,
|
||||
* 和卡片上「错着 N」的 N 对得上:今天只画了流程图的,以前交过没做对、今天还没动的
|
||||
* 和卡片上「错着 N」的 N 对得上:今天只画了流程图的,以前交过没做对、今天还没动的。
|
||||
* 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆
|
||||
*/
|
||||
function wrongOf(index: number): WrongPerson[] {
|
||||
return present.value.flatMap((student) => {
|
||||
return present.value.flatMap((student): WrongPerson[] => {
|
||||
const cell = student.cells[index]
|
||||
if (!cell || cell.status !== 'tried') return []
|
||||
const stage = cellStage(cell, mode.value)
|
||||
if (stage === 'draw') {
|
||||
const latest = cell.flowchart?.latest
|
||||
const evaluated = !!latest && latest.status !== 0 && latest.status !== 1
|
||||
return [{ student, cell, kind: drawKind(cell), explain: evaluated ? 'draw' : null }]
|
||||
}
|
||||
if (stage === 'nodraw') return [{ student, cell, kind: { key: 'nodraw', label: '代码对了,还没画图', line: null }, explain: null }]
|
||||
if (mode.value === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') {
|
||||
return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }]
|
||||
}
|
||||
// 推断出来的课不分步:今天没代码、却画了流程图的单独一堆
|
||||
const kind: ErrorKind = cell.latest
|
||||
? errorKind(cell.latest)
|
||||
: cell.attempts
|
||||
: !mode.value && cell.attempts
|
||||
? { key: 'flowchart', label: '今天只画了流程图', line: null }
|
||||
: { key: 'before', label: '以前交过,今天还没交', line: null }
|
||||
return [{ student, cell, kind }]
|
||||
return [{ student, cell, kind, explain: cell.latest ? 'code' : null }]
|
||||
})
|
||||
}
|
||||
|
||||
/** 没有今天的错可讲的那几堆:灰底,不是今天的错 */
|
||||
const QUIET_KINDS = new Set(['before', 'flowchart', 'nodraw', 'drawn', 'draw-wait'])
|
||||
|
||||
const problemStats = computed(() =>
|
||||
(board.value?.problems ?? []).map((problem, i) => {
|
||||
const done = present.value.filter((student) => student.cells[i]?.status === 'accepted').length
|
||||
@@ -322,10 +359,23 @@ const problemStats = computed(() =>
|
||||
const total = present.value.length
|
||||
// 挑战成功几人:只有配了挑战的题才写
|
||||
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 }
|
||||
// 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码
|
||||
const steps =
|
||||
mode.value === 'both'
|
||||
? {
|
||||
drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length,
|
||||
coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length,
|
||||
}
|
||||
: null
|
||||
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps }
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* 先画再写的课,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
|
||||
*/
|
||||
const wrongWord = computed(() => (mode.value === 'both' ? '没做完' : '错着'))
|
||||
|
||||
const selectedId = ref<number | null>(null)
|
||||
const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value))
|
||||
function toggleProblem(problemId: number) {
|
||||
@@ -360,19 +410,18 @@ const explainProblem = computed(() => board.value?.problems[explainProblemIndex.
|
||||
/** 弹层顶上那排人:这道题错着的人,顺序和错法堆一样 */
|
||||
const explainPeople = computed<ExplainPerson[]>(() => {
|
||||
if (explainProblemIndex.value < 0) return []
|
||||
const people = wrongOf(explainProblemIndex.value).filter((person) => person.cell.latest)
|
||||
const people = wrongOf(explainProblemIndex.value).filter((person) => person.explain)
|
||||
const order = new Map<string, number>()
|
||||
for (const person of people) {
|
||||
order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1)
|
||||
}
|
||||
return people
|
||||
.sort((a, b) => order.get(b.kind.key)! - order.get(a.kind.key)! || a.kind.key.localeCompare(b.kind.key) || b.cell.attempts - a.cell.attempts)
|
||||
.map((person) => ({
|
||||
userId: person.student.userId,
|
||||
username: person.student.username,
|
||||
submissionId: person.cell.latest!.submissionId,
|
||||
label: person.kind.label,
|
||||
}))
|
||||
.map((person) =>
|
||||
person.explain === 'draw'
|
||||
? { userId: person.student.userId, username: person.student.username, flowchartId: person.cell.flowchart!.latest!.id, label: person.kind.label }
|
||||
: { userId: person.student.userId, username: person.student.username, submissionId: person.cell.latest!.submissionId, label: person.kind.label },
|
||||
)
|
||||
})
|
||||
|
||||
function openExplain(problemIndex: number, userId: number) {
|
||||
@@ -390,9 +439,22 @@ const cellColors = computed(() => ({
|
||||
tone,
|
||||
errorColor: theme.value.errorColor,
|
||||
actionColor: theme.value.actionColor,
|
||||
successColor: theme.value.successColor,
|
||||
}))
|
||||
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value)
|
||||
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) => cellTitleOf(cell, student, board.value?.day)
|
||||
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
|
||||
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
|
||||
const cellText = (cell: ClassBoardCell) => cellTextOf(cell, mode.value)
|
||||
|
||||
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
|
||||
function explainable(cell: ClassBoardCell) {
|
||||
if (cell.status !== 'tried') return false
|
||||
const stage = cellStage(cell, mode.value)
|
||||
if (stage === 'draw') {
|
||||
const latest = cell.flowchart?.latest
|
||||
return !!latest && latest.status !== 0 && latest.status !== 1
|
||||
}
|
||||
return stage === 'code' && !!cell.latest
|
||||
}
|
||||
|
||||
/**
|
||||
* 点开着轨迹的那个格子(学生 id:第几道题)。做对了、今天交过代码的格子点了不再直接去提交列表,
|
||||
@@ -426,6 +488,8 @@ function submissionsHref(
|
||||
) {
|
||||
// SQL 题不受布置的语言限制,格子数的是 SQL 交的
|
||||
const language = problem?.isSql ? 'SQL' : board.value?.language
|
||||
// 只画流程图的课,格子数的全是流程图
|
||||
const flowSide = flowchart || mode.value === 'flowchart'
|
||||
return router.resolve({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
@@ -433,7 +497,7 @@ function submissionsHref(
|
||||
exactUsername: '1',
|
||||
...(today ? { today: '1' } : {}),
|
||||
// 布置时选了语言的,代码那边只看这个语言交的(和格子里数的一致)
|
||||
...(flowchart ? { language: 'Flowchart' } : language ? { language } : {}),
|
||||
...(flowSide ? { language: 'Flowchart' } : language ? { language } : {}),
|
||||
...(problem ? { problem: problem.problemDisplayId } : {}),
|
||||
},
|
||||
}).href
|
||||
@@ -459,6 +523,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
:board="board"
|
||||
:class-name="className"
|
||||
:lesson-id="editing === 'edit' ? board.lessonId : null"
|
||||
:carry="editing === 'carry'"
|
||||
variant="inline"
|
||||
class="lesson-edit"
|
||||
@saved="lessonSaved"
|
||||
@@ -466,6 +531,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
/>
|
||||
<div v-else-if="board?.problems.length && !needLesson" class="lesson">
|
||||
<span class="lesson-label">{{ board.source === 'teacher' ? '这节课' : '今天在做' }}</span>
|
||||
<span v-if="board.mode && board.mode !== 'code'" class="chip lang">{{ LESSON_MODE_LABEL[board.mode] }}</span>
|
||||
<span v-if="board.language" class="chip lang">{{ LANGUAGE_SHOW_VALUE[board.language] }}</span>
|
||||
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">
|
||||
{{ problem.problemDisplayId }}
|
||||
@@ -480,6 +546,11 @@ function lastText(student: ClassBoardStudent) {
|
||||
<template #icon><Icon icon="ph:plus" /></template>
|
||||
新的一节
|
||||
</n-button>
|
||||
<!-- 画完流程图让全班写代码:新开一节、带上这几道题(学生那边还没画完的照样看得到要画) -->
|
||||
<n-button v-if="board.source === 'teacher' && board.mode === 'flowchart'" text type="primary" class="edit" @click="editing = 'carry'">
|
||||
<template #icon><Icon icon="ph:code" /></template>
|
||||
用这几道题写代码
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
@@ -527,8 +598,9 @@ function lastText(student: ClassBoardStudent) {
|
||||
<span class="bar">
|
||||
<i :style="{ width: stat.total ? `${(stat.done / stat.total) * 100}%` : '0' }"></i>
|
||||
</span>
|
||||
<span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span>
|
||||
<span class="card-foot">
|
||||
<span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
|
||||
<span :class="stat.wrong ? 'wrong' : 'muted'">{{ wrongWord }} {{ stat.wrong }}</span>
|
||||
<span class="muted">没做 {{ stat.none }}</span>
|
||||
<span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span>
|
||||
</span>
|
||||
@@ -551,7 +623,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
{{ board.problems[selectedIndex]!.title }}
|
||||
</a>
|
||||
<span>
|
||||
错着的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
|
||||
{{ wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
|
||||
</span>
|
||||
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
|
||||
<div class="spacer"></div>
|
||||
@@ -560,12 +632,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
</n-button>
|
||||
</div>
|
||||
<div v-if="wrongGroups.length" class="groups">
|
||||
<div
|
||||
v-for="group in wrongGroups"
|
||||
:key="group.kind.key"
|
||||
class="err-group"
|
||||
:class="{ quiet: group.kind.key === 'before' || group.kind.key === 'flowchart' }"
|
||||
>
|
||||
<div v-for="group in wrongGroups" :key="group.kind.key" class="err-group" :class="{ quiet: QUIET_KINDS.has(group.kind.key) }">
|
||||
<div class="err-title">
|
||||
{{ group.kind.label }}
|
||||
<span class="wrong">{{ group.people.length }} 人</span>
|
||||
@@ -573,15 +640,22 @@ function lastText(student: ClassBoardStudent) {
|
||||
</div>
|
||||
<div class="err-people">
|
||||
<template v-for="person in group.people" :key="person.student.userId">
|
||||
<button v-if="person.cell.latest" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)">
|
||||
<button v-if="person.explain" type="button" class="who" @click="openExplain(selectedIndex, person.student.userId)">
|
||||
<UserName :username="person.student.username" />
|
||||
<span class="mini" :style="cellStyle(person.cell)">✗ {{ person.cell.attempts }}</span>
|
||||
<span class="mini" :style="cellStyle(person.cell)">{{ cellText(person.cell) }}</span>
|
||||
</button>
|
||||
<!-- 没有今天的代码可讲:去提交列表看他交过的 -->
|
||||
<a
|
||||
v-else
|
||||
class="who"
|
||||
:href="submissionsHref(person.student, board.problems[selectedIndex], !!person.cell.attempts, !!person.cell.attempts)"
|
||||
:href="
|
||||
submissionsHref(
|
||||
person.student,
|
||||
board.problems[selectedIndex],
|
||||
person.kind.key === 'flowchart' || person.kind.key.startsWith('draw'),
|
||||
!!person.cell.attempts,
|
||||
)
|
||||
"
|
||||
target="_blank"
|
||||
>
|
||||
<UserName :username="person.student.username" />
|
||||
@@ -672,7 +746,7 @@ function lastText(student: ClassBoardStudent) {
|
||||
</td>
|
||||
<td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }">
|
||||
<button
|
||||
v-if="cell.status === 'tried' && cell.latest"
|
||||
v-if="explainable(cell)"
|
||||
type="button"
|
||||
class="cell"
|
||||
:data-challenge="cell.challenge ?? undefined"
|
||||
@@ -919,6 +993,12 @@ function lastText(student: ClassBoardStudent) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.card-steps {
|
||||
font-size: 13px;
|
||||
color: v-bind('theme.textColor2');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { LessonMode } from '@oj2/contract'
|
||||
import type { Tone } from 'shared/composables/tone'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { ClassBoardCell, ClassBoardStudent } from 'utils/types'
|
||||
@@ -5,20 +6,71 @@ import type { ClassBoardCell, ClassBoardStudent } 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: ClassBoardCell, 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: ClassBoardCell, 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: ClassBoardCell, mode: LessonMode | null) {
|
||||
return cell.status === 'tried' && stageAttempts(cell, mode) >= STUCK_ATTEMPTS
|
||||
}
|
||||
|
||||
/** 先画再写:图画对了、代码还没交过 —— 空心绿的「图 ✓」 */
|
||||
function halfDone(cell: ClassBoardCell, mode: LessonMode | null) {
|
||||
return mode === 'both' && cell.status === 'tried' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none'
|
||||
}
|
||||
|
||||
/** 今天之前就通过了的,颜色淡一档。`day` 是看板的「今天」(东八区日期) */
|
||||
export function acceptedToday(cell: ClassBoardCell, day: string | undefined) {
|
||||
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day
|
||||
}
|
||||
|
||||
/** 最后一张图的那句:评分中 / 评失败 / 走对几组 */
|
||||
function drawText(cell: ClassBoardCell, 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: ClassBoardCell) {
|
||||
export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) {
|
||||
if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
|
||||
if (cell.status === 'tried') return cell.attempts ? `✗ ${cell.attempts}` : '✗'
|
||||
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 ''
|
||||
}
|
||||
|
||||
@@ -29,14 +81,19 @@ export function cellStyle(
|
||||
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 (cell.attempts >= STUCK_ATTEMPTS) {
|
||||
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')
|
||||
@@ -45,7 +102,7 @@ export function cellStyle(
|
||||
return { background: colors.actionColor }
|
||||
}
|
||||
|
||||
export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day: string | undefined) {
|
||||
export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, 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},挑战也做到了`
|
||||
@@ -53,7 +110,16 @@ export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day:
|
||||
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 ''
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
export interface ExplainPerson {
|
||||
/**
|
||||
* 弹层顶上那排人里的一个。讲代码给 submissionId;要画流程图的课里卡在画图上的,给他最后
|
||||
* 那张图的 flowchartId —— 下面就换成图和 AI 的点评
|
||||
*/
|
||||
export type ExplainPerson = {
|
||||
userId: number
|
||||
username: string
|
||||
submissionId: string
|
||||
/** 错法的短名(errorKind),挂在名字后面 */
|
||||
label: string
|
||||
}
|
||||
} & ({ submissionId: string; flowchartId?: undefined } | { flowchartId: string; submissionId?: undefined })
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -17,6 +20,7 @@ import UserName from 'shared/components/UserName.vue'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import type { ClassBoardProblem } from 'utils/types'
|
||||
import FlowchartExplain from './FlowchartExplain.vue'
|
||||
import ProblemBrief from './ProblemBrief.vue'
|
||||
|
||||
/**
|
||||
@@ -106,7 +110,7 @@ onKeyStroke(['ArrowLeft', 'ArrowRight'], (event) => {
|
||||
>
|
||||
题目
|
||||
</n-button>
|
||||
<n-button v-if="current" :size="isMobile ? 'small' : 'medium'" tag="a" :href="'/submission/' + current.submissionId" target="_blank">
|
||||
<n-button v-if="current?.submissionId" :size="isMobile ? 'small' : 'medium'" tag="a" :href="'/submission/' + current.submissionId" target="_blank">
|
||||
在新页面打开
|
||||
</n-button>
|
||||
</div>
|
||||
@@ -119,7 +123,8 @@ onKeyStroke(['ArrowLeft', 'ArrowRight'], (event) => {
|
||||
<ProblemBrief :display-id="problem.problemDisplayId" />
|
||||
</aside>
|
||||
<div class="main">
|
||||
<SubmissionView v-if="current" :id="current.submissionId" embedded />
|
||||
<FlowchartExplain v-if="current?.flowchartId" :id="current.flowchartId" />
|
||||
<SubmissionView v-else-if="current?.submissionId" :id="current.submissionId" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { LessonLanguage } from '@oj2/contract'
|
||||
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'
|
||||
@@ -12,13 +12,15 @@ import ProblemPicker from './ProblemPicker.vue'
|
||||
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
|
||||
* - `start`:刚上课还没有题时,这页最大的那个框。
|
||||
*
|
||||
* 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节
|
||||
* 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节。
|
||||
* `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用
|
||||
*/
|
||||
const props = defineProps<{
|
||||
board: ClassBoard
|
||||
className: string
|
||||
variant: 'inline' | 'start'
|
||||
lessonId: number | null
|
||||
carry?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ saved: []; cancel: [] }>()
|
||||
const message = useMessage()
|
||||
@@ -33,6 +35,13 @@ 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)
|
||||
|
||||
@@ -40,8 +49,10 @@ const saving = ref(false)
|
||||
const editingPlanned = computed(() => props.lessonId !== null && props.lessonId === props.board.lessonId)
|
||||
|
||||
function resetLessonInput() {
|
||||
lessonInput.value = editingPlanned.value ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : ''
|
||||
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()
|
||||
@@ -52,7 +63,7 @@ if (props.variant === 'inline' && props.board.source === 'inferred' && !lessonIn
|
||||
// 换了班、换了一节要把输入框换成那一节的题、布置时选的语言(15 秒一次的刷新不动它们)。
|
||||
// 必须写成几个单值来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的,
|
||||
// 回调次次都跑,老师打到一半的题号每 15 秒被清一次
|
||||
watch([() => props.board.className, () => props.board.source, () => props.lessonId], resetLessonInput)
|
||||
watch([() => props.board.className, () => props.board.source, () => props.lessonId, () => props.carry], resetLessonInput)
|
||||
|
||||
function inputIds() {
|
||||
return lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
|
||||
@@ -62,7 +73,7 @@ async function saveLesson() {
|
||||
const ids = inputIds()
|
||||
saving.value = true
|
||||
try {
|
||||
await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null, props.lessonId)
|
||||
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')
|
||||
@@ -89,7 +100,8 @@ function pickerConfirm(ids: string[]) {
|
||||
|
||||
<template>
|
||||
<div v-if="variant === 'inline'">
|
||||
<n-select v-model:value="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" class="lang-select" />
|
||||
<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>
|
||||
@@ -98,17 +110,32 @@ function pickerConfirm(ids: string[]) {
|
||||
<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" />
|
||||
<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="lessonLanguage" :options="languageOptions" aria-label="用什么语言做" size="large" class="lang-select" />
|
||||
<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" />
|
||||
<ProblemPicker
|
||||
v-model:show="picking"
|
||||
v-model:language="lessonLanguage"
|
||||
:initial="pickerInitial"
|
||||
:saving="saving"
|
||||
@confirm="pickerConfirm"
|
||||
v-model:lesson-mode="lessonMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -118,6 +145,11 @@ function pickerConfirm(ids: string[]) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-select {
|
||||
width: 116px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.start-input {
|
||||
flex: 1 1 0;
|
||||
font-size: 18px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { LessonLanguage, Tag } from '@oj2/contract'
|
||||
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'
|
||||
@@ -10,7 +10,8 @@ import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||
|
||||
/**
|
||||
* 看板布置作业的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
|
||||
* 只列能用这次作业的语言交的题(左栏题数也按这个语言数),不用再一个个记题号。
|
||||
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
|
||||
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。
|
||||
* 点「布置」直接存,和在输入框里敲题号回车是一回事
|
||||
*/
|
||||
const props = defineProps<{
|
||||
@@ -22,12 +23,18 @@ const props = defineProps<{
|
||||
}>()
|
||||
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: '' },
|
||||
@@ -55,7 +62,7 @@ const mustDo = computed(() => tags.value.find((t) => t.name === MUST_DO) ?? null
|
||||
const allCount = ref(0)
|
||||
|
||||
async function loadTags() {
|
||||
const list = await getProblemTagList(language.value)
|
||||
const list = await getProblemTagList(languageFilter.value, needs.value.draw)
|
||||
tags.value = list
|
||||
// 换了语言,原来选的知识点在这个语言下可能一道都没有
|
||||
if (tag.value && !list.some((t) => t.name === tag.value)) tag.value = ''
|
||||
@@ -70,7 +77,8 @@ async function loadRows() {
|
||||
tag: tag.value,
|
||||
keyword: keyword.value.trim(),
|
||||
sort: sort.value,
|
||||
language: language.value,
|
||||
language: languageFilter.value,
|
||||
...(needs.value.draw ? { flowchart: '1' } : {}),
|
||||
})
|
||||
if (mine !== seq) return
|
||||
rows.value = res.results
|
||||
@@ -95,7 +103,7 @@ watch(show, (open) => {
|
||||
loadTags()
|
||||
reload()
|
||||
})
|
||||
watch(language, () => {
|
||||
watch([languageFilter, () => needs.value.draw], () => {
|
||||
if (!show.value) return
|
||||
loadTags()
|
||||
reload()
|
||||
@@ -125,6 +133,12 @@ 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 {}
|
||||
@@ -151,9 +165,11 @@ const vars = computed(() => ({
|
||||
<n-modal v-model:show="show" preset="card" :style="vars" :bordered="false" :auto-focus="false" title="选题">
|
||||
<template #header-extra>
|
||||
<div class="head-extra">
|
||||
<span class="muted">用</span>
|
||||
<n-select v-model:value="language" :options="languageOptions" aria-label="用什么语言做" size="small" class="lang" />
|
||||
<span class="muted">做</span>
|
||||
<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>
|
||||
|
||||
@@ -183,7 +199,7 @@ const vars = computed(() => ({
|
||||
|
||||
<div ref="listRef" class="list">
|
||||
<n-spin :show="loading" class="spin">
|
||||
<n-empty v-if="!loading && !rows.length" :description="`没有能用 ${LANGUAGE_SHOW_VALUE[language]} 做的题`" class="empty" />
|
||||
<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>
|
||||
@@ -247,6 +263,10 @@ const vars = computed(() => ({
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.mode {
|
||||
width: 116px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
@@ -190,6 +190,9 @@ const classActivityTitle = computed(() => {
|
||||
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
||||
if (classActivity.value?.source === 'teacher') {
|
||||
const language = classActivity.value.language
|
||||
const mode = classActivity.value.mode
|
||||
if (mode === 'flowchart') return '这节课 · 画流程图'
|
||||
if (mode === 'both') return `这节课 · 先画流程图,再${language ? `用${LANGUAGE_SHOW_VALUE[language]}` : ''}写代码`
|
||||
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ''}`
|
||||
}
|
||||
if (classToday.value) return '这节课'
|
||||
@@ -203,6 +206,17 @@ const MY_STATUS: Record<ClassActivityProblem['myStatus'], { label: string; type:
|
||||
none: { label: '还没做', type: 'default' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 每行前面那个小标签。要画又要写的题只做到一步时说差哪一步 ——「没做对」会让人以为是代码错了
|
||||
*/
|
||||
function statusTag(item: ClassActivityProblem) {
|
||||
if (item.myStatus === 'tried' && item.draw && item.code) {
|
||||
if (item.draw === 'accepted') return { label: '还要写代码', type: 'warning' as const }
|
||||
if (item.code === 'accepted') return { label: '还要画图', type: 'warning' as const }
|
||||
}
|
||||
return MY_STATUS[item.myStatus]
|
||||
}
|
||||
|
||||
async function loadWeekly() {
|
||||
weekly.value = await getWeeklyRank(userStore.user?.className ? 'class' : 'global')
|
||||
}
|
||||
@@ -291,8 +305,8 @@ function contestTime(contest: Contest) {
|
||||
<n-text depth="3" class="row-meta"> {{ classActivity.problems.length }} 道做对了 {{ doneInClass }} 道 </n-text>
|
||||
</template>
|
||||
<router-link v-for="item in classActivity.problems" :key="item.problemDisplayId" :to="`/problem/${item.problemDisplayId}`" class="row">
|
||||
<n-tag :type="MY_STATUS[item.myStatus].type" size="small" :bordered="false">
|
||||
{{ MY_STATUS[item.myStatus].label }}
|
||||
<n-tag :type="statusTag(item).type" size="small" :bordered="false">
|
||||
{{ statusTag(item).label }}
|
||||
</n-tag>
|
||||
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
|
||||
<!-- 老师刚布置、还没人交的时候不显示「0/0」 -->
|
||||
@@ -346,8 +360,8 @@ function contestTime(contest: Contest) {
|
||||
<n-text depth="3" class="row-meta">{{ classActivity.className }} 班</n-text>
|
||||
</template>
|
||||
<router-link v-for="item in classActivity.problems" :key="item.problemDisplayId" :to="`/problem/${item.problemDisplayId}`" class="row">
|
||||
<n-tag :type="MY_STATUS[item.myStatus].type" size="small" :bordered="false">
|
||||
{{ MY_STATUS[item.myStatus].label }}
|
||||
<n-tag :type="statusTag(item).type" size="small" :bordered="false">
|
||||
{{ statusTag(item).label }}
|
||||
</n-tag>
|
||||
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
|
||||
<n-text v-if="item.userCount" depth="3" class="row-meta"> {{ item.acceptedCount }}/{{ item.userCount }} 人做对 </n-text>
|
||||
|
||||
@@ -53,17 +53,17 @@ const currentId = computed(() => problem.value?._id ?? '')
|
||||
* 换题时 id 跟着变,那一次不算;流程图只认这一页上刚评完的(evaluatedSeq)
|
||||
*/
|
||||
const flowchartStore = useFlowchartStore()
|
||||
function onSolvedHere(callback: () => void) {
|
||||
function onSolvedHere(callback: (step: 'code' | 'draw') => void) {
|
||||
watch(
|
||||
() => [problem.value?.id, problem.value?.myStatus] as const,
|
||||
([id, status], [previousId, previousStatus]) => {
|
||||
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback()
|
||||
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback('code')
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => flowchartStore.evaluatedSeq,
|
||||
() => {
|
||||
if (isFlowchartPass(flowchartStore.latestRating.grade)) callback()
|
||||
if (isFlowchartPass(flowchartStore.latestRating.grade)) callback('draw')
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -90,6 +90,7 @@ const lesson = computed(() => {
|
||||
title: item.title,
|
||||
status: item.myStatus === 'accepted' ? 'done' : item.myStatus === 'tried' ? 'tried' : ('none' as const),
|
||||
to: `/problem/${item.problemDisplayId}`,
|
||||
note: stepNote(item),
|
||||
}))
|
||||
return {
|
||||
chips,
|
||||
@@ -101,9 +102,20 @@ const lesson = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onSolvedHere(() => {
|
||||
/**
|
||||
* 要画又要写的题(先画再写,或者今天先后布置了画流程图和写代码):只做到一步时说清楚差哪一步,
|
||||
* 光写「做过,还没对」学生会以为是代码错了
|
||||
*/
|
||||
function stepNote(item: { draw: string | null; code: string | null; myStatus: string }) {
|
||||
if (!item.draw || !item.code || item.myStatus === 'accepted') return undefined
|
||||
if (item.draw === 'accepted') return '流程图画对了,还要写代码'
|
||||
if (item.code === 'accepted') return '代码做对了,还要画流程图'
|
||||
return '先画流程图,再写代码'
|
||||
}
|
||||
|
||||
onSolvedHere((step) => {
|
||||
if (ctx.value.entry !== 'problem' || !lessonStore.includes(currentId.value)) return
|
||||
lessonStore.markAccepted(currentId.value)
|
||||
lessonStore.markAccepted(currentId.value, step)
|
||||
lessonStore.load(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { useCodeStore } from 'oj/store/code'
|
||||
import { isFlowchartPass, useFlowchartStore } from 'oj/store/flowchart'
|
||||
import { useProblemStore } from 'oj/store/problem'
|
||||
import { useLessonStore } from 'shared/store/lesson'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { sortFlowchartCriteria } from 'utils/constants'
|
||||
import { parseTime } from 'utils/functions'
|
||||
@@ -173,9 +174,19 @@ watch(
|
||||
onBeforeUnmount(() => editor.value?.setHighlight(null))
|
||||
|
||||
/** A / S 之后:照着图写代码。切回学生习惯的那门语言 */
|
||||
const canCode = computed(() => problemStore.codeLanguages.length > 0)
|
||||
/**
|
||||
* 「照着它写代码」:只画流程图的课里不给(老师这节课只要画),这道题在要写代码的课里就换到
|
||||
* 布置的语言,否则回到他习惯的那门
|
||||
*/
|
||||
const lessonStore = useLessonStore()
|
||||
const lessonItem = computed(() => (problemStore.problem ? lessonStore.find(problemStore.problem._id) : undefined))
|
||||
const canCode = computed(() => problemStore.codeLanguages.length > 0 && !(lessonItem.value?.draw && !lessonItem.value.code))
|
||||
/** 课上这道题要写代码、还没交对:画对了不算做完 */
|
||||
const codeStillNeeded = computed(() => !!lessonItem.value && lessonItem.value.code !== null && lessonItem.value.code !== 'accepted')
|
||||
function goCode() {
|
||||
problemStore.switchLanguage(problemStore.supportedLanguage(codeStore.preferredLanguage()))
|
||||
const language = lessonStore.activity?.language
|
||||
if (lessonItem.value?.code && language && problemStore.codeLanguages.includes(language)) problemStore.switchLanguage(language)
|
||||
else problemStore.switchLanguage(problemStore.supportedLanguage(codeStore.preferredLanguage()))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -236,14 +247,14 @@ function goCode() {
|
||||
<div v-if="report" class="headline">
|
||||
<span class="grade" :class="gradeType(current.grade)">{{ current.grade }} 级</span>
|
||||
<span class="headline-text">{{ flowchartVerdict(report, current.grade) }}</span>
|
||||
<span v-if="isFlowchartPass(current.grade)" class="headline-note">这道题算做完了</span>
|
||||
<span v-if="isFlowchartPass(current.grade)" class="headline-note">{{ codeStillNeeded ? '接着写代码' : '这道题算做完了' }}</span>
|
||||
</div>
|
||||
<div v-else class="score-line">
|
||||
<span class="score" :class="gradeType(current.grade)">{{ current.score }}</span>
|
||||
<span class="score-unit">分</span>
|
||||
<span class="grade" :class="gradeType(current.grade)">{{ current.grade }} 级</span>
|
||||
<span v-if="detail?.aiFeedback || isFlowchartPass(current.grade)" class="feedback">
|
||||
{{ detail?.aiFeedback ?? '' }}{{ isFlowchartPass(current.grade) ? ' 这道题算做完了。' : '' }}
|
||||
{{ detail?.aiFeedback ?? '' }}{{ isFlowchartPass(current.grade) ? (codeStillNeeded ? ' 接着写代码吧。' : ' 这道题算做完了。') : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ChipItem {
|
||||
title: string
|
||||
status: 'done' | 'tried' | 'none'
|
||||
to: RouteLocationRaw
|
||||
/** 悬停时代替「做过,还没对」那句(课堂里要画又要写的题,说差哪一步) */
|
||||
note?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -61,7 +63,7 @@ watch(
|
||||
:to="item.to"
|
||||
class="chip"
|
||||
:class="[item.status, { current: isCurrent(item) }]"
|
||||
:aria-label="`${item.displayId} ${item.title}(${STATUS_TEXT[item.status]})`"
|
||||
:aria-label="`${item.displayId} ${item.title}(${item.note ?? STATUS_TEXT[item.status]})`"
|
||||
:aria-current="isCurrent(item) ? 'page' : undefined"
|
||||
>
|
||||
<!-- 内嵌 SVG,不走 Iconify:图标源拉不到时状态就只剩颜色了 -->
|
||||
@@ -109,7 +111,7 @@ watch(
|
||||
<span>{{ item.displayId }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
{{ item.displayId }} {{ item.title }} · {{ STATUS_TEXT[item.status] }}
|
||||
{{ item.displayId }} {{ item.title }} · {{ item.note ?? STATUS_TEXT[item.status] }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -172,20 +172,38 @@ watch(
|
||||
* 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现
|
||||
* 不算数。课堂条只在题库入口出现,这里同一个范围。
|
||||
*
|
||||
* 每道题只自动换一次:学生自己再切回别的语言,就按他的来。课堂状态可能比题面晚到
|
||||
* (课堂条进页面才去拉),所以两边都等到了再判断;画着流程图时不动他。
|
||||
* 要画流程图的课(画流程图 / 先画再写,或者今天先后布置了画和写):这道题的图还没画对,
|
||||
* 就停在画布上;画对了、还要写代码的,换到布置的语言 —— 画一道写一道、全画完再回来写,
|
||||
* 打开题目都是该做的那一步。
|
||||
*
|
||||
* 每道题、每一节课只自动换一次:学生自己再切回别的,就按他的来。老师换了一节(做法、语言变了)
|
||||
* 再判断一次。画对图的那一刻不跟着换:他正在看点评,「照着它写代码」由他自己点。
|
||||
* 课堂状态可能比题面晚到(课堂条进页面才去拉),所以两边都等到了再判断;
|
||||
* 没有画这一步的课,画着流程图时不动他
|
||||
*/
|
||||
const lessonStore = useLessonStore()
|
||||
let lessonLanguageFor: string | null = null
|
||||
watch(
|
||||
() => [problem.value?._id, lessonStore.activity] as const,
|
||||
([id, activity]) => {
|
||||
if (!id || !activity || lessonLanguageFor === id) return
|
||||
lessonLanguageFor = id
|
||||
if (!id || !activity) return
|
||||
const key = [id, activity.day, activity.mode, activity.language].join('|')
|
||||
if (lessonLanguageFor === key) return
|
||||
lessonLanguageFor = key
|
||||
if (ctx.value.entry !== 'problem' || teacherCollab.value) return
|
||||
const item = lessonStore.find(id)
|
||||
if (!item) return
|
||||
if (item.draw && item.draw !== 'accepted') {
|
||||
if (problemStore.canDraw && codeStore.code.language !== 'Flowchart') problemStore.switchLanguage('Flowchart')
|
||||
return
|
||||
}
|
||||
// 只画流程图、已经画对了:没有要写的
|
||||
if (item.draw && !item.code) return
|
||||
const language = activity.language
|
||||
if (!language || ctx.value.entry !== 'problem' || teacherCollab.value) return
|
||||
if (!lessonStore.includes(id) || !problemStore.codeLanguages.includes(language)) return
|
||||
if (codeStore.code.language === language || codeStore.code.language === 'Flowchart') return
|
||||
if (!language || !problemStore.codeLanguages.includes(language)) return
|
||||
if (codeStore.code.language === language) return
|
||||
// 没有画这一步、自己在画流程图的,不打断他
|
||||
if (codeStore.code.language === 'Flowchart' && !item.draw) return
|
||||
problemStore.switchLanguage(language)
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { LESSON_MODE_LABEL } from '@oj2/contract'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { SubmissionLessons } from '@oj2/contract'
|
||||
@@ -88,8 +89,9 @@ function attendance(lesson: Lesson) {
|
||||
return lesson.classSize >= lesson.userCount ? `${lesson.userCount} / ${lesson.classSize} 人` : `${lesson.userCount} 人`
|
||||
}
|
||||
|
||||
/** 要画流程图的课写出来:这一行的次数和正确率连流程图一起数(画到 A / S 算对) */
|
||||
function problemText(lesson: Lesson) {
|
||||
if (lesson.assigned) return `布置 ${lesson.problems.length} 道`
|
||||
if (lesson.assigned) return `布置 ${lesson.problems.length} 道${lesson.mode && lesson.mode !== 'code' ? ` · ${LESSON_MODE_LABEL[lesson.mode]}` : ''}`
|
||||
return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道`
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { submissionTimeText } from '../utils'
|
||||
import FlowchartState from './FlowchartState.vue'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import FlowchartNote from 'shared/components/FlowchartNote.vue'
|
||||
import { caseReport, flowchartVerdict } from 'utils/flowchart'
|
||||
import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } from 'utils/flowchart'
|
||||
|
||||
/**
|
||||
* 流程图模式的右栏:左边画出来的图,右边四项评分和建议。原来要点编号再开一个
|
||||
@@ -85,34 +85,8 @@ async function load() {
|
||||
detail.value = res
|
||||
await nextTick()
|
||||
await renderFlowchart(graph.value, res.mermaidCode)
|
||||
fitGraph(graph.value)
|
||||
markFocus(graph.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图填满图框:大图照旧缩小,小图也放大,但最多到原尺寸的 1.5 倍(再大字就粗笨了)。
|
||||
* 原来只缩不放,比大小这种五六个框的图在一大块空白中间只占一小团
|
||||
*/
|
||||
function fitGraph(el: HTMLElement | null) {
|
||||
const svg = el?.querySelector('svg')
|
||||
const w = Number.parseFloat(svg?.getAttribute('width') ?? '')
|
||||
const h = Number.parseFloat(svg?.getAttribute('height') ?? '')
|
||||
if (!svg || !w || !h) return
|
||||
if (!svg.getAttribute('viewBox')) svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
|
||||
svg.style.width = '100%'
|
||||
svg.style.height = '100%'
|
||||
svg.style.maxWidth = `${w * 1.5}px`
|
||||
svg.style.maxHeight = `${h * 1.5}px`
|
||||
}
|
||||
|
||||
/**
|
||||
* 要改的那个框标红:AI 对着答案指出的,没有就退回程序找到的第一处出错的框。
|
||||
* pele 把 Mermaid 的框号放在节点 <g> 的 data-id 上
|
||||
*/
|
||||
function markFocus(el: HTMLElement | null) {
|
||||
const focus = report.value?.focus ?? report.value?.cases.find((row) => !row.ok)?.wrongAt
|
||||
if (!el || !focus) return
|
||||
el.querySelector(`.pele-node[data-id="${focus}"]`)?.classList.add('oj-focus')
|
||||
fitFlowchartGraph(graph.value)
|
||||
markFlowchartFocus(graph.value, report.value)
|
||||
}
|
||||
|
||||
// 分开写三个来源:列表每次轮询都换一批新的行对象,一个 getter 返回数组的话回调次次都跑
|
||||
@@ -124,7 +98,7 @@ watch(bigOpen, async (open) => {
|
||||
if (!open || !detail.value) return
|
||||
await nextTick()
|
||||
await renderFlowchart(bigGraph.value, detail.value.mermaidCode)
|
||||
markFocus(bigGraph.value)
|
||||
markFlowchartFocus(bigGraph.value, report.value)
|
||||
})
|
||||
|
||||
const shown = computed(() => (detail.value?.id === props.row.id ? detail.value : null))
|
||||
|
||||
Reference in new issue
Block a user