diff --git a/apps/api/src/routes/home.ts b/apps/api/src/routes/home.ts index 1f7c2d9..fdf0ceb 100644 --- a/apps/api/src/routes/home.ts +++ b/apps/api/src/routes/home.ts @@ -1,5 +1,5 @@ -import { type ClassActivity, type ClassActivityProblem, type LastVisit, type LessonLanguage, type LessonMode, lessonNeeds } from '@oj2/contract' -import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm' +import { type ClassActivity, type ClassActivityProblem, type LastVisit } from '@oj2/contract' +import { and, asc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm' import { Hono } from 'hono' 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 { accepted } from '../services/learning-stats' 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 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 [mine, drawn] = await Promise.all([ @@ -193,7 +192,7 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => { problems: lesson.problems.map((problem) => { const code = stepOf(codeById.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 if (needs) { // 老师布置的:要哪几步就看哪几步,全做到才算做完 @@ -215,39 +214,3 @@ homeRoutes.get('/me/class-activity', requireAuth, async (c) => { }), } 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() - 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 } -} diff --git a/apps/api/src/routes/task.ts b/apps/api/src/routes/task.ts index a7dde13..b4b86df 100644 --- a/apps/api/src/routes/task.ts +++ b/apps/api/src/routes/task.ts @@ -5,7 +5,6 @@ import { type TaskBoard, type TaskBoardStudent, type ClassLesson, - lessonNeeds, type StatisticInfo, } from '@oj2/contract' 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( 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 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([ ids.length && userIds.length ? db @@ -145,7 +149,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => { isNull(schema.submission.contestId), inArray(schema.submission.userId, userIds), inArray(schema.submission.problemId, ids), - lessonLanguageFilter(lesson.language), + lessonLanguageFilter(codeLanguage), ), ) .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.problemId, ids), gte(schema.submission.createTime, start), - lessonLanguageFilter(lesson.language), + lessonLanguageFilter(codeLanguage), ), ) .orderBy(schema.submission.createTime) @@ -234,7 +238,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => { * 还没画对的格子点开讲它哪里错、按走对几组归堆。走对几组从 2026-10 起的「走例子」报告里数, * 老评法和还没评完的为 null */ - needs?.draw && ids.length && userIds.length + anyDraw && ids.length && userIds.length ? db .selectDistinctOn([schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId], { userId: schema.flowchartSubmission.userId, @@ -281,6 +285,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => { title: problem.title, isSql: problem.isSql, hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0, + mode: steps?.modeOf(problem.id) ?? null, })), students: roster.map( (student) => @@ -289,6 +294,7 @@ taskRoutes.get('/task/board', requireTeacher, async (c) => { username: student.username, realName: student.realName ?? null, cells: ids.map((id) => { + const needs = needsOf(id) const key = `${student.userId}:${id}` const cell = cellByKey.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) } 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, ) - acceptedAt = steps.every((step) => step.at) ? steps.map((step) => step.at!).reduce(latestOf) : null - status = acceptedAt ? 'accepted' : steps.some((step) => step.status !== 'none') ? 'tried' : 'none' + acceptedAt = wanted.every((step) => step.at) ? wanted.map((step) => step.at!).reduce(latestOf) : null + status = acceptedAt ? 'accepted' : wanted.some((step) => step.status !== 'none') ? 'tried' : 'none' 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, + // 整道题做完了才谈挑战:先画再写只交对了代码、图还没画的,格子是「缺图」,不挂挑战的圈 challenge: - !codeWanted || !codeDone || !liveChallenges.get(id)?.size + !codeWanted || !codeDone || !acceptedAt || !liveChallenges.get(id)?.size ? null : challengeDoneKeys.has(key) ? 'done' @@ -410,9 +417,17 @@ taskRoutes.get('/task/today', requireTeacher, async (c) => { const ids = lesson.problems.map((problem) => problem.id) let allDoneCount = 0 if (ids.length && lesson.source === 'teacher' && lesson.lessonId && lesson.mode) { - // 老师布置的:和任务页、后台课堂任务同一个口径(要画的看图、要写的看代码) + // 老师布置的:和任务页同一个口径(每道题按今天几节合起来的要求,要画的看图、要写的看代码) 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 } else if (ids.length) { diff --git a/apps/api/src/services/lesson.ts b/apps/api/src/services/lesson.ts index b966d20..ed54260 100644 --- a/apps/api/src/services/lesson.ts +++ b/apps/api/src/services/lesson.ts @@ -59,7 +59,16 @@ export function lessonDayEnd(day: string) { * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 */ 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) => lesson.problemIds.map((problemId) => ({ @@ -67,7 +76,7 @@ export async function lessonDoneCounts( class_name: lesson.className, problem_id: problemId, language: lesson.language, - mode: lesson.mode, + mode: lesson.modeOf?.(problemId) ?? lesson.mode, until: lessonDayEnd(lesson.day), })), ) @@ -281,6 +290,45 @@ async function lessonPlan(className: string, day: string) { 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() + 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 = inferred):同班同一天 ≥ 5 人做过的题,只取最近的 @@ -294,17 +342,23 @@ export async function classLessonProblems(className: string, lookbackDays: numbe const plan = await lessonPlan(className, today) if (plan) { 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), - 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 { source: 'teacher' as const, lessonId: plan.id, day: today, language: plan.language ?? null, mode: plan.mode, + steps, assignedAt: plan.createdAt, assignedBy: plan.createdBy, problems: planned.flatMap((id) => { @@ -337,6 +391,7 @@ export async function classLessonProblems(className: string, lookbackDays: numbe // 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限 language: null, mode: null, + steps: null, assignedAt: null, assignedBy: null, problems: picked.flatMap((row) => { diff --git a/apps/web/src/oj/problem/components/ProblemContent.vue b/apps/web/src/oj/problem/components/ProblemContent.vue index efee9ce..2950c8e 100644 --- a/apps/web/src/oj/problem/components/ProblemContent.vue +++ b/apps/web/src/oj/problem/components/ProblemContent.vue @@ -73,12 +73,15 @@ watch( () => (myFlowchartOpen.value = true), ) -/** 流程图画到 A / S 了:标题下给个「流程图 A 级」 */ +/** + * 标题下的「流程图 X 级」:评完过的里面最好的那一次。画到 A / S 是绿的(算做完了), + * 没画到是橙的 —— 原来只在 A / S 时才写,画到 C 的学生标题下是「还没交过」,像是什么都没交 + */ const flowchartGrade = computed(() => { - const passed = flowchartStore.scores.filter((row) => isFlowchartPass(row.grade)) - if (!passed.length) return '' - return passed.some((row) => row.grade === 'S') ? 'S' : 'A' + const best = flowchartStore.scores.reduce<(typeof flowchartStore.scores)[number] | null>((acc, row) => (!acc || row.score > acc.score ? row : acc), null) + return best?.grade ?? '' }) +const flowchartPassed = computed(() => isFlowchartPass(flowchartGrade.value)) // 例子旁的「复制」:点完变成「已复制」一秒 const message = useMessage() @@ -192,12 +195,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem 已解决 -