diff --git a/apps/api/src/auth/presence.ts b/apps/api/src/auth/presence.ts index 6c6ff90..4a777ba 100644 --- a/apps/api/src/auth/presence.ts +++ b/apps/api/src/auth/presence.ts @@ -22,7 +22,7 @@ const ONLINE_WINDOW_MS = 5 * 60 * 1000 /** * 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。 * - * 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着, + * 课堂任务拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着, * 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。 * 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。 */ diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index 7863eee..ecdc6c6 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -428,7 +428,7 @@ function acceptRequest(ws: CollabSocket, request: HelpRequest) { /** * 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找), - * 「帮完了」要回到哪个班的看板 + * 「帮完了」要回到哪个班的任务页 */ function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) { const frame = (peer: Record) => @@ -630,7 +630,7 @@ function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown) } /** - * 老师在看板上主动打开一个学生正在写的代码(学生没举手)。 + * 老师在任务页上主动打开一个学生正在写的代码(学生没举手)。 * * 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」; * 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。 diff --git a/apps/api/src/collab/state.ts b/apps/api/src/collab/state.ts index c4dd58b..91028ba 100644 --- a/apps/api/src/collab/state.ts +++ b/apps/api/src/collab/state.ts @@ -24,7 +24,7 @@ export function normalizeCollabLanguage(value: unknown): CollabLanguage { } /** - * 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂看板的格子同一份形状, + * 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂任务的格子同一份形状, * 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码 */ export interface CollabLatest { @@ -111,7 +111,7 @@ export interface Room { /** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */ studentId: number /** - * help = 学生举了手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。 + * help = 学生举了手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。 * 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清 */ mode: 'help' | 'peek' @@ -144,7 +144,7 @@ export function roomOf(ws: CollabSocket) { } /** - * 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在看板上「在写」那一列看它、 + * 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在任务页上「在写」那一列看它、 * 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报, * 连接断了就清掉。同一账号开了两个标签页时,后报的算 */ diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 4dc968d..d930519 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1293,10 +1293,10 @@ export const exerciseAttempt = pgTable( ) /** - * 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节 + * 老师给某个班布置的一节课的题(课堂任务里布置的),一次布置一行。一个班一天可以有几节 * 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。 * - * 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。 + * 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(services/lesson.ts 的 classLessonProblems)。 * 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。 * * - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列: @@ -1322,7 +1322,7 @@ export const classLesson = pgTable( problemIds: jsonb('problem_ids').notNull().$type(), /** * 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的 - * 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过 + * 没有它。写入侧在 PUT /task/lesson 按 lessonLanguageSchema 校验过 */ language: text().$type(), /** @@ -1330,11 +1330,11 @@ export const classLesson = pgTable( * 都是写代码。写入侧按 lessonModeSchema 校验过 */ mode: text().$type().notNull().default('code'), - /** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */ + /** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。任务页上写「HH:mm 谁 布置」 */ createdBy: integer('created_by').notNull(), /** * 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。 - * 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。 + * 同一天有几节时,按它分先后:最后布置的那一节是任务页上的「这节课」。 * 加这一列之前布置的用 updated_at 回填(0034) */ createdAt: timestamp('created_at', { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c5e1719..937f331 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,7 +10,8 @@ import { authRoutes } from './routes/auth' import { accountRoutes } from './routes/account' import { contestRoutes } from './routes/contest' import { contentRoutes } from './routes/content' -import { classroomRoutes } from './routes/classroom' +import { homeRoutes } from './routes/home' +import { taskRoutes } from './routes/task' import { rankingRoutes } from './routes/ranking' import { problemsetRoutes } from './routes/problemset' import { achievementRoutes } from './routes/achievement' @@ -32,7 +33,8 @@ app.route('/api', accountRoutes) app.route('/api', siteRoutes) app.route('/api', contestRoutes) app.route('/api', contentRoutes) -app.route('/api', classroomRoutes) +app.route('/api', homeRoutes) +app.route('/api', taskRoutes) app.route('/api', rankingRoutes) app.route('/api', problemsetRoutes) app.route('/api', achievementRoutes) diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 3006cd4..4b07c33 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -446,7 +446,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => { return users.length }) - // 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它, + // 新班级顺手加进班级列表:不加的话全站的班级下拉(任务页、提交列表、排名……)里都找不到它, // 原来要再去设置页手动加,经常忘 const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))] const options = await getWebsiteOptions() diff --git a/apps/api/src/routes/admin/learn.ts b/apps/api/src/routes/admin/learn.ts index c5ec182..2ca2c3d 100644 --- a/apps/api/src/routes/admin/learn.ts +++ b/apps/api/src/routes/admin/learn.ts @@ -64,7 +64,7 @@ function studentCondition(value: string | null) { /** * 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数, * 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、 - * 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前 + * 课堂任务(services/class-activity.ts 的 activityRows)同一个口径。不论早晚:读这课之前 * 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。 */ async function problemActivity(userIds: number[], problemIds: number[]) { diff --git a/apps/api/src/routes/admin/lesson.ts b/apps/api/src/routes/admin/lesson.ts index 9871d93..67916a0 100644 --- a/apps/api/src/routes/admin/lesson.ts +++ b/apps/api/src/routes/admin/lesson.ts @@ -10,10 +10,10 @@ import { localDateTime } from '../../time' import { queryInteger } from '../helpers' /** - * 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改, - * 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。 + * 后台「课堂任务」:布置过的每一节课(class_lesson)。任务页上布置错了班、错了题可以在这里改, + * 任务页外上的课(以前的、没在任务页布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。 * - * 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。 + * 权限和任务页一样是老师:任务页上任何老师都能改任何班的这节课,这里不另设门槛。 */ export const adminLessonRoutes = new Hono() @@ -116,7 +116,7 @@ async function lessonValues(body: AdminLessonRequest): Promise< > { const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1) if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` } - // 只画流程图不用语言,存成 null(和看板布置一样) + // 只画流程图不用语言,存成 null(和任务页布置一样) const language = body.mode === 'flowchart' ? null : body.language const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode) if ('code' in resolved) return resolved @@ -156,7 +156,7 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => { // 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次 const classes = await affectedClasses(id, result.values.className) const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className))) - // 布置的老师不换,和看板上「改」一样认第一次 + // 布置的老师不换,和任务页上「改」一样认第一次 const [row] = await db .update(schema.classLesson) .set({ ...result.values, updatedAt: new Date().toISOString() }) diff --git a/apps/api/src/routes/home.ts b/apps/api/src/routes/home.ts new file mode 100644 index 0000000..1f7c2d9 --- /dev/null +++ b/apps/api/src/routes/home.ts @@ -0,0 +1,253 @@ +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 { Hono } from 'hono' + +import { requireAuth, type AppEnv } from '../auth/middleware' +import { getLoginWindow } from '../auth/session' +import { db, schema } from '../db' +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' + +/** + * 首页上和「这节课」有关的两张卡:学生的「上次来」和「班里在做」。 + * 「这节课是哪几道题」的取法在 services/lesson.ts(课堂任务、后台、统计共用) + */ +export const homeRoutes = new Hono() + +/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */ +const LAST_VISIT_MAX_DAYS = 30 + +/** + * 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。 + * 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述, + * 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」 + */ +homeRoutes.get('/me/last-visit', requireAuth, async (c) => { + const user = c.get('user')! + const window = await getLoginWindow(c) + const previousLogin = window?.previousLogin ?? null + if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) { + return success(c, { previousLogin, summary: null } satisfies LastVisit) + } + const rows = await db + .select({ + problemId: schema.submission.problemId, + problemDisplayId: schema.problem.displayId, + title: schema.problem.title, + result: schema.submission.result, + createTime: schema.submission.createTime, + }) + .from(schema.submission) + .innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) + .where( + and( + eq(schema.submission.userId, user.id), + isNull(schema.submission.contestId), + gte(schema.submission.createTime, previousLogin), + lt(schema.submission.createTime, window.loginAt), + ), + ) + .orderBy(asc(schema.submission.createTime)) + if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit) + + // 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved + const byProblem = new Map< + number, + { + problemDisplayId: string + title: string + attempts: number + lastResult: (typeof rows)[number]['result'] + lastTime: string + solved: boolean + } + >() + const solved: { problemDisplayId: string; title: string }[] = [] + for (const row of rows) { + const item = byProblem.get(row.problemId) ?? { + problemDisplayId: row.problemDisplayId, + title: row.title, + attempts: 0, + lastResult: row.result, + lastTime: row.createTime, + solved: false, + } + item.attempts++ + item.lastResult = row.result + item.lastTime = row.createTime + if (!item.solved && accepted.includes(row.result)) { + item.solved = true + solved.push({ problemDisplayId: row.problemDisplayId, title: row.title }) + } + byProblem.set(row.problemId, item) + } + // 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」 + const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id) + const everSolved = tried.length + ? new Set( + ( + await db + .selectDistinct({ problemId: schema.submission.problemId }) + .from(schema.submission) + .where( + and( + eq(schema.submission.userId, user.id), + isNull(schema.submission.contestId), + inArray(schema.submission.problemId, tried), + inArray(schema.submission.result, accepted), + ), + ) + ).map((row) => row.problemId), + ) + : new Set() + const unsolved = tried + .filter((id) => !everSolved.has(id)) + .map((id) => byProblem.get(id)!) + .sort((a, b) => b.lastTime.localeCompare(a.lastTime)) + .slice(0, 3) + .map(({ problemDisplayId, title, attempts, lastResult }) => ({ + problemDisplayId, + title, + attempts, + lastResult, + })) + return success(c, { + previousLogin, + summary: { + submissionCount: rows.length, + solvedCount: solved.length, + lastSubmitTime: rows.at(-1)!.createTime, + unsolved, + solved: solved.slice(0, 12), + }, + } satisfies LastVisit) +}) + +/** + * 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的 + * 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。 + */ +homeRoutes.get('/me/class-activity', requireAuth, async (c) => { + const user = c.get('user')! + if (!user.className) { + return success(c, { + className: null, + day: null, + source: null, + language: null, + mode: null, + problems: [], + } satisfies ClassActivity) + } + + 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 codeLanguage = steps ? steps.language : lesson.language + // 代码提交和流程图提交两张表,互不依赖,一起查 + const [mine, drawn] = await Promise.all([ + ids.length + ? db + .select({ + problemId: schema.submission.problemId, + accepted: sql`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`, + }) + .from(schema.submission) + .where( + and( + eq(schema.submission.userId, user.id), + inArray(schema.submission.problemId, ids), + isNull(schema.submission.contestId), + // 布置的是 C:以前用 Python 做对过不算这份作业做完 + lessonLanguageFilter(codeLanguage), + ), + ) + .groupBy(schema.submission.problemId) + : [], + // 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径 + ids.length + ? db + .select({ + problemId: schema.flowchartSubmission.problemId, + passed: sql`coalesce(bool_or(${FLOWCHART_PASSED}), false)`, + }) + .from(schema.flowchartSubmission) + .where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids))) + .groupBy(schema.flowchartSubmission.problemId) + : [], + ]) + type Step = 'accepted' | 'tried' | 'none' + const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried') + const codeById = new Map(mine.map((row) => [row.problemId, row.accepted])) + const drawById = new Map(drawn.map((row) => [row.problemId, row.passed])) + + return success(c, { + className: user.className, + day: lesson.day, + source: lesson.source, + language: codeLanguage, + mode: lesson.mode, + 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) + let myStatus: Step + if (needs) { + // 老师布置的:要哪几步就看哪几步,全做到才算做完 + const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null) + myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none' + } else { + // 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」) + myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none' + } + return { + problemDisplayId: problem.displayId, + title: problem.title, + userCount: problem.userCount, + acceptedCount: problem.acceptedCount, + myStatus, + draw: needs?.draw ? draw : null, + code: needs?.code ? code : null, + } satisfies ClassActivityProblem + }), + } 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/problem.ts b/apps/api/src/routes/problem.ts index fa5ce93..18b8069 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -73,7 +73,7 @@ function typeFilter(type: ProblemTypeFilter | undefined) { } /** - * 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。 + * 课堂任务「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。 * 学生的题目列表不带这个参数 */ function lessonLanguageFilter(raw: string | undefined) { diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index 466daac..d5cf774 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -361,7 +361,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => { /** * 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。 * - * 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计 + * 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计 * 也是任何老师看任何班,这里和它们一致。 * * 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它, diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index 2992a37..6dd4e5f 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -529,7 +529,7 @@ const LESSONS_MAX = 500 type StatisticsRange = { start: string | null; end: string } -/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */ +/** 这段时间里(按东八区日子)老师在课堂任务布置过的课,同一天的按布置先后 */ async function lessonPlans(range: StatisticsRange) { return db .select({ diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/task.ts similarity index 55% rename from apps/api/src/routes/classroom.ts rename to apps/api/src/routes/task.ts index 809eee7..a7dde13 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/task.ts @@ -1,440 +1,40 @@ import { STUDENT_ROLES, classLessonRequestSchema, - type ClassActivity, type ClassToday, - type ClassBoard, - type ClassBoardStudent, + type TaskBoard, + type TaskBoardStudent, type ClassLesson, - type ClassActivityProblem, - type LastVisit, - type LessonLanguage, - type LessonMode, lessonNeeds, type StatisticInfo, } from '@oj2/contract' -import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm' +import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { Hono } from 'hono' -import { requireAuth, requireTeacher, type AppEnv } from '../auth/middleware' +import { requireTeacher, type AppEnv } from '../auth/middleware' import { seenToday } from '../auth/presence' -import { getLoginWindow } from '../auth/session' import { presenceOf } from '../collab/state' import { db, schema } from '../db' import { challengeSuccessesOf } from '../services/challenge' import { failure, parseBody, success } from '../http' import { isAccepted } from '../judge/status' import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity' -import { accepted } from '../services/learning-stats' -import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson' +import { + announceLessonChange, + CLASS_ACTIVITY_MIN_USERS, + classDayGroups, + classLessonProblems, + lessonDoneCounts, + resolveLessonProblems, + todayLessonSnapshot, +} from '../services/lesson' import { calendarDay, dayStart, localTime } from '../time' -export const classroomRoutes = new Hono() - /** - * 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的: - * (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), - * 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。 + * 课堂任务(老师):布置这节课的题、看全班做到哪、老师首页的「今天的课」。 + * 「这节课是哪几道题」的取法在 services/lesson.ts,学生首页的「班里在做」也用它(routes/home.ts) */ -const CLASS_ACTIVITY_MIN_USERS = 5 -/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */ -const CLASS_ACTIVITY_LOOKBACK_DAYS = 7 -const CLASS_ACTIVITY_LIMIT = 8 -interface ClassDayGroup { - day: string - problemId: number - firstAt: string - userCount: number - acceptedCount: number -} - -/** - * 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生, - * 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛, - * 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。 - * - * 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数, - * 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数 - */ -async function classDayGroups( - className: string, - since: string, - options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {}, -): Promise { - const { mode } = options - const activity = activityRows({ - since, - users: classMemberIds(className), - problemIds: options.problemIds, - language: options.language, - only: mode === 'code' || mode === 'flowchart' ? mode : undefined, - }) - const day = sql`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')` - const perUser = db - .select({ - day: day.as('day'), - problemId: sql`${activity.problemId}`.as('problem_id'), - userId: sql`${activity.userId}`.as('user_id'), - firstAt: sql`min(${activity.createTime})`.as('first_at'), - solved: (mode === 'both' - ? sql`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})` - : sql`bool_or(${activity.solved})` - ).as('solved'), - }) - .from(activity) - .groupBy(day, activity.problemId, activity.userId) - .as('per_user') - const userCount = sql`count(*)::int` - const query = db - .select({ - day: perUser.day, - problemId: perUser.problemId, - firstAt: sql`min(${perUser.firstAt})`, - userCount, - acceptedCount: sql`(count(*) filter (where ${perUser.solved}))::int`, - }) - .from(perUser) - .groupBy(perUser.day, perUser.problemId) - return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query -} - -/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */ -async function visibleProblems(ids: number[]) { - if (!ids.length) return new Map() - const rows = await db - .select({ - id: schema.problem.id, - displayId: schema.problem.displayId, - title: schema.problem.title, - isSql: sql`${schema.problem.languages} @> '["SQL"]'::jsonb`, - }) - .from(schema.problem) - .where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId))) - return new Map(rows.map((row) => [row.id, row])) -} - -/** - * 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师; - * 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁 - */ -async function lessonPlan(className: string, day: string) { - const [row] = await db - .select({ - id: schema.classLesson.id, - problemIds: schema.classLesson.problemIds, - language: schema.classLesson.language, - mode: schema.classLesson.mode, - createdAt: schema.classLesson.createdAt, - createdBy: sql`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`, - }) - .from(schema.classLesson) - .innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy)) - .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy)) - .where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day))) - .orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id)) - .limit(1) - return row?.problemIds.length ? row : null -} - -/** - * 班里「这节课」的题。老师在课堂看板布置过就用老师的(source = teacher), - * 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的 - * **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。 - * - * 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的 - * 学生最需要它的时候。 - */ -async function classLessonProblems(className: string, lookbackDays: number) { - const today = calendarDay() - const plan = await lessonPlan(className, today) - if (plan) { - const planned = plan.problemIds - const [problems, groups] = await Promise.all([ - visibleProblems(planned), - classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }), - ]) - const groupById = new Map(groups.map((row) => [row.problemId, row])) - return { - source: 'teacher' as const, - lessonId: plan.id, - day: today, - language: plan.language ?? null, - mode: plan.mode, - assignedAt: plan.createdAt, - assignedBy: plan.createdBy, - problems: planned.flatMap((id) => { - const problem = problems.get(id) - if (!problem) return [] - const group = groupById.get(id) - return [ - { - ...problem, - userCount: group?.userCount ?? 0, - acceptedCount: group?.acceptedCount ?? 0, - }, - ] - }), - } - } - - const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000) - const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS }) - const latest = groups.reduce((acc, row) => (!acc || row.day > acc ? row.day : acc), null) - const picked = groups - .filter((row) => row.day === latest) - .sort((a, b) => a.firstAt.localeCompare(b.firstAt)) - .slice(0, CLASS_ACTIVITY_LIMIT) - const problems = await visibleProblems(picked.map((row) => row.problemId)) - return { - source: picked.length ? ('inferred' as const) : null, - lessonId: null, - day: picked.length ? latest : null, - // 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限 - language: null, - mode: null, - assignedAt: null, - assignedBy: null, - problems: picked.flatMap((row) => { - const problem = problems.get(row.problemId) - return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : [] - }), - } -} - -/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */ -const LAST_VISIT_MAX_DAYS = 30 - -/** - * 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。 - * 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述, - * 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」 - */ -classroomRoutes.get('/me/last-visit', requireAuth, async (c) => { - const user = c.get('user')! - const window = await getLoginWindow(c) - const previousLogin = window?.previousLogin ?? null - if (!window || !previousLogin || Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5) { - return success(c, { previousLogin, summary: null } satisfies LastVisit) - } - const rows = await db - .select({ - problemId: schema.submission.problemId, - problemDisplayId: schema.problem.displayId, - title: schema.problem.title, - result: schema.submission.result, - createTime: schema.submission.createTime, - }) - .from(schema.submission) - .innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) - .where( - and( - eq(schema.submission.userId, user.id), - isNull(schema.submission.contestId), - gte(schema.submission.createTime, previousLogin), - lt(schema.submission.createTime, window.loginAt), - ), - ) - .orderBy(asc(schema.submission.createTime)) - if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit) - - // 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved - const byProblem = new Map< - number, - { - problemDisplayId: string - title: string - attempts: number - lastResult: (typeof rows)[number]['result'] - lastTime: string - solved: boolean - } - >() - const solved: { problemDisplayId: string; title: string }[] = [] - for (const row of rows) { - const item = byProblem.get(row.problemId) ?? { - problemDisplayId: row.problemDisplayId, - title: row.title, - attempts: 0, - lastResult: row.result, - lastTime: row.createTime, - solved: false, - } - item.attempts++ - item.lastResult = row.result - item.lastTime = row.createTime - if (!item.solved && accepted.includes(row.result)) { - item.solved = true - solved.push({ problemDisplayId: row.problemDisplayId, title: row.title }) - } - byProblem.set(row.problemId, item) - } - // 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」 - const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id) - const everSolved = tried.length - ? new Set( - ( - await db - .selectDistinct({ problemId: schema.submission.problemId }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - isNull(schema.submission.contestId), - inArray(schema.submission.problemId, tried), - inArray(schema.submission.result, accepted), - ), - ) - ).map((row) => row.problemId), - ) - : new Set() - const unsolved = tried - .filter((id) => !everSolved.has(id)) - .map((id) => byProblem.get(id)!) - .sort((a, b) => b.lastTime.localeCompare(a.lastTime)) - .slice(0, 3) - .map(({ problemDisplayId, title, attempts, lastResult }) => ({ - problemDisplayId, - title, - attempts, - lastResult, - })) - return success(c, { - previousLogin, - summary: { - submissionCount: rows.length, - solvedCount: solved.length, - lastSubmitTime: rows.at(-1)!.createTime, - unsolved, - solved: solved.slice(0, 12), - }, - } satisfies LastVisit) -}) - -/** - * 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的 - * 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。 - */ -classroomRoutes.get('/me/class-activity', requireAuth, async (c) => { - const user = c.get('user')! - if (!user.className) { - return success(c, { - className: null, - day: null, - source: null, - language: null, - mode: null, - problems: [], - } satisfies ClassActivity) - } - - 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 codeLanguage = steps ? steps.language : lesson.language - // 代码提交和流程图提交两张表,互不依赖,一起查 - const [mine, drawn] = await Promise.all([ - ids.length - ? db - .select({ - problemId: schema.submission.problemId, - accepted: sql`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`, - }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - inArray(schema.submission.problemId, ids), - isNull(schema.submission.contestId), - // 布置的是 C:以前用 Python 做对过不算这份作业做完 - lessonLanguageFilter(codeLanguage), - ), - ) - .groupBy(schema.submission.problemId) - : [], - // 流程图:交过就算「做过」,评分中、评失败的也算 —— 和代码提交编译错误也算「做过」同一个口径 - ids.length - ? db - .select({ - problemId: schema.flowchartSubmission.problemId, - passed: sql`coalesce(bool_or(${FLOWCHART_PASSED}), false)`, - }) - .from(schema.flowchartSubmission) - .where(and(eq(schema.flowchartSubmission.userId, user.id), inArray(schema.flowchartSubmission.problemId, ids))) - .groupBy(schema.flowchartSubmission.problemId) - : [], - ]) - type Step = 'accepted' | 'tried' | 'none' - const stepOf = (value: boolean | undefined): Step => (value === undefined ? 'none' : value ? 'accepted' : 'tried') - const codeById = new Map(mine.map((row) => [row.problemId, row.accepted])) - const drawById = new Map(drawn.map((row) => [row.problemId, row.passed])) - - return success(c, { - className: user.className, - day: lesson.day, - source: lesson.source, - language: codeLanguage, - mode: lesson.mode, - 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) - let myStatus: Step - if (needs) { - // 老师布置的:要哪几步就看哪几步,全做到才算做完 - const wanted = [needs.draw ? draw : null, needs.code ? code : null].filter((step): step is Step => step !== null) - myStatus = wanted.every((step) => step === 'accepted') ? 'accepted' : wanted.some((step) => step !== 'none') ? 'tried' : 'none' - } else { - // 推断出来的课:代码交对或流程图画到 A / S 都算(有的题全班在画流程图,只看代码的话永远是「没做」) - myStatus = code === 'accepted' || draw === 'accepted' ? 'accepted' : code === 'tried' || draw === 'tried' ? 'tried' : 'none' - } - return { - problemDisplayId: problem.displayId, - title: problem.title, - userCount: problem.userCount, - acceptedCount: problem.acceptedCount, - myStatus, - draw: needs?.draw ? draw : null, - code: needs?.code ? code : null, - } satisfies ClassActivityProblem - }), - } 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 } -} +export const taskRoutes = new Hono() /** * 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2, @@ -450,7 +50,7 @@ function flowchartCaseCounts() { } /** - * 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过 + * 任务页上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过 * 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」 * —— 当堂那份名单把这两种人排在同一行里,老师分不出来。 */ @@ -459,11 +59,11 @@ const RECENT_LESSONS = 5 const RECENT_LESSONS_WINDOW_DAYS = 45 /** - * 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、 + * 课堂任务:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、 * 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一), * 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。 */ -classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { +taskRoutes.get('/task/board', requireTeacher, async (c) => { const day = calendarDay() const className = c.req.query('className')?.trim() || (await suggestActiveClass()) if (!className) { @@ -480,10 +80,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { problems: [], students: [], recentLessons: 0, - } satisfies ClassBoard) + } satisfies TaskBoard) } - // 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来。 + // 推断只看今天(回看 1 天):任务页是给这节课用的,昨天的题不该冒出来。 // lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种) const [lesson, [last]] = await Promise.all([ classLessonProblems(className, 1), @@ -562,7 +162,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { .where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start))) .groupBy(schema.submission.userId) : [], - // 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手。 + // 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在任务页上是一直没动手。 // 和上面代码那条同一个范围:今天、所有题、不论评没评完 userIds.length ? db @@ -586,7 +186,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { : [], /** * 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。 - * 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」。 + * 有的题这节课整个班都在画流程图,只数代码提交的话,任务页上满屏「没开始」。 * 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算 */ ids.length && userIds.length @@ -769,18 +369,18 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { editing: presenceOf(student.userId)?.problemId ?? null, online: seen.get(student.userId)?.online ?? false, seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null, - }) satisfies ClassBoardStudent, + }) satisfies TaskBoardStudent, ), recentLessons: recentDays.length, - } satisfies ClassBoard) + } satisfies TaskBoard) }) /** * 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」 - * 同一个门槛),或者老师今天在看板上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数 - * 和看板同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天 + * 同一个门槛),或者老师今天在任务页上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数 + * 和任务页同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天 */ -classroomRoutes.get('/classroom/today', requireTeacher, async (c) => { +taskRoutes.get('/task/today', requireTeacher, async (c) => { const start = dayStart() const today = calendarDay() const activity = activityRows({ since: start }) @@ -810,13 +410,13 @@ classroomRoutes.get('/classroom/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 }, ]) allDoneCount = counts.get(lesson.lessonId) ?? 0 } else if (ids.length) { - // 推断出来的:做没做对不限今天(和看板的格子一样:以前做对的也算做完),代码或流程图都算 + // 推断出来的:做没做对不限今天(和任务页的格子一样:以前做对的也算做完),代码或流程图都算 const done = activityRows({ since: new Date(0).toISOString(), users: classMemberIds(className), @@ -857,10 +457,10 @@ classroomRoutes.get('/classroom/today', requireTeacher, async (c) => { }) /** - * 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先, + * 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂任务(老师布置的优先, * 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班 */ -classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => { +taskRoutes.get('/task/lesson', requireTeacher, async (c) => { const className = c.req.query('className')?.trim() || (await suggestActiveClass()) if (!className) return success(c, { @@ -869,7 +469,7 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => { language: null, problems: [], } satisfies ClassLesson) - // 和看板一样只回看今天:「这节课」不该冒出昨天的题 + // 和任务页一样只回看今天:「这节课」不该冒出昨天的题 const lesson = await classLessonProblems(className, 1) // C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language) const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL')) @@ -889,12 +489,12 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => { * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节, * 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。 */ -classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => { +taskRoutes.put('/task/lesson', requireTeacher, async (c) => { const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对') if (!parsed.success) return parsed.response const user = c.get('user')! const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data - // 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」 + // 只画流程图不用语言:存成 null,任务页和学生那边就不会写「C语言」 const language = mode === 'flowchart' ? null : parsed.data.language const day = calendarDay() diff --git a/apps/api/src/services/challenge.ts b/apps/api/src/services/challenge.ts index 289fef1..c5e4b46 100644 --- a/apps/api/src/services/challenge.ts +++ b/apps/api/src/services/challenge.ts @@ -333,7 +333,7 @@ export async function challengeList(user: { id: number; className: string | null }) } -/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */ +/** 课堂任务用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */ export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) { if (userIds.length === 0 || problemIds.length === 0) return [] return db diff --git a/apps/api/src/services/class-activity.ts b/apps/api/src/services/class-activity.ts index 32a909d..726f99e 100644 --- a/apps/api/src/services/class-activity.ts +++ b/apps/api/src/services/class-activity.ts @@ -5,8 +5,8 @@ import { db, schema } from '../db' import { JudgeStatus } from '../judge/status' /** - * 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」 - * 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班, + * 课堂上「谁做了哪道题」的取数口径:课堂条、任务页、自学情况、后台首页「正在上课的班」 + * 都从这里数。原来写在课堂任务的路由里,后台 conf.ts 要猜正在上课的班, * 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。 */ /** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */ @@ -30,7 +30,7 @@ export function classMemberIds(className: string) { /** * 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计 * (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数, - * 口径和课堂条 / 看板的「做完」一致: + * 口径和课堂条 / 任务页的「做完」一致: * * - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败; * - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。 @@ -99,7 +99,7 @@ export function activityRows(options: { return code.unionAll(flowchart).as('activity') } -/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */ +/** 任务页没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */ const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000 /** diff --git a/apps/api/src/services/learning-stats.ts b/apps/api/src/services/learning-stats.ts index 94272fe..21030ab 100644 --- a/apps/api/src/services/learning-stats.ts +++ b/apps/api/src/services/learning-stats.ts @@ -3,10 +3,10 @@ * * 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」, * 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。 - * 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts): - * 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。 + * 一节课的题怎么定,和课堂任务 / 首页「班里在做」同一个口径(services/lesson.ts 的 classLessonProblems): + * 老师在任务页布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。 * - * 做对的口径也同看板:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算 + * 做对的口径也同任务页:代码 AC / AST_CHECK_FAILED、流程图评到 A / S;比赛提交不算 * (比赛是课堂测验,题目也不在题库里)。 */ import { FLOWCHART_PASS_GRADES, STUDENT_ROLES, type LearningDay, type LearningItem, type LearningPeriod, type LearningReport } from '@oj2/contract' @@ -19,7 +19,7 @@ import { calendarDay, monthStart, termStart, weekStart } from '../time' export const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] -/** 同 routes/classroom.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */ +/** 同 services/lesson.ts 的 CLASS_ACTIVITY_MIN_USERS:同班同一天几个人做过,才算这节课的题 */ const LESSON_MIN_USERS = 5 /** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */ diff --git a/apps/api/src/services/lesson.ts b/apps/api/src/services/lesson.ts index 8c5232c..b966d20 100644 --- a/apps/api/src/services/lesson.ts +++ b/apps/api/src/services/lesson.ts @@ -3,12 +3,12 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' import { db, schema } from '../db' import { publishLessonUpdate } from '../events' -import { calendarDay, dayNumber, dayStart } from '../time' -import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity' +import { calendarDay, dayNumber, dayStart, localTime } from '../time' +import { activityRows, classMemberIds, FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity' /** - * 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、 - * 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。 + * 布置的课(class_lesson)几处共用的口径:课堂任务布置、后台「课堂任务」增改、 + * 数据统计按布置切课。放在一起,免得任务页能布置的题后台布置不上,或者两边「做完」数得不一样。 */ /** @@ -55,7 +55,7 @@ export function lessonDayEnd(day: string) { /** * 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。 * 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言 - * (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。 + * (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和任务页的格子同一个口径。 * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 */ export async function lessonDoneCounts( @@ -119,7 +119,7 @@ interface LessonSnapshot { } /** - * 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法), + * 学生此刻看到的那一节:这个班今天最后布置的(和下面 lessonPlan 同一个取法), * 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比 */ export async function todayLessonSnapshot(className: string): Promise { @@ -175,3 +175,173 @@ export async function announceLessonChange(className: string, before: LessonSnap console.error('Failed to announce lesson change', error) } } + +/** + * 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的: + * (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), + * 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。 + */ +export const CLASS_ACTIVITY_MIN_USERS = 5 +/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */ +export const CLASS_ACTIVITY_LOOKBACK_DAYS = 7 +const CLASS_ACTIVITY_LIMIT = 8 +interface ClassDayGroup { + day: string + problemId: number + firstAt: string + userCount: number + acceptedCount: number +} + +/** + * 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生, + * 不含比赛提交,代码和流程图合起来数(见 activityRows)。`minUsers` 是「班里在做」的门槛, + * 给了 `problemIds`(老师布置的题)时不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。 + * + * 给了 `mode`(老师布置的这节做什么):写代码只数代码、画流程图只数流程图;先画再写两种都数, + * 「通过」要同一个人两样都做到 —— 所以先按人归一遍再数 + */ +export async function classDayGroups( + className: string, + since: string, + options: { minUsers?: number; problemIds?: number[]; language?: LessonLanguage | null; mode?: LessonMode | null } = {}, +): Promise { + const { mode } = options + const activity = activityRows({ + since, + users: classMemberIds(className), + problemIds: options.problemIds, + language: options.language, + only: mode === 'code' || mode === 'flowchart' ? mode : undefined, + }) + const day = sql`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')` + const perUser = db + .select({ + day: day.as('day'), + problemId: sql`${activity.problemId}`.as('problem_id'), + userId: sql`${activity.userId}`.as('user_id'), + firstAt: sql`min(${activity.createTime})`.as('first_at'), + solved: (mode === 'both' + ? sql`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})` + : sql`bool_or(${activity.solved})` + ).as('solved'), + }) + .from(activity) + .groupBy(day, activity.problemId, activity.userId) + .as('per_user') + const userCount = sql`count(*)::int` + const query = db + .select({ + day: perUser.day, + problemId: perUser.problemId, + firstAt: sql`min(${perUser.firstAt})`, + userCount, + acceptedCount: sql`(count(*) filter (where ${perUser.solved}))::int`, + }) + .from(perUser) + .groupBy(perUser.day, perUser.problemId) + return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query +} + +/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */ +async function visibleProblems(ids: number[]) { + if (!ids.length) return new Map() + const rows = await db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + isSql: sql`${schema.problem.languages} @> '["SQL"]'::jsonb`, + }) + .from(schema.problem) + .where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId))) + return new Map(rows.map((row) => [row.id, row])) +} + +/** + * 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言和做什么、布置时刻和布置的老师; + * 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁 + */ +async function lessonPlan(className: string, day: string) { + const [row] = await db + .select({ + id: schema.classLesson.id, + problemIds: schema.classLesson.problemIds, + language: schema.classLesson.language, + mode: schema.classLesson.mode, + createdAt: schema.classLesson.createdAt, + createdBy: sql`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})`, + }) + .from(schema.classLesson) + .innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy)) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy)) + .where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day))) + .orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id)) + .limit(1) + return row?.problemIds.length ? row : null +} + +/** + * 班里「这节课」的题。老师在课堂任务布置过就用老师的(source = teacher), + * 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的 + * **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。 + * + * 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的 + * 学生最需要它的时候。 + */ +export async function classLessonProblems(className: string, lookbackDays: number) { + const today = calendarDay() + const plan = await lessonPlan(className, today) + if (plan) { + const planned = plan.problemIds + const [problems, groups] = await Promise.all([ + visibleProblems(planned), + classDayGroups(className, dayStart(), { problemIds: planned, language: plan.language, mode: plan.mode }), + ]) + const groupById = new Map(groups.map((row) => [row.problemId, row])) + return { + source: 'teacher' as const, + lessonId: plan.id, + day: today, + language: plan.language ?? null, + mode: plan.mode, + assignedAt: plan.createdAt, + assignedBy: plan.createdBy, + problems: planned.flatMap((id) => { + const problem = problems.get(id) + if (!problem) return [] + const group = groupById.get(id) + return [ + { + ...problem, + userCount: group?.userCount ?? 0, + acceptedCount: group?.acceptedCount ?? 0, + }, + ] + }), + } + } + + const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000) + const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS }) + const latest = groups.reduce((acc, row) => (!acc || row.day > acc ? row.day : acc), null) + const picked = groups + .filter((row) => row.day === latest) + .sort((a, b) => a.firstAt.localeCompare(b.firstAt)) + .slice(0, CLASS_ACTIVITY_LIMIT) + const problems = await visibleProblems(picked.map((row) => row.problemId)) + return { + source: picked.length ? ('inferred' as const) : null, + lessonId: null, + day: picked.length ? latest : null, + // 推断出来的课不知道老师要的是哪种语言、要画还是要写,不限 + language: null, + mode: null, + assignedAt: null, + assignedBy: null, + problems: picked.flatMap((row) => { + const problem = problems.get(row.problemId) + return problem ? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }] : [] + }), + } +} diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts index 660d679..3397d7e 100644 --- a/apps/api/src/time.ts +++ b/apps/api/src/time.ts @@ -52,7 +52,7 @@ export function dayNumber(day: string): number { return Date.UTC(year!, month! - 1, date!) / DAY_MS } -/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂作业用 */ +/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂任务用 */ export function localDateTime(day: string, time: string): string { const [hour, minute] = time.split(':').map(Number) return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString() diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 7304a01..f4d5591 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -64,7 +64,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap Shared logic lives in `shared/`: - `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (登录框、注册框开没开), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码) -- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity` +- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render,渲染器是 pele 不是 mermaid), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂任务的「请假」), `problemJump` (按题号直达), `teacherHelp` (老师接单 / 打开学生编辑器), `chartTheme`, `breakpoints`, `rarity` - `layout/` — `default.vue` and `admin.vue` layout wrappers - `api.ts` — shared API calls (auth, profile, tags, captcha) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 9ad1125..124393b 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -780,7 +780,7 @@ export function deleteAchievement(id: number) { return api.delete(`admin/achievements/${id}`) } -// ---------- 课堂作业(class_lesson) ---------- +// ---------- 课堂任务(class_lesson) ---------- export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) { return api.get('admin/lessons', { diff --git a/apps/web/src/admin/lesson/components/LessonEditModal.vue b/apps/web/src/admin/lesson/components/LessonEditModal.vue index 4d84703..1b69b8f 100644 --- a/apps/web/src/admin/lesson/components/LessonEditModal.vue +++ b/apps/web/src/admin/lesson/components/LessonEditModal.vue @@ -1,7 +1,7 @@ diff --git a/apps/web/src/oj/home/TeacherHome.vue b/apps/web/src/oj/home/TeacherHome.vue index 585ce85..e053ad0 100644 --- a/apps/web/src/oj/home/TeacherHome.vue +++ b/apps/web/src/oj/home/TeacherHome.vue @@ -10,7 +10,7 @@ import { parseTime, zonedParts, zonedYear } from 'utils/functions' /** * 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交), * 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、 - * 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。 + * 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂任务」进那个班。 * 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前) */ const theme = useThemeVars() @@ -61,13 +61,13 @@ function metaOf(row: ClassRow) { return parts.join(' · ') } -function openBoard(row: ClassRow) { - router.push({ name: 'classroom board', query: { className: row.className } }) +function openTask(row: ClassRow) { + router.push({ name: 'task', query: { className: row.className } }) } const shortcuts = computed(() => [ - { label: '课堂看板', to: '/classroom', show: true }, + { label: '课堂任务', to: '/task', show: true }, { label: '数据统计', to: '/statistics', show: true }, { label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission }, { label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true }, @@ -92,7 +92,7 @@ const shortcuts = computed(() => {{ stateOf(row).text }} {{ metaOf(row) }} - 课堂看板 + 课堂任务
(visit.value?.solved ?? []).map((item) => `${i const classActivityTitle = computed(() => { const day = classActivity.value?.day if (!day) return '' - // 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 + // 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 if (classActivity.value?.source === 'teacher') { const language = classActivity.value.language const mode = classActivity.value.mode diff --git a/apps/web/src/oj/class/components/PkDuel.vue b/apps/web/src/oj/pk/components/PkDuel.vue similarity index 100% rename from apps/web/src/oj/class/components/PkDuel.vue rename to apps/web/src/oj/pk/components/PkDuel.vue diff --git a/apps/web/src/oj/class/components/PkGrid.vue b/apps/web/src/oj/pk/components/PkGrid.vue similarity index 100% rename from apps/web/src/oj/class/components/PkGrid.vue rename to apps/web/src/oj/pk/components/PkGrid.vue diff --git a/apps/web/src/oj/class/components/PkHist.vue b/apps/web/src/oj/pk/components/PkHist.vue similarity index 100% rename from apps/web/src/oj/class/components/PkHist.vue rename to apps/web/src/oj/pk/components/PkHist.vue diff --git a/apps/web/src/oj/class/components/PkRace.vue b/apps/web/src/oj/pk/components/PkRace.vue similarity index 100% rename from apps/web/src/oj/class/components/PkRace.vue rename to apps/web/src/oj/pk/components/PkRace.vue diff --git a/apps/web/src/oj/class/pk.vue b/apps/web/src/oj/pk/index.vue similarity index 100% rename from apps/web/src/oj/class/pk.vue rename to apps/web/src/oj/pk/index.vue diff --git a/apps/web/src/oj/class/utils.ts b/apps/web/src/oj/pk/utils.ts similarity index 100% rename from apps/web/src/oj/class/utils.ts rename to apps/web/src/oj/pk/utils.ts diff --git a/apps/web/src/oj/problem/components/CollabBar.vue b/apps/web/src/oj/problem/components/CollabBar.vue index 9adc86a..bdf6dbe 100644 --- a/apps/web/src/oj/problem/components/CollabBar.vue +++ b/apps/web/src/oj/problem/components/CollabBar.vue @@ -8,10 +8,10 @@ import UserName from 'shared/components/UserName.vue' * 老师协作时右栏顶上的一条(设计稿「举手与协作」HcPageA):正在帮谁、举手多久了、 * 看他交过什么、帮完了、下一个。语言不写(用户说的):编辑器工具栏的语言下拉就摆在下面。 * - * 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开看板, - * 所以「帮完了」自动回到这个班的看板,还有人举手就给「下一个」直接去下一个人的题。 + * 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开任务页, + * 所以「帮完了」自动回到这个班的任务页,还有人举手就给「下一个」直接去下一个人的题。 * - * 老师在看板上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。 + * 老师在任务页上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。 */ const collabStore = useCollabStore() const { problem } = storeToRefs(useProblemStore()) @@ -46,7 +46,7 @@ function openHisSubmissions() { } /** - * 帮完了:结束这次(求助记录一并清掉),回到这个班的看板。 + * 帮完了:结束这次(求助记录一并清掉),回到这个班的任务页。 * 等 room_closed 回来、room 清空了再跳 —— 房间还开着时离开题目页,题目页的拦截 * (detail.vue 的 confirmLeavingHelp)会把跳转改成开新标签 */ @@ -54,7 +54,7 @@ async function finish() { const className = room.value?.peerClassName collabStore.leave('done') await until(() => collabStore.room).toBe(null, { timeout: 3000 }) - router.push({ name: 'classroom board', query: className ? { className } : {} }) + router.push({ name: 'task', query: className ? { className } : {} }) } /** diff --git a/apps/web/src/oj/problem/components/EditorToolbar.vue b/apps/web/src/oj/problem/components/EditorToolbar.vue index a576715..49af108 100644 --- a/apps/web/src/oj/problem/components/EditorToolbar.vue +++ b/apps/web/src/oj/problem/components/EditorToolbar.vue @@ -86,7 +86,7 @@ const showCollabBar = useTeacherCollab() */ const collabHere = useCollabHere() /** - * 老师在看板上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 —— + * 老师在任务页上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 —— * 按钮就写「xuyue 在看你的代码」,和「在帮你」一样亮着、点不了 */ const peeked = computed(() => collabHere.value && collabStore.room?.mode === 'peek' && collabStore.helpStatus === 'idle') diff --git a/apps/web/src/oj/problem/components/ProblemEditor.vue b/apps/web/src/oj/problem/components/ProblemEditor.vue index 65e6a76..ef94eb5 100644 --- a/apps/web/src/oj/problem/components/ProblemEditor.vue +++ b/apps/web/src/oj/problem/components/ProblemEditor.vue @@ -168,7 +168,7 @@ watch( ) /** - * 老师在课堂看板布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班, + * 老师在课堂任务布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班, * 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现 * 不算数。课堂条只在题库入口出现,这里同一个范围。 * diff --git a/apps/web/src/oj/problem/detail.vue b/apps/web/src/oj/problem/detail.vue index 15ca4f3..ff64ef0 100644 --- a/apps/web/src/oj/problem/detail.vue +++ b/apps/web/src/oj/problem/detail.vue @@ -174,7 +174,7 @@ function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNorm window.open(router.resolve(to).href, '_blank') return false } - // 老师在看板上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在 + // 老师在任务页上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在 const peeked = collabHere.value && collabStore.room?.mode === 'peek' if (collabStore.isTeacher || (collabStore.helpStatus === 'idle' && !peeked)) return true if (approvedTarget === to.fullPath) { diff --git a/apps/web/src/oj/rank/components/ClassBattle.vue b/apps/web/src/oj/rank/components/ClassBattle.vue index 68ec344..07d75e2 100644 --- a/apps/web/src/oj/rank/components/ClassBattle.vue +++ b/apps/web/src/oj/rank/components/ClassBattle.vue @@ -55,7 +55,7 @@ function shortLabel(className: string) {
班级对抗人均做对 - 班级 PK › + 班级 PK ›
{{ item.rank }} diff --git a/apps/web/src/oj/rank/components/ClassDetailDrawer.vue b/apps/web/src/oj/rank/components/ClassDetailDrawer.vue index c6b11f9..a64de08 100644 --- a/apps/web/src/oj/rank/components/ClassDetailDrawer.vue +++ b/apps/web/src/oj/rank/components/ClassDetailDrawer.vue @@ -213,7 +213,7 @@ function go(path: string, query: Record) { - 和别的班比 → 班级 PK › + 和别的班比 → 班级 PK ›
这个班的数据没取到
diff --git a/apps/web/src/oj/rank/list.vue b/apps/web/src/oj/rank/list.vue index dc91ca2..cc059b6 100644 --- a/apps/web/src/oj/rank/list.vue +++ b/apps/web/src/oj/rank/list.vue @@ -441,7 +441,7 @@ async function restore(userId: number) { secondary @click=" router.push({ - path: '/class', + path: '/pk', query: classContext ? { classes: classContext } : {}, }) " diff --git a/apps/web/src/oj/statistics/attempts.ts b/apps/web/src/oj/statistics/attempts.ts index 96d88bf..e499074 100644 --- a/apps/web/src/oj/statistics/attempts.ts +++ b/apps/web/src/oj/statistics/attempts.ts @@ -4,7 +4,7 @@ import type { Submission } from 'utils/types' import type { Tone } from 'shared/composables/tone' /** - * 一次提交一格的那串方块(统计页的 AttemptSquares、课堂看板做对格子的轨迹)共用的两样: + * 一次提交一格的那串方块(统计页的 AttemptSquares、课堂任务做对格子的轨迹)共用的两样: * 方块的颜色,和停上去看代码时拉的提交。 */ diff --git a/apps/web/src/oj/statistics/components/Lessons.vue b/apps/web/src/oj/statistics/components/Lessons.vue index 177f2c3..96237df 100644 --- a/apps/web/src/oj/statistics/components/Lessons.vue +++ b/apps/web/src/oj/statistics/components/Lessons.vue @@ -143,7 +143,7 @@ function percent(value: number, total: number) {
{{ liveLessons.map((item) => classLabel(item.className)).join('、') }}正在上课 - 去课堂看板 + 去课堂任务
这段时间没有人交
diff --git a/apps/web/src/oj/statistics/components/StatisticsView.vue b/apps/web/src/oj/statistics/components/StatisticsView.vue index 1570b2a..8d9593c 100644 --- a/apps/web/src/oj/statistics/components/StatisticsView.vue +++ b/apps/web/src/oj/statistics/components/StatisticsView.vue @@ -17,7 +17,7 @@ import StatItem from './StatItem.vue' /** * 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框 - * (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的, + * (老师平时从这里看),和 /statistics 页面(课堂任务「回头看」、题目页之类从别处进来的, * 条件放在地址栏里能刷新、能转发)。 * * 代码页签按条件分三种看法: @@ -27,8 +27,8 @@ import StatItem from './StatItem.vue' * - 选了班或填了学生:左边题目、右边学生(CodeStats) * 从前两种点进去时左上角有「← 全部」回来。 * - * 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有 - * 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。 + * 分工:**课堂任务管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有 + * 投影用的大字名单和请假隐藏 —— 那些在任务页上。课堂统计一般看一两节课,时间段默认「一节课」。 * * 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。 */ @@ -66,7 +66,7 @@ const custom = computed<[number, number] | null>(() => { }) /** - * 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样 + * 「做完」只数所选时段之内做对的,和任务页、后台「课堂任务」、一行一节课的「做完」不一样 * (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的 */ const doneLabel = computed(() => { @@ -229,7 +229,7 @@ const judged = computed(() => (stats.value ? stats.value.submissionCount - stats /** * 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭, * 1366 的屏上整排放不下,所以弹框里宽一档才出字。 - * 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的), + * 原来旁边还有一个「这节课去课堂任务」:顶栏已经有任务页,统计里再放一个成了干扰项(用户说的), * 现在只在有班正在上课时,在课的列表上方引过去 */ const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)') diff --git a/apps/web/src/oj/statistics/index.vue b/apps/web/src/oj/statistics/index.vue index ecfd48f..3b68719 100644 --- a/apps/web/src/oj/statistics/index.vue +++ b/apps/web/src/oj/statistics/index.vue @@ -4,7 +4,7 @@ import StatisticsView from './components/StatisticsView.vue' import { DEFAULT_PERIOD } from './period' /** - * 统计的页面形态:课堂看板「回头看」、题目页统计页签这些从别处进来时用。 + * 统计的页面形态:课堂任务「回头看」、题目页统计页签这些从别处进来时用。 * 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。 * * 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。 diff --git a/apps/web/src/oj/statistics/period.ts b/apps/web/src/oj/statistics/period.ts index 79cdfda..2823ad7 100644 --- a/apps/web/src/oj/statistics/period.ts +++ b/apps/web/src/oj/statistics/period.ts @@ -9,7 +9,7 @@ const DAY = 86_400_000 * 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、 * 默认「一节课」(用户定的);后面几档是回头看。名字后面不写「1 小时内」:选框旁边 * 本来就写着具体几点到几点。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的, - * 那件事现在归课堂看板。 + * 那件事现在归课堂任务。 */ export const PERIOD_OPTIONS = [ { label: '一节课', value: '1h' }, diff --git a/apps/web/src/oj/submission/components/SubmissionView.vue b/apps/web/src/oj/submission/components/SubmissionView.vue index 9026fff..cd60026 100644 --- a/apps/web/src/oj/submission/components/SubmissionView.vue +++ b/apps/web/src/oj/submission/components/SubmissionView.vue @@ -30,7 +30,7 @@ import StatusPill from './StatusPill.vue' * 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生 * 怎么一次次改对的。 * - * 独立的提交详情页(detail.vue)和课堂看板「讲错误」的弹层共用这一个。`embedded`(弹层里): + * 独立的提交详情页(detail.vue)和课堂任务「讲错误」的弹层共用这一个。`embedded`(弹层里): * 不出名字和题目那一行(弹层顶上有一排人名),不占 ← →(弹层拿它换人), * 点「第几次」就地换、不改地址栏。 */ diff --git a/apps/web/src/oj/submission/composables/lessonFilter.ts b/apps/web/src/oj/submission/composables/lessonFilter.ts index 4d3ea29..cc8fa05 100644 --- a/apps/web/src/oj/submission/composables/lessonFilter.ts +++ b/apps/web/src/oj/submission/composables/lessonFilter.ts @@ -4,7 +4,7 @@ import { classLabel } from 'utils/classes' import type { ClassLesson } from 'utils/types' import type { SubmissionQuery } from './listQuery' -/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */ +/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂任务 */ export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref; info: (text: string) => void }) { const { query } = options const lesson = ref(null) @@ -36,13 +36,13 @@ export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref } query.className = res.className if (!res.problems.length) { - options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`) + options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂任务布置`) return } lesson.value = res query.problem = lessonIds.value query.today = '1' - // 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动) + // 老师布置时选了语言:任务页上只数这个语言交的,列表也只列它(流程图那边不动) if (res.language && !options.flowMode.value) query.language = res.language } finally { lessonLoading.value = false diff --git a/apps/web/src/oj/submission/detail.vue b/apps/web/src/oj/submission/detail.vue index 6fc32c4..066064c 100644 --- a/apps/web/src/oj/submission/detail.vue +++ b/apps/web/src/oj/submission/detail.vue @@ -1,7 +1,7 @@ diff --git a/apps/web/src/oj/submission/list.vue b/apps/web/src/oj/submission/list.vue index 068803f..9e179f8 100644 --- a/apps/web/src/oj/submission/list.vue +++ b/apps/web/src/oj/submission/list.vue @@ -32,7 +32,7 @@ import { useStatsLink } from './composables/statsLink' * * 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在 * 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。 - * 完成情况在课堂看板和「统计」里,这页不重复造一张表。 + * 完成情况在课堂任务和「统计」里,这页不重复造一张表。 */ // 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着 @@ -579,7 +579,7 @@ function dayBreak(index: number) { -