fix(课堂任务): 先画一节、再用这几道题写代码时,老师这边也认没画图的还没做完

- 任务页格子、老师首页「全做对」和每道题做对几人,按今天几节里同一道题合起来的要求算
  (和学生那边同一份口径,挪进 services/lesson.ts 的 todayNeeds):写代码那节只交对了代码、
  没画图的,格子是「缺图」,不再打 ✓。后台和数据统计还是一节一行、各数各的
- 学生课堂条上「几人做对」也按合起来的要求数,不再把自己没做完的算进去
- 先画再写只交对了代码、图还没画的格子,不再挂挑战的圈
- 题目页标题下:流程图没画到 A 也写「流程图 C 级」(橙色),不再写「还没交过」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-03 09:32:57 +08:00
1 parent c3f1413545
commit 91057c1096
6 files changed
+144 -100

No files matched your search

+4 -41
View File
@@ -1,5 +1,5 @@
import { type ClassActivity, type ClassActivityProblem, type LastVisit, type LessonLanguage, type LessonMode, lessonNeeds } from '@oj2/contract' import { type ClassActivity, type ClassActivityProblem, type LastVisit } from '@oj2/contract'
import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm' import { and, asc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware' import { requireAuth, type AppEnv } from '../auth/middleware'
@@ -9,7 +9,6 @@ import { success } from '../http'
import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity' import { FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS } from '../services/class-activity'
import { accepted } from '../services/learning-stats' import { accepted } from '../services/learning-stats'
import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson' import { CLASS_ACTIVITY_LOOKBACK_DAYS, classLessonProblems } from '../services/lesson'
import { calendarDay } from '../time'
/** /**
* 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。 * 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。
@@ -145,7 +144,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS) const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
const steps = lesson.source === 'teacher' && lesson.mode ? await todaySteps(user.className, ids, lesson) : null const steps = lesson.steps
const codeLanguage = steps ? steps.language : lesson.language const codeLanguage = steps ? steps.language : lesson.language
// 代码提交和流程图提交两张表,互不依赖,一起查 // 代码提交和流程图提交两张表,互不依赖,一起查
const [mine, drawn] = await Promise.all([ const [mine, drawn] = await Promise.all([
@@ -193,7 +192,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
problems: lesson.problems.map((problem) => { problems: lesson.problems.map((problem) => {
const code = stepOf(codeById.get(problem.id)) const code = stepOf(codeById.get(problem.id))
const draw = stepOf(drawById.get(problem.id)) const draw = stepOf(drawById.get(problem.id))
const needs = steps?.needs.get(problem.id) const needs = steps?.needsOf(problem.id)
let myStatus: Step let myStatus: Step
if (needs) { if (needs) {
// 老师布置的:要哪几步就看哪几步,全做到才算做完 // 老师布置的:要哪几步就看哪几步,全做到才算做完
@@ -215,39 +214,3 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => {
}), }),
} satisfies ClassActivity) } satisfies ClassActivity)
}) })
/**
* 学生那边一道题要画还是要写:今天这个班布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再布置一节同样的题写代码时,还没画完的人照样看得到要画 ——
* 学生只看得到今天最后布置的那一节,不合起来的话,画流程图那节一布置写代码就没了。
*
* 写代码用的语言:今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
async function todaySteps(
className: string,
problemIds: number[],
latest: { lessonId: number | null; language: LessonLanguage | null; mode: LessonMode | null },
) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = latest.mode && lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = !!latest.mode && lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.lessonId ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
return { needs, language }
}
+27 -12
View File
@@ -5,7 +5,6 @@ import {
type TaskBoard, type TaskBoard,
type TaskBoardStudent, type TaskBoardStudent,
type ClassLesson, type ClassLesson,
lessonNeeds,
type StatisticInfo, type StatisticInfo,
} from '@oj2/contract' } from '@oj2/contract'
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
@@ -127,9 +126,14 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
const challengeDoneKeys = new Set( const challengeDoneKeys = new Set(
challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`), challengeDone.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId)).map((row) => `${row.userId}:${row.problemId}`),
) )
// 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样) // 老师布置的这节做什么;推断出来的课为 null,代码交对或流程图画对都算(和加「做什么」之前一样)。
// 格子按每道题合起来的要求算(todayNeeds):先画流程图一节、再写代码一节,没画图的照样标「缺图」,
// 和学生那边的「做完」对得上。代码按的语言也是合起来那份:最后一节只画流程图时,用前面写代码那节的
const mode = lesson.source === 'teacher' ? lesson.mode : null const mode = lesson.source === 'teacher' ? lesson.mode : null
const needs = mode ? lessonNeeds(mode) : null const steps = lesson.steps
const needsOf = (problemId: number) => steps?.needsOf(problemId) ?? null
const anyDraw = ids.some((id) => needsOf(id)?.draw)
const codeLanguage = steps ? steps.language : lesson.language
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([ const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen, lastFlows] = await Promise.all([
ids.length && userIds.length ids.length && userIds.length
? db ? db
@@ -145,7 +149,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
isNull(schema.submission.contestId), isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
lessonLanguageFilter(lesson.language), lessonLanguageFilter(codeLanguage),
), ),
) )
.groupBy(schema.submission.userId, schema.submission.problemId) .groupBy(schema.submission.userId, schema.submission.problemId)
@@ -223,7 +227,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
inArray(schema.submission.userId, userIds), inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids), inArray(schema.submission.problemId, ids),
gte(schema.submission.createTime, start), gte(schema.submission.createTime, start),
lessonLanguageFilter(lesson.language), lessonLanguageFilter(codeLanguage),
), ),
) )
.orderBy(schema.submission.createTime) .orderBy(schema.submission.createTime)
@@ -234,7 +238,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
* 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数, * 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数,
* 老评法和还没评完的为 null * 老评法和还没评完的为 null
*/ */
needs?.draw && ids.length && userIds.length anyDraw && ids.length && userIds.length
? db ? db
.selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], { .selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], {
userId: schema.flowchartSubmission.userId, userId: schema.flowchartSubmission.userId,
@@ -281,6 +285,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
title: problem.title, title: problem.title,
isSql: problem.isSql, isSql: problem.isSql,
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0, hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
mode: steps?.modeOf(problem.id) ?? null,
})), })),
students: roster.map( students: roster.map(
(student) => (student) =>
@@ -289,6 +294,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
username: student.username, username: student.username,
realName: student.realName ?? null, realName: student.realName ?? null,
cells: ids.map((id) => { cells: ids.map((id) => {
const needs = needsOf(id)
const key = `${student.userId}:${id}` const key = `${student.userId}:${id}`
const cell = cellByKey.get(key) const cell = cellByKey.get(key)
const flow = drawnByKey.get(key) const flow = drawnByKey.get(key)
@@ -305,11 +311,11 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0) attempts = (cell?.attempts ?? 0) + (flow?.attempts ?? 0)
} else { } else {
// 要哪几步看哪几步:两步都做到的时刻是晚的那一步 // 要哪几步看哪几步:两步都做到的时刻是晚的那一步
const steps = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter( const wanted = [needs.draw ? { status: drawStatus, at: flowAt } : null, needs.code ? { status: codeStatus, at: codeAt } : null].filter(
(step) => step !== null, (step) => step !== null,
) )
acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null acceptedAt = wanted.every((step) => step.at) ? wanted.map((step) => step.at!).reduce(latestOf) : null
status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none' status = acceptedAt ? 'accepted' : wanted.some((step) => step.status !== 'none') ? 'tried' : 'none'
attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0) attempts = (needs.code ? (cell?.attempts ?? 0) : 0) + (needs.draw ? (flow?.attempts ?? 0) : 0)
} }
// 下面几样都是代码那一步的:只画流程图的课没有 // 下面几样都是代码那一步的:只画流程图的课没有
@@ -352,8 +358,9 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => {
})) }))
: [], : [],
acceptedTry: firstTry >= 0 ? firstTry + 1 : null, acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
// 整道题做完了才谈挑战:先画再写只交对了代码、图还没画的,格子是「缺图」,不挂挑战的圈
challenge: challenge:
!codeWanted || !codeDone || !liveChallenges.get(id)?.size !codeWanted || !codeDone || !acceptedAt || !liveChallenges.get(id)?.size
? null ? null
: challengeDoneKeys.has(key) : challengeDoneKeys.has(key)
? 'done' ? 'done'
@@ -410,9 +417,17 @@ taskRoutes.get('/task/today', requireTeacher, async (c) => {
const ids = lesson.problems.map((problem) => problem.id) const ids = lesson.problems.map((problem) => problem.id)
let allDoneCount = 0 let allDoneCount = 0
if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) { if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) {
// 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码) // 老师布置的:和任务页同一个口径(每道题按今天几节合起来的要求,要画的看图、要写的看代码)
const counts = await lessonDoneCounts([ const counts = await lessonDoneCounts([
{ id: lesson.lessonId, className, day: calendarDay(), problemIds: ids, language: lesson.language, mode: lesson.mode }, {
id: lesson.lessonId,
className,
day: calendarDay(),
problemIds: ids,
language: lesson.steps?.language ?? lesson.language,
mode: lesson.mode,
modeOf: lesson.steps?.modeOf,
},
]) ])
allDoneCount = counts.get(lesson.lessonId) ?? 0 allDoneCount = counts.get(lesson.lessonId) ?? 0
} else if (ids.length) { } else if (ids.length) {
+60 -5
View File
@@ -59,7 +59,16 @@ export function lessonDayEnd(day: string) {
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/ */
export async function lessonDoneCounts( export async function lessonDoneCounts(
lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null; mode: LessonMode }[], lessons: {
id: number
className: string
day: string
problemIds: number[]
language: LessonLanguage | null
mode: LessonMode
/** 每道题各自做什么(老师首页按今天几节合起来的要求算,见 todayNeeds);不给就都按 mode */
modeOf?: (problemId: number) => LessonMode
}[],
) { ) {
const rows = lessons.flatMap((lesson) => const rows = lessons.flatMap((lesson) =>
lesson.problemIds.map((problemId) => ({ lesson.problemIds.map((problemId) => ({
@@ -67,7 +76,7 @@ export async function lessonDoneCounts(
class_name: lesson.className, class_name: lesson.className,
problem_id: problemId, problem_id: problemId,
language: lesson.language, language: lesson.language,
mode: lesson.mode, mode: lesson.modeOf?.(problemId) ?? lesson.mode,
until: lessonDayEnd(lesson.day), until: lessonDayEnd(lesson.day),
})), })),
) )
@@ -281,6 +290,45 @@ async function lessonPlan(className: string, day: string) {
return row?.problemIds.length ? row : null return row?.problemIds.length ? row : null
} }
/**
* 一道题今天要画还是要写:这个班今天布置过的每一节里,凡是有这道题的,要求合起来算。
* 老师先布置一节画流程图、再「用这几道题写代码」开一节时,还没画完的照样要画 ——
* 只看今天最后布置的那一节的话,一布置写代码,画图那一步就没了。学生的课堂条、任务页的格子、
* 老师首页的「全做对」都按它算,三处说的「做完」才一样(后台和数据统计还是一节一行、各数各的)。
*
* `language`:代码按什么语言数 —— 今天最后布置的、要写代码的那一节选的(只画流程图的课没有语言)
*/
export async function todayNeeds(className: string, problemIds: number[], latest: { id: number; language: LessonLanguage | null; mode: LessonMode }) {
const rows = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, mode: schema.classLesson.mode })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
const needs = new Map<number, { draw: boolean; code: boolean }>()
let language: LessonLanguage | null = lessonNeeds(latest.mode).code ? latest.language : null
let languageFound = lessonNeeds(latest.mode).code
for (const row of rows) {
const shared = row.id === latest.id ? problemIds : row.problemIds.filter((id) => problemIds.includes(id))
if (!shared.length) continue
const rowNeeds = lessonNeeds(row.mode)
for (const id of shared) {
const known = needs.get(id) ?? { draw: false, code: false }
needs.set(id, { draw: known.draw || rowNeeds.draw, code: known.code || rowNeeds.code })
}
if (!languageFound && rowNeeds.code) {
language = row.language ?? null
languageFound = true
}
}
const latestNeeds = lessonNeeds(latest.mode)
const needsOf = (id: number) => needs.get(id) ?? latestNeeds
const modeOf = (id: number): LessonMode => {
const { draw, code } = needsOf(id)
return draw && code ? 'both' : draw ? 'flowchart' : 'code'
}
return { needsOf, modeOf, language }
}
/** /**
* 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher), * 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher),
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的 * 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
@@ -294,17 +342,23 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
const plan = await lessonPlan(className, today) const plan = await lessonPlan(className, today)
if (plan) { if (plan) {
const planned = plan.problemIds const planned = plan.problemIds
const [problems, groups] = await Promise.all([ const steps = await todayNeeds(className, planned, plan)
// 每道题按它合起来的要求数「做过 / 做对」:要求一样的几道一起数
const byMode = Map.groupBy(planned, (id) => steps.modeOf(id))
const [problems, ...groupLists] = await Promise.all([
visibleProblems(planned), visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }), ...[...byMode].map(([mode, ids]) =>
classDayGroups(className, dayStart(), { problemIds: ids, language: lessonNeeds(mode).code ? steps.language : null, mode }),
),
]) ])
const groupById = new Map(groups.map((row) => [row.problemId, row])) const groupById = new Map(groupLists.flat().map((row) => [row.problemId, row]))
return { return {
source: 'teacher' as const, source: 'teacher' as const,
lessonId: plan.id, lessonId: plan.id,
day: today, day: today,
language: plan.language ?? null, language: plan.language ?? null,
mode: plan.mode, mode: plan.mode,
steps,
assignedAt: plan.createdAt, assignedAt: plan.createdAt,
assignedBy: plan.createdBy, assignedBy: plan.createdBy,
problems: planned.flatMap((id) => { problems: planned.flatMap((id) => {
@@ -337,6 +391,7 @@ export async function classLessonProblems(className: string, lookbackDays: numbe
// 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限 // 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限
language: null, language: null,
mode: null, mode: null,
steps: null,
assignedAt: null, assignedAt: null,
assignedBy: null, assignedBy: null,
problems: picked.flatMap((row) => { problems: picked.flatMap((row) => {
@@ -73,12 +73,15 @@ watch(
() => (myFlowchartOpen.value = true), () => (myFlowchartOpen.value = true),
) )
/** 流程图画到 A / S 了:标题下给个「流程图 A 级」 */ /**
* 标题下的「流程图 X 级」:评完过的里面最好的那一次。画到 A / S 是绿的(算做完了),
* 没画到是橙的 —— 原来只在 A / S 时才写,画到 C 的学生标题下是「还没交过」,像是什么都没交
*/
const flowchartGrade = computed(() => { const flowchartGrade = computed(() => {
const passed = flowchartStore.scores.filter((row) => isFlowchartPass(row.grade)) const best = flowchartStore.scores.reduce<(typeof flowchartStore.scores)[number] | null>((acc, row) => (!acc || row.score > acc.score ? row : acc), null)
if (!passed.length) return '' return best?.grade ?? ''
return passed.some((row) => row.grade === 'S') ? 'S' : 'A'
}) })
const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value))
// 例子旁的「复制」:点完变成「已复制」一秒 // 例子旁的「复制」:点完变成「已复制」一秒
const message = useMessage() const message = useMessage()
@@ -192,12 +195,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</n-tag> </n-tag>
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag> <n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false"> 已解决 </n-tag>
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」 <!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码 --> 「做过,还没对」—— 那两个说的是代码。图没画到 A 的,代码交过没对照写;没交过代码就不写
<template v-else-if="!flowchartGrade"> 「还没交过」,旁边的「流程图 C 级」已经说了交过什么 -->
<template v-else-if="!flowchartPassed">
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag> <n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false"> 做过,还没对 </n-tag>
<n-tag v-else-if="myStatus === 'none'" size="small" :bordered="false">还没交过</n-tag> <n-tag v-else-if="myStatus === 'none' && !flowchartGrade" size="small" :bordered="false">还没交过</n-tag>
</template> </template>
<n-tag v-if="flowchartGrade" size="small" type="success" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag> <n-tag v-if="flowchartGrade" size="small" :type="flowchartPassed ? 'success' : 'warning'" :bordered="false"> 流程图 {{ flowchartGrade }} 级 </n-tag>
</div> </div>
</header> </header>
+36 -34
View File
@@ -122,8 +122,11 @@ onUnmounted(pause)
*/ */
const editing = ref<false | 'edit' | 'new' | 'carry'>(false) const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
/** 这节课做什么;推断出来的课为 null(代码交对或流程图画对都算) */ /**
const mode = computed(() => board.value?.mode ?? null) * 第几道题今天做什么:今天几节里有这道题的要求合起来(先画流程图一节、再写代码一节 = 先画再写),
* 和学生那边的「做完」一致。推断出来的课为 null(代码交对或流程图画对都算)
*/
const modeAt = (index: number) => board.value?.problems[index]?.mode ?? null
/** /**
* 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久, * 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久,
@@ -200,7 +203,7 @@ function groupOf(student: TaskBoardStudent): Group {
const cells = student.cells const cells = student.cells
if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done' if (cells.length && cells.every((cell) => cell.status === 'accepted')) return 'done'
if (!student.lastSubmitAt) return 'idle' if (!student.lastSubmitAt) return 'idle'
if (cells.some((cell) => isStuck(cell, mode.value))) return 'stuck' if (cells.some((cell, i) => isStuck(cell, modeAt(i)))) return 'stuck'
return 'working' return 'working'
} }
@@ -327,23 +330,24 @@ function drawKind(cell: TaskBoardCell): ErrorKind {
* 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆 * 要画流程图的课,卡在画图上的按最后一张图归堆(drawKind);先写了代码、没画图的单独一堆
*/ */
function wrongOf(index: number): WrongPerson[] { function wrongOf(index: number): WrongPerson[] {
const mode = modeAt(index)
return present.value.flatMap((student): WrongPerson[] => { return present.value.flatMap((student): WrongPerson[] => {
const cell = student.cells[index] const cell = student.cells[index]
if (!cell || cell.status !== 'tried') return [] if (!cell || cell.status !== 'tried') return []
const stage = cellStage(cell, mode.value) const stage = cellStage(cell, mode)
if (stage === 'draw') { if (stage === 'draw') {
const latest = cell.flowchart?.latest const latest = cell.flowchart?.latest
const evaluated = !!latest && latest.status !== 0 && latest.status !== 1 const evaluated = !!latest && latest.status !== 0 && latest.status !== 1
return [{ student, cell, kind: drawKind(cell), explain: evaluated ? 'draw' : null }] 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 (stage === 'nodraw') return [{ student, cell, kind: { key: 'nodraw', label: '代码对了,还没画图', line: null }, explain: null }]
if (mode.value === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') { if (mode === 'both' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none') {
return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }] return [{ student, cell, kind: { key: 'drawn', label: '图画对了,还没写代码', line: null }, explain: null }]
} }
// 推断出来的课不分步:今天没代码、却画了流程图的单独一堆 // 推断出来的课不分步:今天没代码、却画了流程图的单独一堆
const kind: ErrorKind = cell.latest const kind: ErrorKind = cell.latest
? errorKind(cell.latest) ? errorKind(cell.latest)
: !mode.value && cell.attempts : !mode && cell.attempts
? { key: 'flowchart', label: '今天只画了流程图', line: null } ? { key: 'flowchart', label: '今天只画了流程图', line: null }
: { key: 'before', label: '以前交过,今天还没交', line: null } : { key: 'before', label: '以前交过,今天还没交', line: null }
return [{ student, cell, kind, explain: cell.latest ? 'code' : null }] return [{ student, cell, kind, explain: cell.latest ? 'code' : null }]
@@ -362,21 +366,18 @@ const problemStats = computed(() =>
const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null const challenged = problem.hasChallenges ? present.value.filter((student) => student.cells[i]?.challenge === 'done').length : null
// 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码 // 先画再写:两步各几人做到,老师一眼看出全班卡在画图还是写代码
const steps = const steps =
mode.value === 'both' problem.mode === 'both'
? { ? {
drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length, drawn: present.value.filter((student) => student.cells[i]?.flowchart?.status === 'accepted').length,
coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length, coded: present.value.filter((student) => student.cells[i]?.codeStatus === 'accepted').length,
} }
: null : null
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps } // 先画再写的题,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
const wrongWord = problem.mode === 'both' ? '没做完' : '错着'
return { problem, done, wrong, none: total - done - wrong, total, challenged, steps, wrongWord }
}), }),
) )
/**
* 先画再写的课,没做完的里面有图画对了、还没写代码的,说「错着」不对 —— 改说「没做完」
*/
const wrongWord = computed(() => (mode.value === 'both' ? '没做完' : '错着'))
const selectedId = ref<number | null>(null) const selectedId = ref<number | null>(null)
const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value)) const selectedIndex = computed(() => (board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value))
function toggleProblem(problemId: number) { function toggleProblem(problemId: number) {
@@ -447,14 +448,15 @@ const cellColors = computed(() => ({
actionColor: theme.value.actionColor, actionColor: theme.value.actionColor,
successColor: theme.value.successColor, successColor: theme.value.successColor,
})) }))
const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value) /** `index`:第几道题,格子按那道题今天做什么来画(modeAt) */
const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value) const cellStyle = (cell: TaskBoardCell, index: number) => cellStyleOf(cell, board.value?.day, cellColors.value, modeAt(index))
const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value) const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent, index: number) => cellTitleOf(cell, student, board.value?.day, modeAt(index))
const cellText = (cell: TaskBoardCell, index: number) => cellTextOf(cell, modeAt(index))
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */ /** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
function explainable(cell: TaskBoardCell) { function explainable(cell: TaskBoardCell, index: number) {
if (cell.status !== 'tried') return false if (cell.status !== 'tried') return false
const stage = cellStage(cell, mode.value) const stage = cellStage(cell, modeAt(index))
if (stage === 'draw') { if (stage === 'draw') {
const latest = cell.flowchart?.latest const latest = cell.flowchart?.latest
return !!latest && latest.status !== 0 && latest.status !== 1 return !!latest && latest.status !== 0 && latest.status !== 1
@@ -495,7 +497,7 @@ function submissionsHref(
// SQL 题不受布置的语言限制,格子数的是 SQL 交的 // SQL 题不受布置的语言限制,格子数的是 SQL 交的
const language = problem?.isSql ? 'SQL' : board.value?.language const language = problem?.isSql ? 'SQL' : board.value?.language
// 只画流程图的课,格子数的全是流程图 // 只画流程图的课,格子数的全是流程图
const flowSide = flowchart || mode.value === 'flowchart' const flowSide = flowchart || problem?.mode === 'flowchart'
return router.resolve({ return router.resolve({
name: 'submissions', name: 'submissions',
query: { query: {
@@ -607,7 +609,7 @@ function lastText(student: TaskBoardStudent) {
</span> </span>
<span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span> <span v-if="stat.steps" class="card-steps">画对 {{ stat.steps.drawn }} · 写对 {{ stat.steps.coded }}</span>
<span class="card-foot"> <span class="card-foot">
<span :class="stat.wrong ? 'wrong' : 'muted'">{{ wrongWord }} {{ stat.wrong }}</span> <span :class="stat.wrong ? 'wrong' : 'muted'">{{ stat.wrongWord }} {{ stat.wrong }}</span>
<span class="muted">没做 {{ stat.none }}</span> <span class="muted">没做 {{ stat.none }}</span>
<span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span> <span v-if="stat.challenged !== null" class="challenged"> 挑战成功 {{ stat.challenged }} </span>
</span> </span>
@@ -630,7 +632,7 @@ function lastText(student: TaskBoardStudent) {
{{ board.problems[selectedIndex]!.title }} {{ board.problems[selectedIndex]!.title }}
</a> </a>
<span> <span>
{{ wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人 {{ problemStats[selectedIndex]!.wrongWord }}的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
</span> </span>
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span> <span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
<div class="spacer"></div> <div class="spacer"></div>
@@ -649,7 +651,7 @@ function lastText(student: TaskBoardStudent) {
<template v-for="person in group.people" :key="person.student.userId"> <template v-for="person in group.people" :key="person.student.userId">
<button v-if="person.explain" 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" /> <UserName :username="person.student.username" />
<span class="mini" :style="cellStyle(person.cell)">{{ cellText(person.cell) }}</span> <span class="mini" :style="cellStyle(person.cell, selectedIndex)">{{ cellText(person.cell, selectedIndex) }}</span>
</button> </button>
<!-- 没有今天的代码可讲:去提交列表看他交过的 --> <!-- 没有今天的代码可讲:去提交列表看他交过的 -->
<a <a
@@ -753,15 +755,15 @@ function lastText(student: TaskBoardStudent) {
</td> </td>
<td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }"> <td v-for="(cell, i) in student.cells" :key="i" :class="{ hl: i === selectedIndex }">
<button <button
v-if="explainable(cell)" v-if="explainable(cell, i)"
type="button" type="button"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
@click="openExplain(i, student.userId)" @click="openExplain(i, student.userId)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</button> </button>
<n-popover <n-popover
v-else-if="cell.trail.length" v-else-if="cell.trail.length"
@@ -777,11 +779,11 @@ function lastText(student: TaskBoardStudent) {
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:class="{ picked: trailKey === `${student.userId}:${i}` }" :class="{ picked: trailKey === `${student.userId}:${i}` }"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
@click="toggleTrail(`${student.userId}:${i}`)" @click="toggleTrail(`${student.userId}:${i}`)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</button> </button>
</template> </template>
<TrailPanel <TrailPanel
@@ -797,15 +799,15 @@ function lastText(student: TaskBoardStudent) {
v-else-if="cell.attempts" v-else-if="cell.attempts"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined" :data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell, i)"
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)" :href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
target="_blank" target="_blank"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student, i)"
> >
{{ cellText(cell) }} {{ cellText(cell, i) }}
</a> </a>
<span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell)" :title="cellTitle(cell, student)"> <span v-else class="cell" :data-challenge="cell.challenge ?? undefined" :style="cellStyle(cell, i)" :title="cellTitle(cell, student, i)">
{{ cellText(cell) }} {{ cellText(cell, i) }}
</span> </span>
</td> </td>
<td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td> <td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td>
+5
View File
@@ -42,6 +42,11 @@ export const taskBoardProblemSchema = z.object({
isSql: z.boolean(), isSql: z.boolean(),
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */ /** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
hasChallenges: z.boolean(), hasChallenges: z.boolean(),
/**
* 这道题今天要做什么:这个班今天布置过的几节里有这道题的,要求合起来(先布置画流程图、再用同样的题
* 写代码 = both)。格子按它画,和学生那边的「做完」一致。推断出来的课为 null
*/
mode: lessonModeSchema.nullable(),
}) })
export const taskBoardCellSchema = z.object({ export const taskBoardCellSchema = z.object({