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

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

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

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ const ONLINE_WINDOW_MS = 5 * 60 * 1000
/** /**
* 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。 * 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。
* *
* 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着, * 课堂任务拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
* 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。 * 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。
* 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。 * 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。
*/ */
+2 -2
View File
@@ -428,7 +428,7 @@ function acceptRequest(ws: CollabSocket, request: HelpRequest) {
/** /**
* 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找), * 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找),
* 「帮完了」要回到哪个班的看板 * 「帮完了」要回到哪个班的任务页
*/ */
function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) { function sendRoomOpen(room: Room, studentName: string, teacherName: string, className: string | null) {
const frame = (peer: Record<string, unknown>) => const frame = (peer: Record<string, unknown>) =>
@@ -630,7 +630,7 @@ function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown)
} }
/** /**
* 老师在看板上主动打开一个学生正在写的代码(学生没举手)。 * 老师在任务页上主动打开一个学生正在写的代码(学生没举手)。
* *
* 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」; * 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」;
* 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。 * 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。
+3 -3
View File
@@ -24,7 +24,7 @@ export function normalizeCollabLanguage(value: unknown): CollabLanguage {
} }
/** /**
* 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂看板的格子同一份形状, * 学生这道题最后一次代码提交,老师的举手面板按它写错法(和课堂任务的格子同一份形状,
* 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码 * 前端用同一个 errorKind 归类)。只带归类要用的几样,不带代码
*/ */
export interface CollabLatest { export interface CollabLatest {
@@ -111,7 +111,7 @@ export interface Room {
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */ /** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
studentId: number studentId: number
/** /**
* help = 学生举了手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。 * help = 学生举了手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
* 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清 * 两边看到的字不一样(「在帮你」/「在看你的代码」),peek 没有求助记录要清
*/ */
mode: 'help' | 'peek' mode: 'help' | 'peek'
@@ -144,7 +144,7 @@ export function roomOf(ws: CollabSocket) {
} }
/** /**
* 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在看板上「在写」那一列看它、 * 学生此刻开着哪道题的代码编辑器(题目页,非比赛)。老师在任务页上「在写」那一列看它、
* 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报, * 点它主动打开 —— 不用等学生举手。学生端编辑器挂上 / 换题 / 卸载时上报,
* 连接断了就清掉。同一账号开了两个标签页时,后报的算 * 连接断了就清掉。同一账号开了两个标签页时,后报的算
*/ */
+5 -5
View File
@@ -1293,10 +1293,10 @@ export const exerciseAttempt = pgTable(
) )
/** /**
* 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节 * 老师给某个班布置的一节课的题(课堂任务里布置的),一次布置一行。一个班一天可以有几节
* 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。 * 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。
* *
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。 * 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(services/lesson.ts 的 classLessonProblems)。
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。 * 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
* *
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列: * - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
@@ -1322,7 +1322,7 @@ export const classLesson = pgTable(
problemIds: jsonb('problem_ids').notNull().$type<number[]>(), problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
/** /**
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的 * 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过 * 没有它。写入侧在 PUT /task/lesson 按 lessonLanguageSchema 校验过
*/ */
language: text().$type<LessonLanguage>(), language: text().$type<LessonLanguage>(),
/** /**
@@ -1330,11 +1330,11 @@ export const classLesson = pgTable(
* 都是写代码。写入侧按 lessonModeSchema 校验过 * 都是写代码。写入侧按 lessonModeSchema 校验过
*/ */
mode: text().$type<LessonMode>().notNull().default('code'), mode: text().$type<LessonMode>().notNull().default('code'),
/** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */ /** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。任务页上写「HH:mm 谁 布置」 */
createdBy: integer('created_by').notNull(), createdBy: integer('created_by').notNull(),
/** /**
* 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。 * 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
* 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。 * 同一天有几节时,按它分先后:最后布置的那一节是任务页上的「这节课」。
* 加这一列之前布置的用 updated_at 回填(0034) * 加这一列之前布置的用 updated_at 回填(0034)
*/ */
createdAt: timestamp('created_at', { createdAt: timestamp('created_at', {
+4 -2
View File
@@ -10,7 +10,8 @@ import { authRoutes } from './routes/auth'
import { accountRoutes } from './routes/account' import { accountRoutes } from './routes/account'
import { contestRoutes } from './routes/contest' import { contestRoutes } from './routes/contest'
import { contentRoutes } from './routes/content' 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 { rankingRoutes } from './routes/ranking'
import { problemsetRoutes } from './routes/problemset' import { problemsetRoutes } from './routes/problemset'
import { achievementRoutes } from './routes/achievement' import { achievementRoutes } from './routes/achievement'
@@ -32,7 +33,8 @@ app.route('/api', accountRoutes)
app.route('/api', siteRoutes) app.route('/api', siteRoutes)
app.route('/api', contestRoutes) app.route('/api', contestRoutes)
app.route('/api', contentRoutes) app.route('/api', contentRoutes)
app.route('/api', classroomRoutes) app.route('/api', homeRoutes)
app.route('/api', taskRoutes)
app.route('/api', rankingRoutes) app.route('/api', rankingRoutes)
app.route('/api', problemsetRoutes) app.route('/api', problemsetRoutes)
app.route('/api', achievementRoutes) app.route('/api', achievementRoutes)
+1 -1
View File
@@ -446,7 +446,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
return users.length return users.length
}) })
// 新班级顺手加进班级列表:不加的话全站的班级下拉(看板、提交列表、排名……)里都找不到它, // 新班级顺手加进班级列表:不加的话全站的班级下拉(任务页、提交列表、排名……)里都找不到它,
// 原来要再去设置页手动加,经常忘 // 原来要再去设置页手动加,经常忘
const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))] const classes = [...new Set(prepared.map((item) => item.className).filter((name) => !!name))]
const options = await getWebsiteOptions() const options = await getWebsiteOptions()
+1 -1
View File
@@ -64,7 +64,7 @@ function studentCondition(value: string | null) {
/** /**
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数, * 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、 * 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
* 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前 * 课堂任务(services/class-activity.ts 的 activityRows)同一个口径。不论早晚:读这课之前
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。 * 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
*/ */
async function problemActivity(userIds: number[], problemIds: number[]) { async function problemActivity(userIds: number[], problemIds: number[]) {
+5 -5
View File
@@ -10,10 +10,10 @@ import { localDateTime } from '../../time'
import { queryInteger } from '../helpers' import { queryInteger } from '../helpers'
/** /**
* 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改, * 后台「课堂任务」:布置过的每一节课(class_lesson)。任务页上布置错了班、错了题可以在这里改,
* 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。 * 任务页外上的课(以前的、没在任务页布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。
* *
* 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。 * 权限和任务页一样是老师:任务页上任何老师都能改任何班的这节课,这里不另设门槛。
*/ */
export const adminLessonRoutes = new Hono<AppEnv>() export const adminLessonRoutes = new Hono<AppEnv>()
@@ -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) 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} 这个班` } if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` }
// 只画流程图不用语言,存成 null(和看板布置一样) // 只画流程图不用语言,存成 null(和任务页布置一样)
const language = body.mode === 'flowchart' ? null : body.language const language = body.mode === 'flowchart' ? null : body.language
const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode) const resolved = await resolveLessonProblems(body.problemDisplayIds, language, body.mode)
if ('code' in resolved) return resolved 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 classes = await affectedClasses(id, result.values.className)
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className))) const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
// 布置的老师不换,和看板上「改」一样认第一次 // 布置的老师不换,和任务页上「改」一样认第一次
const [row] = await db const [row] = await db
.update(schema.classLesson) .update(schema.classLesson)
.set({ ...result.values, updatedAt: new Date().toISOString() }) .set({ ...result.values, updatedAt: new Date().toISOString() })
+253
View File
@@ -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<AppEnv>()
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
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<number>()
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<boolean>`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<boolean>`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<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 }
}
+1 -1
View File
@@ -73,7 +73,7 @@ function typeFilter(type: ProblemTypeFilter | undefined) {
} }
/** /**
* 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。 * 课堂任务「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
* 学生的题目列表不带这个参数 * 学生的题目列表不带这个参数
*/ */
function lessonLanguageFilter(raw: string | undefined) { function lessonLanguageFilter(raw: string | undefined) {
+1 -1
View File
@@ -361,7 +361,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
/** /**
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。 * 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
* *
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计 * 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
* 也是任何老师看任何班,这里和它们一致。 * 也是任何老师看任何班,这里和它们一致。
* *
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它, * 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
+1 -1
View File
@@ -529,7 +529,7 @@ const LESSONS_MAX = 500
type StatisticsRange = { start: string | null; end: string } type StatisticsRange = { start: string | null; end: string }
/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */ /** 这段时间里(按东八区日子)老师在课堂任务布置过的课,同一天的按布置先后 */
async function lessonPlans(range: StatisticsRange) { async function lessonPlans(range: StatisticsRange) {
return db return db
.select({ .select({
@@ -1,440 +1,40 @@
import { import {
STUDENT_ROLES, STUDENT_ROLES,
classLessonRequestSchema, classLessonRequestSchema,
type ClassActivity,
type ClassToday, type ClassToday,
type ClassBoard, type TaskBoard,
type ClassBoardStudent, type TaskBoardStudent,
type ClassLesson, type ClassLesson,
type ClassActivityProblem,
type LastVisit,
type LessonLanguage,
type LessonMode,
lessonNeeds, lessonNeeds,
type StatisticInfo, type StatisticInfo,
} from '@oj2/contract' } 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 { Hono } from 'hono'
import { requireAuth, requireTeacher, type AppEnv } from '../auth/middleware' import { requireTeacher, type AppEnv } from '../auth/middleware'
import { seenToday } from '../auth/presence' import { seenToday } from '../auth/presence'
import { getLoginWindow } from '../auth/session'
import { presenceOf } from '../collab/state' import { presenceOf } from '../collab/state'
import { db, schema } from '../db' import { db, schema } from '../db'
import { challengeSuccessesOf } from '../services/challenge' import { challengeSuccessesOf } from '../services/challenge'
import { failure, parseBody, success } from '../http' import { failure, parseBody, success } from '../http'
import { isAccepted } from '../judge/status' import { isAccepted } from '../judge/status'
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity' import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
import { accepted } from '../services/learning-stats' import {
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson' announceLessonChange,
CLASS_ACTIVITY_MIN_USERS,
classDayGroups,
classLessonProblems,
lessonDoneCounts,
resolveLessonProblems,
todayLessonSnapshot,
} from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time' import { calendarDay, dayStart, localTime } from '../time'
export const classroomRoutes = new Hono<AppEnv>()
/** /**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的: * 课堂任务(老师):布置这节课的题、看全班做到哪、老师首页的「今天的课」。
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题), * 「这节课是哪几道题」的取法在 services/lesson.ts,学生首页的「班里在做」也用它(routes/home.ts)
* 5 落在中间的空档里,最小的那几个班(十来个人)也够得着。
*/ */
const CLASS_ACTIVITY_MIN_USERS = 5 export const taskRoutes = new Hono<AppEnv>()
/** 往回找几天:上完课第二天在家补作业、请假回来的学生,都还能看到上一次课做了什么 */
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<ClassDayGroup[]> {
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<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const perUser = db
.select({
day: day.as('day'),
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
userId: sql<number>`${activity.userId}`.as('user_id'),
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
solved: (mode === 'both'
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
: sql<boolean>`bool_or(${activity.solved})`
).as('solved'),
})
.from(activity)
.groupBy(day, activity.problemId, activity.userId)
.as('per_user')
const userCount = sql<number>`count(*)::int`
const query = db
.select({
day: perUser.day,
problemId: perUser.problemId,
firstAt: sql<string>`min(${perUser.firstAt})`,
userCount,
acceptedCount: sql<number>`(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<number, { id: number; displayId: string; title: string; isSql: boolean }>()
const rows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
isSql: sql<boolean>`${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<string>`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<string | null>((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<number>()
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<boolean>`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<boolean>`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<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 }
}
/** /**
* 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2, * 一张流程图走对了几组、一共几组:2026-10 起的「走例子」报告(ai_criteria_details version 2,
@@ -450,7 +50,7 @@ function flowchartCaseCounts() {
} }
/** /**
* 看板上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过 * 任务页上的「最近几节课交了几节」:分清「今天没来」和「一直不动手」。2025 秋至少上过
* 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」 * 5 节课的学生里,12.5% 七成以上的课一道都没交,37% 的学生贡献了一半以上的「没交」
* —— 当堂那份名单把这两种人排在同一行里,老师分不出来。 * —— 当堂那份名单把这两种人排在同一行里,老师分不出来。
*/ */
@@ -459,11 +59,11 @@ const RECENT_LESSONS = 5
const RECENT_LESSONS_WINDOW_DAYS = 45 const RECENT_LESSONS_WINDOW_DAYS = 45
/** /**
* 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、 * 课堂任务:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一), * 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
* 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。 * 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。
*/ */
classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { taskRoutes.get('/task/board', requireTeacher, async (c) => {
const day = calendarDay() const day = calendarDay()
const className = c.req.query('className')?.trim() || (await suggestActiveClass()) const className = c.req.query('className')?.trim() || (await suggestActiveClass())
if (!className) { if (!className) {
@@ -480,10 +80,10 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
problems: [], problems: [],
students: [], students: [],
recentLessons: 0, recentLessons: 0,
} satisfies ClassBoard) } satisfies TaskBoard)
} }
// 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来。 // 推断只看今天(回看 1 天):任务页是给这节课用的,昨天的题不该冒出来。
// lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种) // lastLanguage 给语言下拉当默认值:这个班上一次布置选的什么(一个班一般一学期只学一种)
const [lesson, [last]] = await Promise.all([ const [lesson, [last]] = await Promise.all([
classLessonProblems(className, 1), 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))) .where(and(inArray(schema.submission.userId, userIds), gte(schema.submission.createTime, start)))
.groupBy(schema.submission.userId) .groupBy(schema.submission.userId)
: [], : [],
// 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在看板上是一直没动手。 // 今天只画了流程图的学生,「最后一次提交」也得算上画图,不然他在任务页上是一直没动手。
// 和上面代码那条同一个范围:今天、所有题、不论评没评完 // 和上面代码那条同一个范围:今天、所有题、不论评没评完
userIds.length userIds.length
? db ? db
@@ -586,7 +186,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
: [], : [],
/** /**
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。 * 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」。 * 有的题这节课整个班都在画流程图,只数代码提交的话,任务页上满屏「没开始」。
* 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算 * 和代码那条(cells)同一个范围:交过就算尝试,评分中、评失败的也算
*/ */
ids.length && userIds.length ids.length && userIds.length
@@ -769,18 +369,18 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => {
editing: presenceOf(student.userId)?.problemId ?? null, editing: presenceOf(student.userId)?.problemId ?? null,
online: seen.get(student.userId)?.online ?? false, online: seen.get(student.userId)?.online ?? false,
seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null, seenAt: seen.has(student.userId) ? new Date(seen.get(student.userId)!.at).toISOString() : null,
}) satisfies ClassBoardStudent, }) satisfies TaskBoardStudent,
), ),
recentLessons: recentDays.length, recentLessons: recentDays.length,
} satisfies ClassBoard) } satisfies TaskBoard)
}) })
/** /**
* 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」 * 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」
* 同一个门槛),或者老师今天在看板上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数 * 同一个门槛),或者老师今天在任务页上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
* 和看板同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天 * 和任务页同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
*/ */
classroomRoutes.get('/classroom/today', requireTeacher, async (c) => { taskRoutes.get('/task/today', requireTeacher, async (c) => {
const start = dayStart() const start = dayStart()
const today = calendarDay() const today = calendarDay()
const activity = activityRows({ since: start }) const activity = activityRows({ since: start })
@@ -810,13 +410,13 @@ classroomRoutes.get('/classroom/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.language, mode: lesson.mode },
]) ])
allDoneCount = counts.get(lesson.lessonId) ?? 0 allDoneCount = counts.get(lesson.lessonId) ?? 0
} else if (ids.length) { } else if (ids.length) {
// 推断出来的:做没做对不限今天(和看板的格子一样:以前做对的也算做完),代码或流程图都算 // 推断出来的:做没做对不限今天(和任务页的格子一样:以前做对的也算做完),代码或流程图都算
const done = activityRows({ const done = activityRows({
since: new Date(0).toISOString(), since: new Date(0).toISOString(),
users: classMemberIds(className), 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()) const className = c.req.query('className')?.trim() || (await suggestActiveClass())
if (!className) if (!className)
return success(c, { return success(c, {
@@ -869,7 +469,7 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
language: null, language: null,
problems: [], problems: [],
} satisfies ClassLesson) } satisfies ClassLesson)
// 和看板一样只回看今天:「这节课」不该冒出昨天的题 // 和任务页一样只回看今天:「这节课」不该冒出昨天的题
const lesson = await classLessonProblems(className, 1) const lesson = await classLessonProblems(className, 1)
// C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language) // C / Python 的作业夹了 SQL 题就不筛语言(见契约 classLessonSchema.language)
const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL')) const mixed = lesson.problems.some((problem) => problem.isSql !== (lesson.language === 'SQL'))
@@ -889,12 +489,12 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => {
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节, * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节,
* 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。 * 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。
*/ */
classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => { taskRoutes.put('/task/lesson', requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对') const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对')
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const user = c.get('user')! const user = c.get('user')!
const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data const { className, problemDisplayIds, lessonId, newLesson, mode } = parsed.data
// 只画流程图不用语言:存成 null,看板和学生那边就不会写「C语言」 // 只画流程图不用语言:存成 null,任务页和学生那边就不会写「C语言」
const language = mode === 'flowchart' ? null : parsed.data.language const language = mode === 'flowchart' ? null : parsed.data.language
const day = calendarDay() const day = calendarDay()
+1 -1
View File
@@ -333,7 +333,7 @@ export async function challengeList(user: { id: number; className: string | null
}) })
} }
/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */ /** 课堂任务用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) { export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
if (userIds.length === 0 || problemIds.length === 0) return [] if (userIds.length === 0 || problemIds.length === 0) return []
return db return db
+4 -4
View File
@@ -5,8 +5,8 @@ import { db, schema } from '../db'
import { JudgeStatus } from '../judge/status' import { JudgeStatus } from '../judge/status'
/** /**
* 课堂上「谁做了哪道题」的取数口径:课堂条、看板、自学情况、后台首页「正在上课的班」 * 课堂上「谁做了哪道题」的取数口径:课堂条、任务页、自学情况、后台首页「正在上课的班」
* 都从这里数。原来写在 routes/classroom.ts 里,后台 conf.ts 要猜正在上课的班, * 都从这里数。原来写在课堂任务的路由里,后台 conf.ts 要猜正在上课的班,
* 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。 * 就只能从另一个路由文件里 import —— 挪到 services 来,两边都从这里拿。
*/ */
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */ /** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
@@ -30,7 +30,7 @@ export function classMemberIds(className: string) {
/** /**
* 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计 * 做题记录:代码提交(不含比赛)和流程图提交并成一张,一次提交一行。课堂上的统计
* (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数, * (这节课是哪几道题、班上几人做完、最近几节课来了几次、哪个班在上课)都从这里数,
* 口径和课堂条 / 看板的「做完」一致: * 口径和课堂条 / 任务页的「做完」一致:
* *
* - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败; * - 交过就算「动过手」:代码提交不论结果,流程图不论评没评完、评没评失败;
* - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。 * - `solved`:代码 AC / AST_CHECK_FAILED,流程图评到 A / S。
@@ -99,7 +99,7 @@ export function activityRows(options: {
return code.unionAll(flowchart).as('activity') return code.unionAll(flowchart).as('activity')
} }
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */ /** 任务页没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000 const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/** /**
+4 -4
View File
@@ -3,10 +3,10 @@
* *
* 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」, * 主线是「每节课」:中职的课八成提交在课上,学生回想自己学了什么,记得的是「周三那节课」,
* 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。 * 不是某一周、某个月。所以按天排开,每天分成「班上这节课的题」和「自己练的题」两组。
* 一节课的题怎么定,和课堂看板 / 首页「班里在做」同一个口径(routes/classroom.ts): * 一节课的题怎么定,和课堂任务 / 首页「班里在做」同一个口径(services/lesson.ts 的 classLessonProblems):
* 老师在看板布置过就用布置的,否则同班同一天 ≥ 5 人做过的题。 * 老师在任务页布置过就用布置的,否则同班同一天 ≥ 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' 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] 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 const LESSON_MIN_USERS = 5
/** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */ /** 不算进「错在哪」的状态:判题机自己崩的、还没判完的 */
+176 -6
View File
@@ -3,12 +3,12 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { db, schema } from '../db' import { db, schema } from '../db'
import { publishLessonUpdate } from '../events' import { publishLessonUpdate } from '../events'
import { calendarDay, dayNumber, dayStart } from '../time' import { calendarDay, dayNumber, dayStart, localTime } from '../time'
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity' import { activityRows, classMemberIds, FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
/** /**
* 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、 * 布置的课(class_lesson)几处共用的口径:课堂任务布置、后台「课堂任务」增改、
* 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。 * 数据统计按布置切课。放在一起,免得任务页能布置的题后台布置不上,或者两边「做完」数得不一样。
*/ */
/** /**
@@ -55,7 +55,7 @@ export function lessonDayEnd(day: string) {
/** /**
* 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。 * 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算)。
* 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言 * 「做对」看这节课做什么(契约 lessonModeSchema):写代码只认代码交对,代码只认布置的语言
* (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和看板的格子同一个口径。 * (SQL 题放行);画流程图只认评到 A / S;先画再写两样都要 —— 和任务页的格子同一个口径。
* 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上
*/ */
export async function lessonDoneCounts( export async function lessonDoneCounts(
@@ -119,7 +119,7 @@ interface LessonSnapshot {
} }
/** /**
* 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法), * 学生此刻看到的那一节:这个班今天最后布置的(和下面 lessonPlan 同一个取法),
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比 * 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
*/ */
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> { export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
@@ -175,3 +175,173 @@ export async function announceLessonChange(className: string, before: LessonSnap
console.error('Failed to announce lesson change', error) 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<ClassDayGroup[]> {
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<string>`to_char(${localTime(activity.createTime)}, 'YYYY-MM-DD')`
const perUser = db
.select({
day: day.as('day'),
problemId: sql<number>`${activity.problemId}`.as('problem_id'),
userId: sql<number>`${activity.userId}`.as('user_id'),
firstAt: sql<string>`min(${activity.createTime})`.as('first_at'),
solved: (mode === 'both'
? sql<boolean>`bool_or(${activity.kind} = 'code' and ${activity.solved}) and bool_or(${activity.kind} = 'flowchart' and ${activity.solved})`
: sql<boolean>`bool_or(${activity.solved})`
).as('solved'),
})
.from(activity)
.groupBy(day, activity.problemId, activity.userId)
.as('per_user')
const userCount = sql<number>`count(*)::int`
const query = db
.select({
day: perUser.day,
problemId: perUser.problemId,
firstAt: sql<string>`min(${perUser.firstAt})`,
userCount,
acceptedCount: sql<number>`(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<number, { id: number; displayId: string; title: string; isSql: boolean }>()
const rows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
isSql: sql<boolean>`${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<string>`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<string | null>((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 }] : []
}),
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function dayNumber(day: string): number {
return Date.UTC(year!, month! - 1, date!) / DAY_MS 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 { export function localDateTime(day: string, time: string): string {
const [hour, minute] = time.split(':').map(Number) const [hour, minute] = time.split(':').map(Number)
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString() return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
+1 -1
View File
@@ -64,7 +64,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
Shared logic lives in `shared/`: 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 源码) - `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 - `layout/` — `default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha) - `api.ts` — shared API calls (auth, profile, tags, captcha)
+1 -1
View File
@@ -780,7 +780,7 @@ export function deleteAchievement(id: number) {
return api.delete(`admin/achievements/${id}`) return api.delete(`admin/achievements/${id}`)
} }
// ---------- 课堂作业(class_lesson) ---------- // ---------- 课堂任务(class_lesson) ----------
export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) { export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) {
return api.get<AdminLessonList>('admin/lessons', { return api.get<AdminLessonList>('admin/lessons', {
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract' import { LESSON_MODE_LABEL, lessonNeeds, type AdminLesson, type LessonLanguage, type LessonMode } from '@oj2/contract'
import ProblemPicker from 'oj/classroom/components/ProblemPicker.vue' import ProblemPicker from 'oj/task/components/ProblemPicker.vue'
import { useConfigStore } from 'shared/store/config' import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { groupClassOptions } from 'utils/classes' import { groupClassOptions } from 'utils/classes'
@@ -10,8 +10,8 @@ import { parseTime } from 'utils/functions'
import { createAdminLesson, updateAdminLesson } from '../../api' import { createAdminLesson, updateAdminLesson } from '../../api'
/** /**
* 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 看板上点错了班、隔天才想起来布置, * 后台改一节 / 补一节。改的时候班级、日子、时刻都能动 —— 任务页上点错了班、隔天才想起来布置,
* 都在这里纠正;补的是看板外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑 * 都在这里纠正;补的是任务页外上的课,补了数据统计才认得到。题号可以敲,也可以点「选题」挑
*/ */
const props = defineProps<{ const props = defineProps<{
/** null = 补一节 */ /** null = 补一节 */
@@ -54,7 +54,7 @@ function inputIds() {
return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean) return idsInput.value.split(/[\s,,、;;]+/).filter(Boolean)
} }
// ---- 选题弹框,和看板上那个是同一个 ---- // ---- 选题弹框,和任务页上那个是同一个 ----
const picking = ref(false) const picking = ref(false)
const pickerInitial = ref<{ id: string; title?: string }[]>([]) const pickerInitial = ref<{ id: string; title?: string }[]>([])
function openPicker() { function openPicker() {
+4 -4
View File
@@ -17,8 +17,8 @@ import { deleteAdminLesson, getAdminLessons } from '../api'
import LessonEditModal from './components/LessonEditModal.vue' import LessonEditModal from './components/LessonEditModal.vue'
/** /**
* 后台「课堂作业」:布置过的每一节课一行,新的在前。课堂看板上当场布置的都在这里, * 后台「课堂任务」:布置过的每一节课一行,新的在前。课堂任务上当场布置的都在这里,
* 布置错了能改、删;看板外上的课能补一节 —— 数据统计按布置切课,补了才认得到 * 布置错了能改、删;任务页外上的课能补一节 —— 数据统计按布置切课,补了才认得到
*/ */
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
@@ -26,7 +26,7 @@ const theme = useThemeVars()
const configStore = useConfigStore() const configStore = useConfigStore()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
// 从看板「布置过的课」进来带着 ?className= // 从任务页「布置过的课」进来带着 ?className=
const { query } = usePagination<{ className: string; teacher: string }>({ const { query } = usePagination<{ className: string; teacher: string }>({
className: useRouteQuery('className', '').value, className: useRouteQuery('className', '').value,
teacher: useRouteQuery('teacher', '').value, teacher: useRouteQuery('teacher', '').value,
@@ -163,7 +163,7 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
</script> </script>
<template> <template>
<PageHeader title="课堂作业"> <PageHeader title="课堂任务">
<template #extra> <template #extra>
<n-text v-if="data" depth="3">{{ data.total }} 节</n-text> <n-text v-if="data" depth="3">{{ data.total }} 节</n-text>
</template> </template>
+2 -2
View File
@@ -94,7 +94,7 @@ const shortcuts = [
{ label: '新建题目', to: '/admin/problem/create' }, { label: '新建题目', to: '/admin/problem/create' },
{ label: '新建比赛', to: '/admin/contest/create' }, { label: '新建比赛', to: '/admin/contest/create' },
{ label: '批量建号', to: '/admin/user/generate' }, { label: '批量建号', to: '/admin/user/generate' },
{ label: '课堂看板', to: '/classroom' }, { label: '课堂任务', to: '/task' },
] ]
// ---------------------------------------------------------------- 抽一个人 // ---------------------------------------------------------------- 抽一个人
@@ -170,7 +170,7 @@ onUnmounted(stopRolling)
<div class="tile"> <div class="tile">
<span class="label">正在上课</span> <span class="label">正在上课</span>
<b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b> <b>{{ info.activeClass ? classLabel(info.activeClass) : '没有' }}</b>
<router-link v-if="info.activeClass" class="link" :to="{ name: 'classroom board', query: { className: info.activeClass } }"> 去课堂看板 → </router-link> <router-link v-if="info.activeClass" class="link" :to="{ name: 'task', query: { className: info.activeClass } }"> 去课堂任务 → </router-link>
<span v-else class="note">最近两小时没有哪个班在交题</span> <span v-else class="note">最近两小时没有哪个班在交题</span>
</div> </div>
</div> </div>
+8 -8
View File
@@ -26,7 +26,7 @@ import {
type Tutorial, type Tutorial,
type ClassToday, type ClassToday,
type LastVisit, type LastVisit,
type ClassBoard, type TaskBoard,
type ClassLesson, type ClassLesson,
type KnowledgeMap, type KnowledgeMap,
type ClassBattleItem, type ClassBattleItem,
@@ -251,14 +251,14 @@ export function getWeeklyRank(scope: 'global' | 'class') {
return api.get<WeeklyRank>('rankings/weekly', { params: { scope } }) return api.get<WeeklyRank>('rankings/weekly', { params: { scope } })
} }
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */ /** 课堂任务(老师)。不给班级时后端猜最近两小时在交题的那个班 */
export function getClassBoard(className?: string) { export function getTaskBoard(className?: string) {
return api.get<ClassBoard>('classroom/board', { params: { className } }) return api.get<TaskBoard>('task/board', { params: { className } })
} }
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */ /** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和任务页一样 */
export function getClassLesson(className?: string) { export function getClassLesson(className?: string) {
return api.get<ClassLesson>('classroom/lesson', { params: { className } }) return api.get<ClassLesson>('task/lesson', { params: { className } })
} }
/** /**
@@ -266,7 +266,7 @@ export function getClassLesson(className?: string) {
* (一个班一天可以上几节内容不同的课),否则改那一节 * (一个班一天可以上几节内容不同的课),否则改那一节
*/ */
export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) { export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, mode: LessonMode, lessonId: number | null) {
return api.put<null>('classroom/lesson', { return api.put<null>('task/lesson', {
className, className,
problemDisplayIds, problemDisplayIds,
language, language,
@@ -282,7 +282,7 @@ export function getKnowledgeMap() {
/** 老师首页「今天的课」:今天在上课的每个班 */ /** 老师首页「今天的课」:今天在上课的每个班 */
export function getClassToday() { export function getClassToday() {
return api.get<ClassToday>('classroom/today') return api.get<ClassToday>('task/today')
} }
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */ /** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
+2 -2
View File
@@ -18,7 +18,7 @@ import { useVisibleInterval } from 'shared/composables/visibleInterval'
* *
* 名字和排行榜一样用用户名(ks241XXX),不用真名。 * 名字和排行榜一样用用户名(ks241XXX),不用真名。
* 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面, * 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面,
* 没进来的人在最下面(看板里标了请假的写「请假」)。 * 没进来的人在最下面(课堂任务里标了请假的写「请假」)。
* 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。 * 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。
* 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView) * 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView)
*/ */
@@ -357,7 +357,7 @@ const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.valu
<span v-for="p in problems" :key="p.id"></span> <span v-for="p in problems" :key="p.id"></span>
<span></span> <span></span>
<span class="flag-wrap"> <span class="flag-wrap">
<span class="flag grey">{{ user.leave ? '请假(看板里标的)' : '没进来' }}</span> <span class="flag grey">{{ user.leave ? '请假(课堂任务里标的)' : '没进来' }}</span>
</span> </span>
</div> </div>
</template> </template>
+5 -5
View File
@@ -10,7 +10,7 @@ import { parseTime, zonedParts, zonedYear } from 'utils/functions'
/** /**
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交), * 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、 * 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。 * 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂任务」进那个班。
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前) * 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
*/ */
const theme = useThemeVars() const theme = useThemeVars()
@@ -61,13 +61,13 @@ function metaOf(row: ClassRow) {
return parts.join(' · ') return parts.join(' · ')
} }
function openBoard(row: ClassRow) { function openTask(row: ClassRow) {
router.push({ name: 'classroom board', query: { className: row.className } }) router.push({ name: 'task', query: { className: row.className } })
} }
const shortcuts = computed(() => const shortcuts = computed(() =>
[ [
{ label: '课堂看板', to: '/classroom', show: true }, { label: '课堂任务', to: '/task', show: true },
{ label: '数据统计', to: '/statistics', show: true }, { label: '数据统计', to: '/statistics', show: true },
{ label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission }, { label: '新建题目', to: '/admin/problem/create', show: userStore.hasProblemPermission },
{ label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true }, { label: '后台', to: userStore.isSuperAdmin ? '/admin' : '/admin/problem/list', show: true },
@@ -92,7 +92,7 @@ const shortcuts = computed(() =>
<span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span> <span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span>
<span class="muted">{{ metaOf(row) }}</span> <span class="muted">{{ metaOf(row) }}</span>
<span class="spacer"></span> <span class="spacer"></span>
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openBoard(row)"> 课堂看板 </n-button> <n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openTask(row)"> 课堂任务 </n-button>
</div> </div>
<div v-if="row.problems.length" class="minis"> <div v-if="row.problems.length" class="minis">
<router-link <router-link
+1 -1
View File
@@ -187,7 +187,7 @@ const solvedText = computed(() => (visit.value?.solved ?? []).map((item) => `${i
const classActivityTitle = computed(() => { const classActivityTitle = computed(() => {
const day = classActivity.value?.day const day = classActivity.value?.day
if (!day) return '' if (!day) return ''
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 // 老师在课堂任务布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === 'teacher') { if (classActivity.value?.source === 'teacher') {
const language = classActivity.value.language const language = classActivity.value.language
const mode = classActivity.value.mode const mode = classActivity.value.mode
File renamed without changes.
File renamed without changes.
@@ -8,10 +8,10 @@ import UserName from 'shared/components/UserName.vue'
* 老师协作时右栏顶上的一条(设计稿「举手与协作」HcPageA):正在帮谁、举手多久了、 * 老师协作时右栏顶上的一条(设计稿「举手与协作」HcPageA):正在帮谁、举手多久了、
* 看他交过什么、帮完了、下一个。语言不写(用户说的):编辑器工具栏的语言下拉就摆在下面。 * 看他交过什么、帮完了、下一个。语言不写(用户说的):编辑器工具栏的语言下拉就摆在下面。
* *
* 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开看板, * 用户选的方案 A 是「跳到题目页帮」,为了演示错误时题目和代码并排;代价是要离开任务页,
* 所以「帮完了」自动回到这个班的看板,还有人举手就给「下一个」直接去下一个人的题。 * 所以「帮完了」自动回到这个班的任务页,还有人举手就给「下一个」直接去下一个人的题。
* *
* 老师在看板上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。 * 老师在任务页上主动打开的(学生没举手,mode = peek)写「正在看 X 的代码」,按钮叫「看完了」。
*/ */
const collabStore = useCollabStore() const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore()) const { problem } = storeToRefs(useProblemStore())
@@ -46,7 +46,7 @@ function openHisSubmissions() {
} }
/** /**
* 帮完了:结束这次(求助记录一并清掉),回到这个班的看板。 * 帮完了:结束这次(求助记录一并清掉),回到这个班的任务页。
* 等 room_closed 回来、room 清空了再跳 —— 房间还开着时离开题目页,题目页的拦截 * 等 room_closed 回来、room 清空了再跳 —— 房间还开着时离开题目页,题目页的拦截
* (detail.vue 的 confirmLeavingHelp)会把跳转改成开新标签 * (detail.vue 的 confirmLeavingHelp)会把跳转改成开新标签
*/ */
@@ -54,7 +54,7 @@ async function finish() {
const className = room.value?.peerClassName const className = room.value?.peerClassName
collabStore.leave('done') collabStore.leave('done')
await until(() => collabStore.room).toBe(null, { timeout: 3000 }) await until(() => collabStore.room).toBe(null, { timeout: 3000 })
router.push({ name: 'classroom board', query: className ? { className } : {} }) router.push({ name: 'task', query: className ? { className } : {} })
} }
/** /**
@@ -86,7 +86,7 @@ const showCollabBar = useTeacherCollab()
*/ */
const collabHere = useCollabHere() const collabHere = useCollabHere()
/** /**
* 老师在看板上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 —— * 老师在任务页上主动打开了他的代码(他没举手)。用户定的:学生不能拒绝,但必须看得到 ——
* 按钮就写「xuyue 在看你的代码」,和「在帮你」一样亮着、点不了 * 按钮就写「xuyue 在看你的代码」,和「在帮你」一样亮着、点不了
*/ */
const peeked = computed(() => collabHere.value && collabStore.room?.mode === 'peek' && collabStore.helpStatus === 'idle') const peeked = computed(() => collabHere.value && collabStore.room?.mode === 'peek' && collabStore.helpStatus === 'idle')
@@ -168,7 +168,7 @@ watch(
) )
/** /**
* 老师在课堂看板布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班, * 老师在课堂任务布置这节课时选了语言:打开这节课的题,编辑器默认就用它。学 C 的班,
* 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现 * 编辑器停在默认的 Python(或者上一个用这台电脑的人留下的偏好)的话,学生交上去才发现
* 不算数。课堂条只在题库入口出现,这里同一个范围。 * 不算数。课堂条只在题库入口出现,这里同一个范围。
* *
+1 -1
View File
@@ -174,7 +174,7 @@ function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNorm
window.open(router.resolve(to).href, '_blank') window.open(router.resolve(to).href, '_blank')
return false return false
} }
// 老师在看板上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在 // 老师在任务页上主动打开了他的代码(没举手):helpStatus 是 idle,但老师在
const peeked = collabHere.value && collabStore.room?.mode === 'peek' const peeked = collabHere.value && collabStore.room?.mode === 'peek'
if (collabStore.isTeacher || (collabStore.helpStatus === 'idle' && !peeked)) return true if (collabStore.isTeacher || (collabStore.helpStatus === 'idle' && !peeked)) return true
if (approvedTarget === to.fullPath) { if (approvedTarget === to.fullPath) {
@@ -55,7 +55,7 @@ function shortLabel(className: string) {
<div class="battle" :class="{ bare }"> <div class="battle" :class="{ bare }">
<div class="title"> <div class="title">
<b v-if="!bare">班级对抗</b><span class="muted">人均做对</span> <b v-if="!bare">班级对抗</b><span class="muted">人均做对</span>
<router-link v-if="pk" :to="{ path: '/class', query: mine ? { classes: mine } : {} }" class="pk">班级 PK ›</router-link> <router-link v-if="pk" :to="{ path: '/pk', query: mine ? { classes: mine } : {} }" class="pk">班级 PK ›</router-link>
</div> </div>
<div v-for="item in shown" :key="item.className" class="row" :class="{ mine: item.className === mine }"> <div v-for="item in shown" :key="item.className" class="row" :class="{ mine: item.className === mine }">
<span class="rank">{{ item.rank }}</span> <span class="rank">{{ item.rank }}</span>
@@ -213,7 +213,7 @@ function go(path: string, query: Record<string, string>) {
<Markdown v-if="report" :content="report" /> <Markdown v-if="report" :content="report" />
</section> </section>
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', { classes: className! })">和别的班比 → 班级 PK ›</a> <a v-if="pk" href="#" class="pk" @click.prevent="go('/pk', { classes: className! })">和别的班比 → 班级 PK ›</a>
</div> </div>
<div v-else-if="!loading" class="muted">这个班的数据没取到</div> <div v-else-if="!loading" class="muted">这个班的数据没取到</div>
</n-spin> </n-spin>
+1 -1
View File
@@ -441,7 +441,7 @@ async function restore(userId: number) {
secondary secondary
@click=" @click="
router.push({ router.push({
path: '/class', path: '/pk',
query: classContext ? { classes: classContext } : {}, query: classContext ? { classes: classContext } : {},
}) })
" "
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Submission } from 'utils/types'
import type { Tone } from 'shared/composables/tone' import type { Tone } from 'shared/composables/tone'
/** /**
* 一次提交一格的那串方块(统计页的 AttemptSquares、课堂看板做对格子的轨迹)共用的两样: * 一次提交一格的那串方块(统计页的 AttemptSquares、课堂任务做对格子的轨迹)共用的两样:
* 方块的颜色,和停上去看代码时拉的提交。 * 方块的颜色,和停上去看代码时拉的提交。
*/ */
@@ -143,7 +143,7 @@ function percent(value: number, total: number) {
<div v-if="liveLessons.length" class="live" :style="{ background: tone('success').background, color: tone('success').color }"> <div v-if="liveLessons.length" class="live" :style="{ background: tone('success').background, color: tone('success').color }">
<span>{{ liveLessons.map((item) => classLabel(item.className)).join('、') }}正在上课</span> <span>{{ liveLessons.map((item) => classLabel(item.className)).join('、') }}正在上课</span>
<span class="spacer"></span> <span class="spacer"></span>
<a href="#" class="live-link" @click.prevent="router.push('/classroom')"> <Icon icon="ph:monitor" :width="14" />去课堂看板 </a> <a href="#" class="live-link" @click.prevent="router.push('/task')"> <Icon icon="ph:monitor" :width="14" />去课堂任务 </a>
</div> </div>
<div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div> <div v-if="!lessonCount && !data.scattered.length" class="empty">这段时间没有人交</div>
@@ -17,7 +17,7 @@ import StatItem from './StatItem.vue'
/** /**
* 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框 * 统计的主体(设计稿「统计重设计 · 定稿 B」+「统计合二为一」B)。两处用它:提交列表里的弹框
* (老师平时从这里看),和 /statistics 页面(课堂看板「回头看」、题目页之类从别处进来的, * (老师平时从这里看),和 /statistics 页面(课堂任务「回头看」、题目页之类从别处进来的,
* 条件放在地址栏里能刷新、能转发)。 * 条件放在地址栏里能刷新、能转发)。
* *
* 代码页签按条件分三种看法: * 代码页签按条件分三种看法:
@@ -27,8 +27,8 @@ import StatItem from './StatItem.vue'
* - 选了班或填了学生:左边题目、右边学生(CodeStats) * - 选了班或填了学生:左边题目、右边学生(CodeStats)
* 从前两种点进去时左上角有「← 全部」回来。 * 从前两种点进去时左上角有「← 全部」回来。
* *
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有 * 分工:**课堂任务管这节课,这里管回头看**(用户定的)。所以不自动刷新、没有
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「一节课」。 * 投影用的大字名单和请假隐藏 —— 那些在任务页上。课堂统计一般看一两节课,时间段默认「一节课」。
* *
* 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。 * 查询条件都是 v-model:页面接地址栏,弹框接提交列表那边的一份本地状态。
*/ */
@@ -66,7 +66,7 @@ const custom = computed<[number, number] | null>(() => {
}) })
/** /**
* 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样 * 「做完」只数所选时段之内做对的,和任务页、后台「课堂任务」、一行一节课的「做完」不一样
* (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的 * (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的
*/ */
const doneLabel = computed(() => { const doneLabel = computed(() => {
@@ -229,7 +229,7 @@ const judged = computed(() => (stats.value ? stats.value.submissionCount - stats
/** /**
* 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭, * 1366 以下(机房 1280 的屏)「在提交列表里看」只留图标。弹框比屏幕窄一圈、还多一个关闭,
* 1366 的屏上整排放不下,所以弹框里宽一档才出字。 * 1366 的屏上整排放不下,所以弹框里宽一档才出字。
* 原来旁边还有一个「这节课去课堂看板」:顶栏已经有看板,统计里再放一个成了干扰项(用户说的), * 原来旁边还有一个「这节课去课堂任务」:顶栏已经有任务页,统计里再放一个成了干扰项(用户说的),
* 现在只在有班正在上课时,在课的列表上方引过去 * 现在只在有班正在上课时,在课的列表上方引过去
*/ */
const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)') const narrow = useMediaQuery(props.modal ? '(max-width: 1439px)' : '(max-width: 1365px)')
+1 -1
View File
@@ -4,7 +4,7 @@ import StatisticsView from './components/StatisticsView.vue'
import { DEFAULT_PERIOD } from './period' import { DEFAULT_PERIOD } from './period'
/** /**
* 统计的页面形态:课堂看板「回头看」、题目页统计页签这些从别处进来时用。 * 统计的页面形态:课堂任务「回头看」、题目页统计页签这些从别处进来时用。
* 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。 * 提交列表里是弹框(用户定的),两边是同一个 StatisticsView。
* *
* 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。 * 查询条件都在地址栏里:带过来、刷新、转发给别的老师都还是这一份。
+1 -1
View File
@@ -9,7 +9,7 @@ const DAY = 86_400_000
* 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、 * 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、
* 默认「一节课」(用户定的);后面几档是回头看。名字后面不写「1 小时内」:选框旁边 * 默认「一节课」(用户定的);后面几档是回头看。名字后面不写「1 小时内」:选框旁边
* 本来就写着具体几点到几点。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的, * 本来就写着具体几点到几点。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的,
* 那件事现在归课堂看板。 * 那件事现在归课堂任务。
*/ */
export const PERIOD_OPTIONS = [ export const PERIOD_OPTIONS = [
{ label: '一节课', value: '1h' }, { label: '一节课', value: '1h' },
@@ -30,7 +30,7 @@ import StatusPill from './StatusPill.vue'
* 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生 * 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生
* 怎么一次次改对的。 * 怎么一次次改对的。
* *
* 独立的提交详情页(detail.vue)和课堂看板「讲错误」的弹层共用这一个。`embedded`(弹层里): * 独立的提交详情页(detail.vue)和课堂任务「讲错误」的弹层共用这一个。`embedded`(弹层里):
* 不出名字和题目那一行(弹层顶上有一排人名),不占 ← →(弹层拿它换人), * 不出名字和题目那一行(弹层顶上有一排人名),不占 ← →(弹层拿它换人),
* 点「第几次」就地换、不改地址栏。 * 点「第几次」就地换、不改地址栏。
*/ */
@@ -4,7 +4,7 @@ import { classLabel } from 'utils/classes'
import type { ClassLesson } from 'utils/types' import type { ClassLesson } from 'utils/types'
import type { SubmissionQuery } from './listQuery' import type { SubmissionQuery } from './listQuery'
/** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂看板 */ /** 提交列表的「这节课」:一键筛到 班级 + 这节课的题 + 今天。班级和题的取法同课堂任务 */
export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref<boolean>; info: (text: string) => void }) { export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref<boolean>; info: (text: string) => void }) {
const { query } = options const { query } = options
const lesson = ref<ClassLesson | null>(null) const lesson = ref<ClassLesson | null>(null)
@@ -36,13 +36,13 @@ export function useLessonFilter(options: { query: SubmissionQuery; flowMode: Ref
} }
query.className = res.className query.className = res.className
if (!res.problems.length) { if (!res.problems.length) {
options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂看板布置`) options.info(`${classLabel(res.className)}今天还没有布置题,也还没人交。可以先去课堂任务布置`)
return return
} }
lesson.value = res lesson.value = res
query.problem = lessonIds.value query.problem = lessonIds.value
query.today = '1' query.today = '1'
// 老师布置时选了语言:看板上只数这个语言交的,列表也只列它(流程图那边不动) // 老师布置时选了语言:任务页上只数这个语言交的,列表也只列它(流程图那边不动)
if (res.language && !options.flowMode.value) query.language = res.language if (res.language && !options.flowMode.value) query.language = res.language
} finally { } finally {
lessonLoading.value = false lessonLoading.value = false
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import SubmissionView from './components/SubmissionView.vue' import SubmissionView from './components/SubmissionView.vue'
/** 单独打开的一条提交,主体在 SubmissionView(课堂看板「讲错误」的弹层也用它) */ /** 单独打开的一条提交,主体在 SubmissionView(课堂任务「讲错误」的弹层也用它) */
defineProps<{ submissionID: string }>() defineProps<{ submissionID: string }>()
</script> </script>
+3 -3
View File
@@ -32,7 +32,7 @@ import { useStatsLink } from './composables/statsLink'
* *
* 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在 * 老师课上的三种用法 —— 一条条翻全班的代码、看某一个学生、看完成情况 —— 前两种在
* 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。 * 这一页里做完:↑ ↓ 换条,右边跟着换,不再「点开弹框 → 关掉 → 点下一行」。
* 完成情况在课堂看板和「统计」里,这页不重复造一张表。 * 完成情况在课堂任务和「统计」里,这页不重复造一张表。
*/ */
// 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着 // 右栏和今日统计弹框都不在关键路径上:右栏要等选中一条才有内容,弹框默认关着
@@ -579,7 +579,7 @@ function dayBreak(index: number) {
<span v-if="userStore.isAuthed" class="vsep"></span> <span v-if="userStore.isAuthed" class="vsep"></span>
<!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 --> <!-- 「我的」时这节课 / 班级 / 学生都用不上:后端只看自己时本来就不认它们 -->
<!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、看板跳过来)就摆一个能点掉的标签, <!-- 学生界面没有班级下拉:地址里带着班级(比如从统计页、任务页跳过来)就摆一个能点掉的标签,
不然一直被一个看不见的条件筛着 --> 不然一直被一个看不见的条件筛着 -->
<button v-if="!teacher && query.className" class="chip on" title="点一下去掉班级筛选" @click="query.className = ''"> <button v-if="!teacher && query.className" class="chip on" title="点一下去掉班级筛选" @click="query.className = ''">
{{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" /> {{ classLabel(query.className) }}<Icon icon="ph:x-bold" :width="12" />
@@ -605,7 +605,7 @@ function dayBreak(index: number) {
/> />
</template> </template>
<!-- 按整名只看一个人(「只看他」、协作条、看板点进来):用户名有八九个字,塞进 108px 的 <!-- 按整名只看一个人(「只看他」、协作条、任务页点进来):用户名有八九个字,塞进 108px 的
输入框只剩半截,还被清除按钮压着 —— 换成一个带名字的标签,点 × 取消 --> 输入框只剩半截,还被清除按钮压着 —— 换成一个带名字的标签,点 × 取消 -->
<button v-if="!mine && exactPerson" class="chip on person" :title="`只看 ${query.username}(点一下取消)`" @click="clearPerson"> <button v-if="!mine && exactPerson" class="chip on person" :title="`只看 ${query.username}(点一下取消)`" @click="clearPerson">
<Icon icon="ph:user-bold" :width="12" /> <Icon icon="ph:user-bold" :width="12" />
@@ -1,11 +1,11 @@
import type { LessonMode } from '@oj2/contract' import type { LessonMode } from '@oj2/contract'
import type { Tone } from 'shared/composables/tone' import type { Tone } from 'shared/composables/tone'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
import type { ClassBoardCell, ClassBoardStudent } from 'utils/types' import type { TaskBoardCell, TaskBoardStudent } from 'utils/types'
/** /**
* 课堂看板的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数, * 课堂任务的格子:一个学生 × 一道题。显示什么字、什么颜色、悬停说什么,都是纯函数,
* 和 errorKind.ts 一样单放一个文件(看板组件只管排版和交互)。 * 和 errorKind.ts 一样单放一个文件(任务页组件只管排版和交互)。
* *
* `mode` 是这节课做什么(契约 lessonModeSchema),推断出来的课为 null。先画再写的格子是一条进度: * `mode` 是这节课做什么(契约 lessonModeSchema),推断出来的课为 null。先画再写的格子是一条进度:
* 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」 * 在画 → 图画对了(空心绿「图 ✓」)→ 在写 → 两样都对(实心绿 ✓)。先写了代码、还没画图的写「缺图」
@@ -20,7 +20,7 @@ export const STUCK_ATTEMPTS = 3
*/ */
export type CellStage = 'draw' | 'code' | 'nodraw' export type CellStage = 'draw' | 'code' | 'nodraw'
export function cellStage(cell: ClassBoardCell, mode: LessonMode | null): CellStage { export function cellStage(cell: TaskBoardCell, mode: LessonMode | null): CellStage {
if (mode === 'flowchart') return 'draw' if (mode === 'flowchart') return 'draw'
if (mode !== 'both' || !cell.flowchart) return 'code' if (mode !== 'both' || !cell.flowchart) return 'code'
if (cell.flowchart.status === 'tried') return 'draw' if (cell.flowchart.status === 'tried') return 'draw'
@@ -29,29 +29,29 @@ export function cellStage(cell: ClassBoardCell, mode: LessonMode | null): CellSt
} }
/** 今天在卡着的那一步上交了几次:先画再写的课里,画了三张才画对的人写代码时不该算卡住 */ /** 今天在卡着的那一步上交了几次:先画再写的课里,画了三张才画对的人写代码时不该算卡住 */
export function stageAttempts(cell: ClassBoardCell, mode: LessonMode | null) { export function stageAttempts(cell: TaskBoardCell, mode: LessonMode | null) {
if (mode !== 'both') return cell.attempts if (mode !== 'both') return cell.attempts
const stage = cellStage(cell, mode) const stage = cellStage(cell, mode)
return stage === 'draw' ? cell.flowchartAttempts : stage === 'code' ? cell.codeAttempts : 0 return stage === 'draw' ? cell.flowchartAttempts : stage === 'code' ? cell.codeAttempts : 0
} }
/** 卡住了:没做完,而且在卡着的那一步上今天交了 STUCK_ATTEMPTS 次以上 */ /** 卡住了:没做完,而且在卡着的那一步上今天交了 STUCK_ATTEMPTS 次以上 */
export function isStuck(cell: ClassBoardCell, mode: LessonMode | null) { export function isStuck(cell: TaskBoardCell, mode: LessonMode | null) {
return cell.status === 'tried' && stageAttempts(cell, mode) >= STUCK_ATTEMPTS return cell.status === 'tried' && stageAttempts(cell, mode) >= STUCK_ATTEMPTS
} }
/** 先画再写:图画对了、代码还没交过 —— 空心绿的「图 ✓」 */ /** 先画再写:图画对了、代码还没交过 —— 空心绿的「图 ✓」 */
function halfDone(cell: ClassBoardCell, mode: LessonMode | null) { function halfDone(cell: TaskBoardCell, mode: LessonMode | null) {
return mode === 'both' && cell.status === 'tried' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none' return mode === 'both' && cell.status === 'tried' && cell.flowchart?.status === 'accepted' && cell.codeStatus === 'none'
} }
/** 今天之前就通过了的,颜色淡一档。`day` 是看板的「今天」(东八区日期) */ /** 今天之前就通过了的,颜色淡一档。`day` 是任务页的「今天」(东八区日期) */
export function acceptedToday(cell: ClassBoardCell, day: string | undefined) { export function acceptedToday(cell: TaskBoardCell, day: string | undefined) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day return !!cell.acceptedAt && parseTime(cell.acceptedAt, 'YYYY-MM-DD') === day
} }
/** 最后一张图的那句:评分中 / 评失败 / 走对几组 */ /** 最后一张图的那句:评分中 / 评失败 / 走对几组 */
function drawText(cell: ClassBoardCell, prefix: string) { function drawText(cell: TaskBoardCell, prefix: string) {
const latest = cell.flowchart?.latest const latest = cell.flowchart?.latest
if (!latest) return '✗' if (!latest) return '✗'
if (latest.status === 0 || latest.status === 1) return '评分中' if (latest.status === 0 || latest.status === 1) return '评分中'
@@ -61,7 +61,7 @@ function drawText(cell: ClassBoardCell, prefix: string) {
} }
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */ /** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) { export function cellText(cell: TaskBoardCell, mode: LessonMode | null = null) {
if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓' if (cell.status === 'accepted') return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : '✓'
if (cell.status === 'tried') { if (cell.status === 'tried') {
const stage = cellStage(cell, mode) const stage = cellStage(cell, mode)
@@ -75,7 +75,7 @@ export function cellText(cell: ClassBoardCell, mode: LessonMode | null = null) {
} }
export function cellStyle( export function cellStyle(
cell: ClassBoardCell, cell: TaskBoardCell,
day: string | undefined, day: string | undefined,
colors: { colors: {
tone: (kind: Tone) => { color: string; background: string } tone: (kind: Tone) => { color: string; background: string }
@@ -102,7 +102,7 @@ export function cellStyle(
return { background: colors.actionColor } return { background: colors.actionColor }
} }
export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day: string | undefined, mode: LessonMode | null = null) { export function cellTitle(cell: TaskBoardCell, student: TaskBoardStudent, day: string | undefined, mode: LessonMode | null = null) {
if (cell.status === 'accepted') { if (cell.status === 'accepted') {
const when = acceptedToday(cell, day) ? `${parseTime(cell.acceptedAt!, 'HH:mm')} 做对` : '以前就做对了' const when = acceptedToday(cell, day) ? `${parseTime(cell.acceptedAt!, 'HH:mm')} 做对` : '以前就做对了'
if (cell.challenge === 'done') return `${when},挑战也做到了` if (cell.challenge === 'done') return `${when},挑战也做到了`
@@ -126,7 +126,7 @@ export function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent, day:
} }
/** 点开做对的格子,方块串旁边那句 */ /** 点开做对的格子,方块串旁边那句 */
export function trailSummary(cell: ClassBoardCell, day: string | undefined) { export function trailSummary(cell: TaskBoardCell, day: string | undefined) {
const total = cell.trail.length const total = cell.trail.length
if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对` if (cell.acceptedTry === 1) return total === 1 ? '一次就做对' : `交了 ${total} 次,第 1 次就做对`
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对` if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
@@ -19,23 +19,23 @@ import SubmissionView from 'oj/submission/components/SubmissionView.vue'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
import { useBackClose } from 'shared/composables/backClose' import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints' import { useBreakpoints } from 'shared/composables/breakpoints'
import type { ClassBoardProblem } from 'utils/types' import type { TaskBoardProblem } from 'utils/types'
import FlowchartExplain from './FlowchartExplain.vue' import FlowchartExplain from './FlowchartExplain.vue'
import ProblemBrief from './ProblemBrief.vue' import ProblemBrief from './ProblemBrief.vue'
/** /**
* 课堂看板「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上 * 课堂任务「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
* 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块 * 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块
* (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。 * (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。
* *
* 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。 * 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。
* 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样 * 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样
* *
* 手机上(老师拿手机看看板):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮; * 手机上(老师拿手机看任务页):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
* 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排 * 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排
*/ */
const props = defineProps<{ const props = defineProps<{
problem: ClassBoardProblem | null problem: TaskBoardProblem | null
people: ExplainPerson[] people: ExplainPerson[]
}>() }>()
const show = defineModel<boolean>('show', { required: true }) const show = defineModel<boolean>('show', { required: true })
@@ -43,7 +43,7 @@ const index = defineModel<number>('index', { required: true })
const theme = useThemeVars() const theme = useThemeVars()
const { isMobile } = useBreakpoints() const { isMobile } = useBreakpoints()
// 手机上是整屏的:按返回键关掉它回到看板,不是退出看板 // 手机上是整屏的:按返回键关掉它回到任务页,不是退出任务页
useBackClose(show, isMobile) useBackClose(show, isMobile)
/** /**
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面, * 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
@@ -7,7 +7,7 @@ import { caseReport, fitFlowchartGraph, flowchartVerdict, markFlowchartFocus } f
import type { FlowchartSubmission } from 'utils/types' import type { FlowchartSubmission } from 'utils/types'
/** /**
* 课堂看板「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。 * 课堂任务「讲错误」弹层里的一张流程图:要画流程图的课,点卡在画图上的人打开它。
* 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏 * 左边是他最后画的那张(要改的框标红),右边是 AI 写的哪里错、可以怎么改 —— 和提交列表右栏
* (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。 * (FlowchartPane)、题目页结果页签同一套点评(FlowchartNote)。
* *
@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ClassBoardStudent } from 'utils/types' import type { TaskBoardStudent } from 'utils/types'
/** /**
* 看板的「请假」框:勾今天不在的人。规则在看板那边(只有今天还没交过的人能算请假、 * 任务页的「请假」框:勾今天不在的人。规则在任务页那边(只有今天还没交过的人能算请假、
* 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。 * 交了题或举手就自动算到,见 board.vue 的 canLeave),这里只管勾。
*/ */
const props = defineProps<{ const props = defineProps<{
/** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */ /** 能勾的人:今天还没交过的(刚上课时就是全班),按名字排好 */
candidates: ClassBoardStudent[] candidates: TaskBoardStudent[]
/** 打开时已经标着请假的 */ /** 打开时已经标着请假的 */
current: string[] current: string[]
}>() }>()
@@ -23,7 +23,7 @@ watch(show, (open) => {
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」 * 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt) * 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/ */
const unseen = (student: ClassBoardStudent | undefined) => !!student && !student.seenAt const unseen = (student: TaskBoardStudent | undefined) => !!student && !student.seenAt
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */ /** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
const sections = computed(() => const sections = computed(() =>
@@ -36,7 +36,7 @@ const sections = computed(() =>
].filter((section) => section.students.length), ].filter((section) => section.students.length),
) )
function selectAll(list: ClassBoardStudent[]) { function selectAll(list: TaskBoardStudent[]) {
selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])] selected.value = [...new Set([...selected.value, ...list.map((student) => student.username)])]
} }
@@ -3,12 +3,12 @@ import { LESSON_MODE_LABEL, lessonNeeds, type LessonLanguage, type LessonMode }
import { setClassLesson } from 'oj/api' import { setClassLesson } from 'oj/api'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import type { ClassBoard } from 'utils/types' import type { TaskBoard } from 'utils/types'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import ProblemPicker from './ProblemPicker.vue' import ProblemPicker from './ProblemPicker.vue'
/** /**
* 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 —— * 任务页上布置「这节课的题」:题号是上课时当场填的。两种样子 ——
* - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条; * - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条;
* - `start`:刚上课还没有题时,这页最大的那个框。 * - `start`:刚上课还没有题时,这页最大的那个框。
* *
@@ -16,7 +16,7 @@ import ProblemPicker from './ProblemPicker.vue'
* `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用 * `carry`:新开的这一节带上这一节的题、改成写代码 —— 先布置画流程图、再让全班写代码时用
*/ */
const props = defineProps<{ const props = defineProps<{
board: ClassBoard board: TaskBoard
className: string className: string
variant: 'inline' | 'start' variant: 'inline' | 'start'
lessonId: number | null lessonId: number | null
@@ -27,7 +27,7 @@ const message = useMessage()
/** /**
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班 * 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的 * 上一次布置时选的;选了之后任务页和学生那边的「做完」都只认这个语言交对的
*/ */
const lessonLanguage = ref<LessonLanguage>('Python') const lessonLanguage = ref<LessonLanguage>('Python')
const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL'] const LESSON_LANGUAGES: LessonLanguage[] = ['Python', 'C', 'C++', 'SQL']
@@ -109,7 +109,7 @@ function pickerConfirm(ids: string[]) {
</n-button> </n-button>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button> <n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="emit('cancel')">取消</n-button> <n-button @click="emit('cancel')">取消</n-button>
<!-- 弹层挂在 body 下,放在根节点里面:两个根会让看板传进来的 class 落空 --> <!-- 弹层挂在 body 下,放在根节点里面:两个根会让任务页传进来的 class 落空 -->
<ProblemPicker <ProblemPicker
v-model:show="picking" v-model:show="picking"
v-model:language="lessonLanguage" v-model:language="lessonLanguage"
@@ -9,7 +9,7 @@ import type { ProblemRow } from 'utils/types'
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue' import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
/** /**
* 看板布置作业的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。 * 任务页布置任务的「选题」弹框:左边按知识点分,右边勾题,底下按勾的先后排成这节课的顺序。
* 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的, * 只列这节课做得了的题(左栏题数也这样数):要写代码的只列能用这次作业的语言交的,
* 要画流程图的只列打开了画流程图的。不用再一个个记题号。 * 要画流程图的只列打开了画流程图的。不用再一个个记题号。
* 点「布置」直接存,和在输入框里敲题号回车是一回事 * 点「布置」直接存,和在输入框里敲题号回车是一回事
@@ -23,7 +23,7 @@ const props = defineProps<{
}>() }>()
const show = defineModel<boolean>('show', { required: true }) const show = defineModel<boolean>('show', { required: true })
const language = defineModel<LessonLanguage>('language', { required: true }) const language = defineModel<LessonLanguage>('language', { required: true })
/** 这节课做什么(写代码 / 画流程图 / 先画再写),和看板上那个下拉是同一个 */ /** 这节课做什么(写代码 / 画流程图 / 先画再写),和任务页上那个下拉是同一个 */
const lessonMode = defineModel<LessonMode>('lessonMode', { required: true }) const lessonMode = defineModel<LessonMode>('lessonMode', { required: true })
const needs = computed(() => lessonNeeds(lessonMode.value)) const needs = computed(() => lessonNeeds(lessonMode.value))
/** 只画流程图不看语言 */ /** 只画流程图不看语言 */
@@ -6,7 +6,7 @@ import UserName from 'shared/components/UserName.vue'
import { attemptTone, cachedSubmission } from 'oj/statistics/attempts' import { attemptTone, cachedSubmission } from 'oj/statistics/attempts'
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants' import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
import type { ClassBoardCell, ClassBoardProblem, Submission } from 'utils/types' import type { TaskBoardCell, TaskBoardProblem, Submission } from 'utils/types'
/** /**
* 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码 * 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码
@@ -17,8 +17,8 @@ import type { ClassBoardCell, ClassBoardProblem, Submission } from 'utils/types'
*/ */
const props = defineProps<{ const props = defineProps<{
username: string username: string
problem: ClassBoardProblem problem: TaskBoardProblem
cell: ClassBoardCell cell: TaskBoardCell
summary: string summary: string
/** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */ /** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */
listHref: string listHref: string
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { getClassBoard } from 'oj/api' import { getTaskBoard } from 'oj/api'
import { useTone } from 'shared/composables/tone' import { useTone } from 'shared/composables/tone'
import { groupClassOptions } from 'utils/classes' import { groupClassOptions } from 'utils/classes'
import UserName from 'shared/components/UserName.vue' import UserName from 'shared/components/UserName.vue'
@@ -12,7 +12,7 @@ import { useConfigStore } from 'shared/store/config'
import { errorMessage } from 'utils/api' import { errorMessage } from 'utils/api'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from 'utils/types' import type { TaskBoard, TaskBoardCell, TaskBoardProblem, TaskBoardStudent } from 'utils/types'
import ExplainModal, { type ExplainPerson } from './components/ExplainModal.vue' import ExplainModal, { type ExplainPerson } from './components/ExplainModal.vue'
import LeaveModal from './components/LeaveModal.vue' import LeaveModal from './components/LeaveModal.vue'
import AdminLink from 'shared/components/AdminLink.vue' import AdminLink from 'shared/components/AdminLink.vue'
@@ -21,10 +21,10 @@ import LessonForm from './components/LessonForm.vue'
import TrailPanel from './components/TrailPanel.vue' import TrailPanel from './components/TrailPanel.vue'
import { errorKind, type ErrorKind } from 'utils/errorKind' import { errorKind, type ErrorKind } from 'utils/errorKind'
import { LESSON_MODE_LABEL } from '@oj2/contract' import { LESSON_MODE_LABEL } from '@oj2/contract'
import { cellStage, cellStyle as cellStyleOf, cellText as cellTextOf, cellTitle as cellTitleOf, isStuck, trailSummary } from './boardCells' import { cellStage, cellStyle as cellStyleOf, cellText as cellTextOf, cellTitle as cellTitleOf, isStuck, trailSummary } from './cells'
/** /**
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。 * 课堂任务(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
* *
* 用法(用户说的):开在教师电脑上,不投影;看完成情况、谁卡住了,讲没交对的代码错在哪; * 用法(用户说的):开在教师电脑上,不投影;看完成情况、谁卡住了,讲没交对的代码错在哪;
* 题号是上课时当场填的。所以: * 题号是上课时当场填的。所以:
@@ -49,7 +49,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const { help, peek } = useTeacherHelp() const { help, peek } = useTeacherHelp()
const board = ref<ClassBoard | null>(null) const board = ref<TaskBoard | null>(null)
/** /**
* 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班, * 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班,
* 否则第一次由后端猜 * 否则第一次由后端猜
@@ -67,7 +67,7 @@ const classOptions = computed(() => {
async function load() { async function load() {
if (document.hidden) return if (document.hidden) return
try { try {
const data = await getClassBoard(className.value ?? undefined) const data = await getTaskBoard(className.value ?? undefined)
board.value = data board.value = data
// 第一次由后端猜,之后就固定在这个班上,别每次刷新都换 // 第一次由后端猜,之后就固定在这个班上,别每次刷新都换
className.value = data.className className.value = data.className
@@ -90,7 +90,7 @@ function changeClass(value: string) {
} }
onMounted(load) onMounted(load)
// 已经停在看板上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建 // 已经停在任务页上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建
watch( watch(
() => route.query, () => route.query,
(query) => { (query) => {
@@ -125,9 +125,9 @@ const editing = ref<false | 'edit' | 'new' | 'carry'>(false)
const mode = computed(() => board.value?.mode ?? null) const mode = computed(() => board.value?.mode ?? null)
/** /**
* 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久, * 一个班一天可以上几节内容不同的课。打开任务页(或换到这个班)时,这一节已经布置了这么久,
* 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。 * 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。
* 只在第一次看到这一节时判一次:老师一直开着看板时,15 秒一刷不会把表突然换掉 * 只在第一次看到这一节时判一次:老师一直开着任务页时,15 秒一刷不会把表突然换掉
*/ */
const STALE_LESSON_MINUTES = 100 const STALE_LESSON_MINUTES = 100
const staleLessonId = ref<number | null>(null) const staleLessonId = ref<number | null>(null)
@@ -159,10 +159,10 @@ type Group = 'help' | 'idle' | 'stuck' | 'working' | 'done' | 'leave'
/** /**
* 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接), * 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接),
* 不经看板接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着 * 不经任务页接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着
* **本站**的学生:服务器和机房各有各的求助队列,和课堂求助本身一样。 * **本站**的学生:服务器和机房各有各的求助队列,和课堂求助本身一样。
*/ */
function helpOf(student: ClassBoardStudent) { function helpOf(student: TaskBoardStudent) {
return collabStore.requests.find((request) => request.studentId === student.userId) ?? null return collabStore.requests.find((request) => request.studentId === student.userId) ?? null
} }
@@ -170,7 +170,7 @@ function helpOf(student: ClassBoardStudent) {
* 经常不动手:最近几节课里最多只交过一节。至少有 3 节课可比才下结论, * 经常不动手:最近几节课里最多只交过一节。至少有 3 节课可比才下结论,
* 开学头两节谁都一样。没交过的人里把他们排在前面,「最后交」那格写上来过几节 * 开学头两节谁都一样。没交过的人里把他们排在前面,「最后交」那格写上来过几节
*/ */
function oftenIdle(student: ClassBoardStudent) { function oftenIdle(student: TaskBoardStudent) {
const total = board.value?.recentLessons ?? 0 const total = board.value?.recentLessons ?? 0
return total >= 3 && student.recentAttended <= 1 return total >= 3 && student.recentAttended <= 1
} }
@@ -179,7 +179,7 @@ function oftenIdle(student: ClassBoardStudent) {
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」 * 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt) * 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/ */
function unseen(student: ClassBoardStudent) { function unseen(student: TaskBoardStudent) {
return !student.seenAt return !student.seenAt
} }
@@ -189,11 +189,11 @@ function seenText(seenAt: string) {
return minutes < 60 ? `${minutes} 分钟前在` : `${Math.floor(minutes / 60)} 小时前在` return minutes < 60 ? `${minutes} 分钟前在` : `${Math.floor(minutes / 60)} 小时前在`
} }
function doneCount(student: ClassBoardStudent) { function doneCount(student: TaskBoardStudent) {
return student.cells.filter((cell) => cell.status === 'accepted').length return student.cells.filter((cell) => cell.status === 'accepted').length
} }
function groupOf(student: ClassBoardStudent): Group { function groupOf(student: TaskBoardStudent): Group {
if (absent(student)) return 'leave' if (absent(student)) return 'leave'
if (helpOf(student)) return 'help' if (helpOf(student)) return 'help'
const cells = student.cells const cells = student.cells
@@ -230,10 +230,10 @@ const GROUP_LABEL: Record<Group, string> = {
* 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手 * 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手
*/ */
const { onLeave, setLeave, cancelLeave } = useLeaveStudents('oj_leave_students') const { onLeave, setLeave, cancelLeave } = useLeaveStudents('oj_leave_students')
function canLeave(student: ClassBoardStudent) { function canLeave(student: TaskBoardStudent) {
return !student.lastSubmitAt && !helpOf(student) return !student.lastSubmitAt && !helpOf(student)
} }
function absent(student: ClassBoardStudent) { function absent(student: TaskBoardStudent) {
return canLeave(student) && onLeave(student.username) return canLeave(student) && onLeave(student.username)
} }
const boardStudents = computed(() => board.value?.students ?? []) const boardStudents = computed(() => board.value?.students ?? [])
@@ -255,7 +255,7 @@ function saveLeave(selected: string[]) {
) )
} }
function byName(a: ClassBoardStudent, b: ClassBoardStudent) { function byName(a: TaskBoardStudent, b: TaskBoardStudent) {
return a.username.localeCompare(b.username, 'zh-CN') return a.username.localeCompare(b.username, 'zh-CN')
} }
@@ -279,7 +279,7 @@ const groupCount = computed(() => {
return count return count
}) })
/** /**
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 classBoardCellSchema.challenge)。 * 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 taskBoardCellSchema.challenge)。
* 做完了又没在挑战的,就是空着的 * 做完了又没在挑战的,就是空着的
*/ */
const challengingCount = computed( const challengingCount = computed(
@@ -300,8 +300,8 @@ const groupStarts = computed(() => {
// ---------- 题卡片和「错着的人按错法归堆」 ---------- // ---------- 题卡片和「错着的人按错法归堆」 ----------
interface WrongPerson { interface WrongPerson {
student: ClassBoardStudent student: TaskBoardStudent
cell: ClassBoardCell cell: TaskBoardCell
kind: ErrorKind kind: ErrorKind
/** 点名字讲的是哪一份:代码(cell.latest)还是最后一张图(cell.flowchart.latest);null = 没有可讲的 */ /** 点名字讲的是哪一份:代码(cell.latest)还是最后一张图(cell.flowchart.latest);null = 没有可讲的 */
explain: 'code' | 'draw' | null explain: 'code' | 'draw' | null
@@ -311,7 +311,7 @@ interface WrongPerson {
* 卡在画流程图上的人按最后一张图归堆:一组都没走对的、对了一部分的分开讲;还在评、评失败的、 * 卡在画流程图上的人按最后一张图归堆:一组都没走对的、对了一部分的分开讲;还在评、评失败的、
* 老评法(数不出几组)各一堆 * 老评法(数不出几组)各一堆
*/ */
function drawKind(cell: ClassBoardCell): ErrorKind { function drawKind(cell: TaskBoardCell): ErrorKind {
const latest = cell.flowchart?.latest const latest = cell.flowchart?.latest
if (!latest) return { key: 'draw-none', label: '还没画对', line: null } if (!latest) return { key: 'draw-none', label: '还没画对', line: null }
if (latest.status === 0 || latest.status === 1) return { key: 'draw-wait', label: 'AI 在评', line: null } if (latest.status === 0 || latest.status === 1) return { key: 'draw-wait', label: 'AI 在评', line: null }
@@ -433,7 +433,7 @@ function openExplain(problemIndex: number, userId: number) {
explainShow.value = true explainShow.value = true
} }
// ---------- 格子(显示规则在 boardCells.ts) ---------- // ---------- 格子(显示规则在 cells.ts) ----------
const cellColors = computed(() => ({ const cellColors = computed(() => ({
tone, tone,
@@ -441,12 +441,12 @@ const cellColors = computed(() => ({
actionColor: theme.value.actionColor, actionColor: theme.value.actionColor,
successColor: theme.value.successColor, successColor: theme.value.successColor,
})) }))
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value) const cellStyle = (cell: TaskBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value, mode.value)
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value) const cellTitle = (cell: TaskBoardCell, student: TaskBoardStudent) => cellTitleOf(cell, student, board.value?.day, mode.value)
const cellText = (cell: ClassBoardCell) => cellTextOf(cell, mode.value) const cellText = (cell: TaskBoardCell) => cellTextOf(cell, mode.value)
/** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */ /** 点了能讲的格子:没做完,卡着的那一步有评完的图或今天的代码 */
function explainable(cell: ClassBoardCell) { function explainable(cell: TaskBoardCell) {
if (cell.status !== 'tried') return false if (cell.status !== 'tried') return false
const stage = cellStage(cell, mode.value) const stage = cellStage(cell, mode.value)
if (stage === 'draw') { if (stage === 'draw') {
@@ -473,15 +473,15 @@ function trailOutside(event: MouseEvent) {
} }
/** /**
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。 * 点格子 / 名字到提交列表看他今天交的。新标签打开:任务页一般开着不关,别把它顶掉。
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。 * 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
* *
* 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时, * 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时,
* 点进去是一片「没有符合条件的提交」 * 点进去是一片「没有符合条件的提交」
*/ */
function submissionsHref( function submissionsHref(
student: ClassBoardStudent, student: TaskBoardStudent,
problem: ClassBoardProblem | undefined, problem: TaskBoardProblem | undefined,
flowchart: boolean, flowchart: boolean,
/** 「以前交过、今天还没交」的人要看以前的,不能只列今天 */ /** 「以前交过、今天还没交」的人要看以前的,不能只列今天 */
today = true, today = true,
@@ -503,7 +503,7 @@ function submissionsHref(
}).href }).href
} }
function lastText(student: ClassBoardStudent) { function lastText(student: TaskBoardStudent) {
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm') if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, 'HH:mm')
if (oftenIdle(student)) { if (oftenIdle(student)) {
return `近 ${board.value?.recentLessons} 节交过 ${student.recentAttended} 节` return `近 ${board.value?.recentLessons} 节交过 ${student.recentAttended} 节`
@@ -559,7 +559,7 @@ function lastText(student: ClassBoardStudent) {
</n-button> </n-button>
<!-- 布置过的每一节课:改错了班、隔天想起来补,去后台 --> <!-- 布置过的每一节课:改错了班、隔天想起来补,去后台 -->
<AdminLink v-if="isDesktop" :to="{ name: 'admin lesson list', query: className ? { className } : {} }">布置过的课</AdminLink> <AdminLink v-if="isDesktop" :to="{ name: 'admin lesson list', query: className ? { className } : {} }">布置过的课</AdminLink>
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) --> <!-- 任务页只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button> <n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button>
</div> </div>
+7 -7
View File
@@ -78,9 +78,9 @@ export const ojs: RouteRecordRaw = {
component: () => import('oj/rank/list.vue'), component: () => import('oj/rank/list.vue'),
}, },
{ {
path: 'classroom', path: 'task',
name: 'classroom board', name: 'task',
component: () => import('oj/classroom/board.vue'), component: () => import('oj/task/index.vue'),
meta: { requiresAuth: true, requiresTeacherAdmin: true }, meta: { requiresAuth: true, requiresTeacherAdmin: true },
}, },
{ {
@@ -90,9 +90,9 @@ export const ojs: RouteRecordRaw = {
meta: { requiresAuth: true, requiresTeacherAdmin: true }, meta: { requiresAuth: true, requiresTeacherAdmin: true },
}, },
{ {
path: 'class', path: 'pk',
name: 'class', name: 'pk',
component: () => import('oj/class/pk.vue'), component: () => import('oj/pk/index.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
@@ -303,7 +303,7 @@ export const admins: RouteRecordRaw = {
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
// 课堂看板布置的每一节课:改、删、补(数据统计按布置切课) // 课堂任务布置的每一节课:改、删、补(数据统计按布置切课)
path: 'lesson', path: 'lesson',
name: 'admin lesson list', name: 'admin lesson list',
component: () => import('admin/lesson/list.vue'), component: () => import('admin/lesson/list.vue'),
+1 -1
View File
@@ -32,7 +32,7 @@ export async function getProfile(username: string = ''): Promise<Profile | null>
return contractDeferred('GET /profiles/:username', () => import('./profileSchema').then((m) => m.userProfileSchema), response) return contractDeferred('GET /profiles/:username', () => import('./profileSchema').then((m) => m.userProfileSchema), response)
} }
/** 带 language 只数能用这个语言交的题(看板「选题」弹框用) */ /** 带 language 只数能用这个语言交的题(任务页「选题」弹框用) */
/** 课堂布置选题用:language 只数能用它交的题,flowchart 只数打开了画流程图的题 */ /** 课堂布置选题用:language 只数能用它交的题,flowchart 只数打开了画流程图的题 */
export function getProblemTagList(language = '', flowchart = false) { export function getProblemTagList(language = '', flowchart = false) {
return api.get<Tag[]>('problem-tags', { params: { language, ...(flowchart ? { flowchart: '1' } : {}) } }) return api.get<Tag[]>('problem-tags', { params: { language, ...(flowchart ? { flowchart: '1' } : {}) } })
@@ -39,7 +39,7 @@ watch(
) )
/** /**
* 老师这边房间开好了(「帮他」、看板上点「在写」、协作条的「下一个」):跳到那道题。 * 老师这边房间开好了(「帮他」、任务页上点「在写」、协作条的「下一个」):跳到那道题。
* 等 room_open 到了才跳,不在点的那一刻跳 —— 服务端可能拒了(别的老师先接走、学生刚走), * 等 room_open 到了才跳,不在点的那一刻跳 —— 服务端可能拒了(别的老师先接走、学生刚走),
* 那时候跳过去就是对着一个空编辑器。已经在这道题上就不跳(同一道题换下一个人) * 那时候跳过去就是对着一个空编辑器。已经在这道题上就不跳(同一道题换下一个人)
*/ */
+7 -7
View File
@@ -23,7 +23,7 @@ const { isDark, toggleDark } = useDarkTransition()
const theme = useThemeVars() const theme = useThemeVars()
/** /**
* 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂看板」只留图标。 * 1280 以下(老师的笔记本)收一收:题号框变窄、「课堂任务」只留图标。
* 机房的两种屏是 1280 和 1366,都走完整那版 —— 老师也会用机房的电脑。 * 机房的两种屏是 1280 和 1366,都走完整那版 —— 老师也会用机房的电脑。
*/ */
const compact = useMediaQuery('(max-width: 1279px)') const compact = useMediaQuery('(max-width: 1279px)')
@@ -142,7 +142,7 @@ function renderThemeRow() {
/** /**
* 个人菜单只放「自己的东西」+ 后台入口 + 暗色开关。 * 个人菜单只放「自己的东西」+ 后台入口 + 暗色开关。
* 课堂求助、课堂看板挪到了顶栏上(HelpButton、下面的课堂看板链接),不在这里。 * 课堂求助、课堂任务挪到了顶栏上(HelpButton、下面的课堂任务链接),不在这里。
*/ */
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => { const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
const work = userStore.isAdminRole || userStore.canToggleDemoMode const work = userStore.isAdminRole || userStore.canToggleDemoMode
@@ -220,13 +220,13 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
<n-button <n-button
quaternary quaternary
class="tool-link" class="tool-link"
:type="active === 'classroom' ? 'primary' : 'default'" :type="active === 'task' ? 'primary' : 'default'"
title="课堂看板" title="课堂任务"
aria-label="课堂看板" aria-label="课堂任务"
@click="router.push('/classroom')" @click="router.push('/task')"
> >
<template #icon><Icon icon="ph:monitor" /></template> <template #icon><Icon icon="ph:monitor" /></template>
<template v-if="!compact">课堂看板</template> <template v-if="!compact">课堂任务</template>
</n-button> </n-button>
<span class="divider"></span> <span class="divider"></span>
</template> </template>
@@ -10,8 +10,8 @@ import HelpRequestList from './HelpRequestList.vue'
* 按钮闪一下(约 1 秒、没声音、不挡内容),代替原来每来一个就弹一条的 toast: * 按钮闪一下(约 1 秒、没声音、不挡内容),代替原来每来一个就弹一条的 toast:
* 那条 toast 一节课能弹十几次,讲台电脑投着屏的时候还弹在全班面前。 * 那条 toast 一节课能弹十几次,讲台电脑投着屏的时候还弹在全班面前。
* *
* 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂看板的表里举手的人 * 点开是挂在按钮下面的举手面板(HelpRequestList),不是弹框。课堂任务的表里举手的人
* 排最前、行里就有「帮他」,在看板上不用点这里。 * 排最前、行里就有「帮他」,在任务页上不用点这里。
*/ */
const props = withDefaults(defineProps<{ size?: 'medium' | 'small' }>(), { size: 'medium' }) const props = withDefaults(defineProps<{ size?: 'medium' | 'small' }>(), { size: 'medium' })
@@ -11,8 +11,8 @@ import UserName from './UserName.vue'
/** /**
* 顶栏「N 人举手」点开的面板(设计稿「举手与协作」HcPanel),挂在按钮下面,不是弹框。 * 顶栏「N 人举手」点开的面板(设计稿「举手与协作」HcPanel),挂在按钮下面,不是弹框。
* *
* 按题分组,每人写错法(和课堂看板同一套 errorKind)和等了多久。同一道题两人以上举手, * 按题分组,每人写错法(和课堂任务同一套 errorKind)和等了多久。同一道题两人以上举手,
* 用户的做法是**停下来全班讲**:组头给「讲这道题」(去看板、选中这道题看错法)和 * 用户的做法是**停下来全班讲**:组头给「讲这道题」(去任务页、选中这道题看错法)和
* 「讲完了,都放下」(这道题还在排队的手一次放下)。 * 「讲完了,都放下」(这道题还在排队的手一次放下)。
*/ */
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: [] }>()
@@ -48,12 +48,12 @@ function onHelp(item: CollabRequestItem) {
if (help(item.studentId)) emit('close') if (help(item.studentId)) emit('close')
} }
/** 「讲这道题」:去这个班的看板、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */ /** 「讲这道题」:去这个班的任务页、选中这道题,下面展开错着的人按错法归堆,点人就是讲错误弹层 */
function explain(items: CollabRequestItem[], problemId: string) { function explain(items: CollabRequestItem[], problemId: string) {
const className = items.find((item) => item.className)?.className const className = items.find((item) => item.className)?.className
emit('close') emit('close')
router.push({ router.push({
name: 'classroom board', name: 'task',
query: { ...(className ? { className } : {}), problem: problemId }, query: { ...(className ? { className } : {}), problem: problemId },
}) })
} }
@@ -191,7 +191,7 @@ watch(
) )
/** /**
* 学生开着这道题的编辑器:报给服务端,老师看板「在写」那一列就是它,点了能直接打开他的 * 学生开着这道题的编辑器:报给服务端,老师任务页「在写」那一列就是它,点了能直接打开他的
* 代码(不用等他举手)。换题、换语言重报一次,编辑器卸载(切走、切成流程图)报 null。 * 代码(不用等他举手)。换题、换语言重报一次,编辑器卸载(切走、切成流程图)报 null。
* 比赛入口不报(collab = false):比赛题号和公开题撞号,老师打开的会是另一道题 * 比赛入口不报(collab = false):比赛题号和公开题撞号,老师打开的会是另一道题
*/ */
@@ -2,7 +2,7 @@ import { onMounted, ref } from 'vue'
import { parseTime } from 'utils/functions' import { parseTime } from 'utils/functions'
/** /**
* 课堂看板的「请假」:老师手动标记今天不在的学生,看板的人数、进度、「还没交过」都不算他们。 * 课堂任务的「请假」:老师手动标记今天不在的学生,任务页的人数、进度、「还没交过」都不算他们。
* *
* **今天有效**(按东八区的日子),第二天自动清掉 —— 请假一般是请一整天,原来按两小时算, * **今天有效**(按东八区的日子),第二天自动清掉 —— 请假一般是请一整天,原来按两小时算,
* 连上两节课的第二节中途人又冒出来,得重标一遍。 * 连上两节课的第二节中途人又冒出来,得重标一遍。
@@ -11,7 +11,7 @@ import { parseTime } from 'utils/functions'
* 换个人、换台机器都不该继承(2026-10 用户定的,不上服务器)。 * 换个人、换台机器都不该继承(2026-10 用户定的,不上服务器)。
* *
* 原来叫「请假隐藏」(开关 → 名字变成带 × 的标签 → 点 ×),用户说不会用,2026-10 改成 * 原来叫「请假隐藏」(开关 → 名字变成带 × 的标签 → 点 ×),用户说不会用,2026-10 改成
* 看板上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻, * 任务页上一个「请假」按钮勾名单。旧键 `oj_hidden_students` 存的是两小时的过期时刻,
* 口径不同,不迁移,直接删掉。 * 口径不同,不迁移,直接删掉。
* *
* @param storageKey localStorage 的键 * @param storageKey localStorage 的键
@@ -36,7 +36,7 @@ export function useLeaveStudents(storageKey: string) {
try { try {
localStorage.setItem(storageKey, JSON.stringify(data)) localStorage.setItem(storageKey, JSON.stringify(data))
} catch { } catch {
// 存不下就只在这次打开里有效,不影响看板 // 存不下就只在这次打开里有效,不影响任务页
} }
} }
@@ -48,7 +48,7 @@ export function useLeaveStudents(storageKey: string) {
/** /**
* 把 `usernames` 这批人的标记换成 `selected`:在里面但没选的取消,选了的标上。 * 把 `usernames` 这批人的标记换成 `selected`:在里面但没选的取消,选了的标上。
* 只动这一批(看板上这个班),别的班标着的人不受影响 * 只动这一批(任务页上这个班),别的班标着的人不受影响
*/ */
function setLeave(usernames: string[], selected: string[]) { function setLeave(usernames: string[], selected: string[]) {
const scope = new Set(usernames) const scope = new Set(usernames)
@@ -1,7 +1,7 @@
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
/** /**
* 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂看板)共用。 * 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂任务)共用。
* *
* 只发消息,不跳页面:房间开好(room_open 到了)才跳到那道题,跳转统一在 CollabHost 里做 * 只发消息,不跳页面:房间开好(room_open 到了)才跳到那道题,跳转统一在 CollabHost 里做
* —— 原来是点完就跳,服务端拒了(别的老师先接走、学生刚走)前端也已经跳过去了。 * —— 原来是点完就跳,服务端拒了(别的老师先接走、学生刚走)前端也已经跳过去了。
+3 -3
View File
@@ -7,9 +7,9 @@ import { useUserStore } from 'shared/store/user'
import { getAccessToken } from 'utils/auth' import { getAccessToken } from 'utils/auth'
import { STORAGE_KEY } from 'utils/constants' import { STORAGE_KEY } from 'utils/constants'
import storage from 'utils/storage' import storage from 'utils/storage'
import type { ClassBoardCell } from 'utils/types' import type { TaskBoardCell } from 'utils/types'
type BoardLatest = NonNullable<ClassBoardCell['latest']> type BoardLatest = NonNullable<TaskBoardCell['latest']>
// 全站唯一一处,脱离 n-message-provider 也能弹 —— 强制登出跟当前挂着哪个组件无关。 // 全站唯一一处,脱离 n-message-provider 也能弹 —— 强制登出跟当前挂着哪个组件无关。
// utils/api.ts 里也是这么做的 // utils/api.ts 里也是这么做的
@@ -580,7 +580,7 @@ export interface CollabRequestItem {
className: string | null className: string | null
problemId: string problemId: string
problemTitle: string problemTitle: string
/** 他这道题最后一次代码提交(和看板格子的 latest 同形),面板按它写错法;没交过为 null */ /** 他这道题最后一次代码提交(和任务页格子的 latest 同形),面板按它写错法;没交过为 null */
latest: BoardLatest | null latest: BoardLatest | null
createdAt: number createdAt: number
status: 'pending' | 'active' status: 'pending' | 'active'
+1 -1
View File
@@ -67,7 +67,7 @@ const NAV: NavGroup[] = [
to: '/admin/problemset/list', to: '/admin/problemset/list',
role: 'teacher', role: 'teacher',
}, },
{ label: '课堂作业', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' }, { label: '课堂任务', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' },
{ {
label: '教程', label: '教程',
icon: 'ph:book-open', icon: 'ph:book-open',
+5 -5
View File
@@ -6,13 +6,13 @@ export type HelpStatus = 'idle' | 'pending' | 'active'
export interface RoomInfo { export interface RoomInfo {
/** /**
* help = 学生举手、老师接的;peek = 老师在看板上主动打开的(学生没举手)。 * help = 学生举手、老师接的;peek = 老师在任务页上主动打开的(学生没举手)。
* 学生那边按钮写「在帮你」还是「在看你的代码」、结束时说「帮完了」还是「看完了」看它 * 学生那边按钮写「在帮你」还是「在看你的代码」、结束时说「帮完了」还是「看完了」看它
*/ */
mode: 'help' | 'peek' mode: 'help' | 'peek'
peerName: string peerName: string
peerRole: 'student' | 'teacher' peerRole: 'student' | 'teacher'
/** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的看板) */ /** 教师端才有:学生的 id(协作条按它在列表里找他举手多久了)和班级(「帮完了」回哪个班的任务页) */
peerId: number | null peerId: number | null
peerClassName: string | null peerClassName: string | null
problemId: string problemId: string
@@ -107,7 +107,7 @@ export const useCollabStore = defineStore('collab', () => {
// 自己离开了这道题(跳走页面、把语言切成流程图) // 自己离开了这道题(跳走页面、把语言切成流程图)
case 'self_left': case 'self_left':
return teacher ? '你已离开这道题,求助退回队列了' : '你已离开这道题,协作结束' return teacher ? '你已离开这道题,求助退回队列了' : '你已离开这道题,协作结束'
// done:老师自己点的「帮完了」,页面跟着回看板或者去下一个,不用再说一句 // done:老师自己点的「帮完了」,页面跟着回任务页或者去下一个,不用再说一句
default: default:
return teacher ? '' : '老师已结束这次帮忙' return teacher ? '' : '老师已结束这次帮忙'
} }
@@ -231,7 +231,7 @@ export const useCollabStore = defineStore('collab', () => {
ws.send({ type: 'accept', studentId }) ws.send({ type: 'accept', studentId })
} }
/** 老师在看板上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */ /** 老师在任务页上主动打开一个学生正在写的代码(他没举手也行;举着手就等于接单) */
function peek(studentId: number) { function peek(studentId: number) {
ws.send({ type: 'peek', studentId }) ws.send({ type: 'peek', studentId })
} }
@@ -242,7 +242,7 @@ export const useCollabStore = defineStore('collab', () => {
} }
/** /**
* 学生报告他开着哪道题的编辑器(null = 离开了),老师看板「在写」那一列用。 * 学生报告他开着哪道题的编辑器(null = 离开了),老师任务页「在写」那一列用。
* 老师不报:老师的编辑器不会被别人打开 * 老师不报:老师的编辑器不会被别人打开
*/ */
function reportPresence(problemId: string | null, language: LANGUAGE = 'Python') { function reportPresence(problemId: string | null, language: LANGUAGE = 'Python') {
+1 -1
View File
@@ -8,7 +8,7 @@ import type { ClassActivity, ClassActivityProblem } from 'utils/types'
const STALE_MS = 2 * 60 * 1000 const STALE_MS = 2 * 60 * 1000
/** /**
* 「这节课的题」:首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的)。 * 「这节课的题」:首页「班里在做」那几道(老师在课堂任务布置的,或者从同班提交推断的)。
* *
* 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。 * 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。
* 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边 * 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边
+1 -1
View File
@@ -19,7 +19,7 @@ export const useUserStore = defineStore('user', () => {
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏。 // 演示模式:超管临时把界面伪装成普通学生,方便上课投屏。
// 开关存在 storage 里、只有主动退登才清,会话过期后换人登录它还在 —— 所以只对超管 // 开关存在 storage 里、只有主动退登才清,会话过期后换人登录它还在 —— 所以只对超管
// 生效。不然老师在超管用过的机房电脑上一登录,后台、看板、求助全没了, // 生效。不然老师在超管用过的机房电脑上一登录,后台、任务页、求助全没了,
// 而「退出演示」只给超管,他自己关不掉 // 而「退出演示」只给超管,他自己关不掉
const demoSwitch = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false) const demoSwitch = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value) const demoMode = computed(() => demoSwitch.value && realIsSuperAdmin.value)
+3 -3
View File
@@ -1,15 +1,15 @@
import { classifyDiff, type DiffKind } from 'utils/wrongAnswer' import { classifyDiff, type DiffKind } from 'utils/wrongAnswer'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants' import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
import type { ClassBoardCell } from 'utils/types' import type { TaskBoardCell } from 'utils/types'
/** /**
* 课堂看板:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时 * 课堂任务:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
* 要的是「这道题全班错在哪」:同一种错讲一遍就够,所以按最后一次提交归类,堆名要短, * 要的是「这道题全班错在哪」:同一种错讲一遍就够,所以按最后一次提交归类,堆名要短,
* 一眼能读完 —— 长的中文解释在点开之后的弹层里(和提交详情同一份)。 * 一眼能读完 —— 长的中文解释在点开之后的弹层里(和提交详情同一份)。
* *
* 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。 * 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。
*/ */
export type BoardLatest = NonNullable<ClassBoardCell['latest']> export type BoardLatest = NonNullable<TaskBoardCell['latest']>
export interface ErrorKind { export interface ErrorKind {
/** 归堆用:同一个 key 是同一种错 */ /** 归堆用:同一个 key 是同一种错 */
+2 -2
View File
@@ -251,8 +251,8 @@ export type { WeeklyRank, WeeklyRankItem } from '@oj2/contract'
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */ /** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem, LastVisit } from '@oj2/contract' export type { ClassActivity, ClassActivityProblem, LastVisit } from '@oj2/contract'
/** 课堂看板:一个班今天这节课的题 × 全班学生 */ /** 课堂任务:一个班今天这节课的题 × 全班学生 */
export type { ClassBoard, ClassBoardProblem, ClassBoardStudent, ClassBoardCell, ClassLesson } from '@oj2/contract' export type { TaskBoard, TaskBoardProblem, TaskBoardStudent, TaskBoardCell, ClassLesson } from '@oj2/contract'
/** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */ /** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */
export type { KnowledgeLevel, KnowledgeMap } from '@oj2/contract' export type { KnowledgeLevel, KnowledgeMap } from '@oj2/contract'
+2 -2
View File
@@ -377,9 +377,9 @@ export const dashboardInfoSchema = z.object({
todaySystemErrorCount: z.number().int(), todaySystemErrorCount: z.number().int(),
/** 现在在线的人数(5 分钟内有动静,auth/presence.ts) */ /** 现在在线的人数(5 分钟内有动静,auth/presence.ts) */
onlineCount: z.number().int(), onlineCount: z.number().int(),
/** 正在上课的班(最近两小时同班在交题,和课堂看板猜班同一个办法);没有为 null */ /** 正在上课的班(最近两小时同班在交题,和课堂任务猜班同一个办法);没有为 null */
activeClass: z.string().nullable(), activeClass: z.string().nullable(),
/** 全站的数(看板那一排)。学生 = 普通用户 + 学生管理员,老师 = 教师管理员 + 超管 */ /** 全站的数(后台首页看板那一排)。学生 = 普通用户 + 学生管理员,老师 = 教师管理员 + 超管 */
site: z.object({ site: z.object({
students: z.number().int(), students: z.number().int(),
teachers: z.number().int(), teachers: z.number().int(),
+2 -2
View File
@@ -16,7 +16,7 @@ export const LEARNING_PERIOD_LABELS = {
} as const satisfies Record<z.infer<typeof learningPeriodSchema>, string> } as const satisfies Record<z.infer<typeof learningPeriodSchema>, string>
/** /**
* 一道题在这段时间里的结果。代码提交和流程图合起来算,口径同课堂看板:代码 AC / * 一道题在这段时间里的结果。代码提交和流程图合起来算,口径同课堂任务:代码 AC /
* AST_CHECK_FAILED、流程图评到 A / S 都算做对。 * AST_CHECK_FAILED、流程图评到 A / S 都算做对。
* *
* - `first`:第一次交就对了 * - `first`:第一次交就对了
@@ -38,7 +38,7 @@ export const learningItemSchema = z.object({
}) })
/** /**
* 一天一行。`lesson` 是班上这节课的题:老师在课堂看板布置过就用布置的,否则同班同一天 * 一天一行。`lesson` 是班上这节课的题:老师在课堂任务布置过就用布置的,否则同班同一天
* ≥ 5 人做过的题(同「班里在做」的推断);一道题只归到一天。`own` 是这个学生这天自己练的、 * ≥ 5 人做过的题(同「班里在做」的推断);一道题只归到一天。`own` 是这个学生这天自己练的、
* 不属于任何一节课的题,按他第一次交的那天归。 * 不属于任何一节课的题,按他第一次交的那天归。
*/ */
+1 -1
View File
@@ -64,7 +64,7 @@ export const createFlowchartResponseSchema = z.object({
status: z.literal('pending'), status: z.literal('pending'),
}) })
/** /**
* 流程图画到这两档算这道题做完:题单进度、课堂条、课堂看板、「你画的流程图」、结果页签的 ✓ * 流程图画到这两档算这道题做完:题单进度、课堂条、课堂任务、「你画的流程图」、结果页签的 ✓
* 都按它(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 4、6)。前后端只有这一份 * 都按它(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 4、6)。前后端只有这一份
*/ */
export const FLOWCHART_PASS_GRADES = ['S', 'A'] as const export const FLOWCHART_PASS_GRADES = ['S', 'A'] as const
+82
View File
@@ -0,0 +1,82 @@
import { z } from 'zod'
import { judgeStatusSchema } from './judge-status'
import { lessonModeSchema } from './language'
import { assignedLanguageSchema, lessonStepStatusSchema } from './task'
/**
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
* `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。
*/
export const classActivityProblemSchema = z.object({
problemDisplayId: z.string(),
title: z.string(),
/** 那天做过这道题的同班人数(含自己) */
userCount: z.number().int(),
/** 其中那天通过了的人数 */
acceptedCount: z.number().int(),
/**
* 我自己在题库里(不含比赛)做这道题的状态,不限那一天。老师布置时选了语言的,
* 代码只认用那个语言交的(流程图照旧算)
*/
myStatus: z.enum(['accepted', 'tried', 'none']),
/**
* 今天布置的课要这道题画流程图 / 写代码时,这一步我做到哪了;不要这一步为 null。
* 推断出来的课两个都是 null(不分步,myStatus 照旧代码或流程图做对都算)。
*
* 一道题今天可能被布置了两次(先布置画流程图、再布置写代码):两节课的要求合起来算,
* 学生那边就和「先画再写」一样是两步 —— 老师开了写代码的那节,还没画完的人照样看得到要画
*/
draw: lessonStepStatusSchema.nullable(),
code: lessonStepStatusSchema.nullable(),
})
export const classActivitySchema = z.object({
className: z.string().nullable(),
day: z.string().nullable(),
/** teacher = 老师在课堂任务里布置的;inferred = 从同班提交记录推断的 */
source: z.enum(['teacher', 'inferred']).nullable(),
/** 写代码那一步用的语言:今天最后布置的、要写代码的那节选的(只画流程图的课为 null) */
language: assignedLanguageSchema,
/** 今天最后布置的那节做什么;推断出来的课为 null */
mode: lessonModeSchema.nullable(),
problems: z.array(classActivityProblemSchema),
})
/**
* 首页的「上次来」卡(原来登录后弹的「登录速报」并进了首页,AI 那两句不要了)。
* 窗口是**上次登录到这次登录之间**,不是到现在 —— 这次登录期间卡片内容不变,
* 今天做的题不会混进「上次」里。只算题库提交,比赛的题号在题库里点不开。
*/
export const lastVisitSchema = z.object({
/** 上次登录的时刻。null = 头一回登录(或会话早于这个字段) */
previousLogin: z.string().nullable(),
/** null = 上次来已经是 30 天以前,或那次一条代码都没交 */
summary: z
.object({
submissionCount: z.number().int(),
/** 那次做对的题数(去重) */
solvedCount: z.number().int(),
/** 那次最后一条提交的时刻,前端拿它和「班里那天」比是不是同一天 */
lastSubmitTime: z.string(),
/**
* 那次交过、**到现在都还没做对**的题(这次登录后补做对了的不算),
* 按最后一次提交倒序,最多 3 道
*/
unsolved: z.array(
z.object({
problemDisplayId: z.string(),
title: z.string(),
attempts: z.number().int(),
lastResult: judgeStatusSchema,
}),
),
/** 那次做对的题,按第一次做对的先后,最多 12 道(总数看 solvedCount) */
solved: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
})
.nullable(),
})
export type ClassActivity = z.infer<typeof classActivitySchema>
export type LastVisit = z.infer<typeof lastVisitSchema>
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
+2 -1
View File
@@ -3,7 +3,8 @@ export * from './auth'
export * from './account' export * from './account'
export * from './achievement' export * from './achievement'
export * from './ai' export * from './ai'
export * from './classroom' export * from './home'
export * from './task'
export * from './common' export * from './common'
export * from './content' export * from './content'
export * from './contest' export * from './contest'
+1 -1
View File
@@ -42,7 +42,7 @@ export const problemLanguageSchema = z.enum([...judgeLanguageSchema.options, 'SQ
export const runnableLanguageSchema = judgeLanguageSchema.extract(['C', 'C++', 'Python']) export const runnableLanguageSchema = judgeLanguageSchema.extract(['C', 'C++', 'Python'])
/** /**
* 老师布置作业能选的语言:判题机跑得了的三种,加 SQL(数据库课一整节都是 SQL 题)。 * 老师布置任务能选的语言:判题机跑得了的三种,加 SQL(数据库课一整节都是 SQL 题)。
* SQL 题只收 SQL,所以 C / Python 的作业里夹 SQL 题也行 —— 那几道不受作业语言限制 * SQL 题只收 SQL,所以 C / Python 的作业里夹 SQL 题也行 —— 那几道不受作业语言限制
*/ */
export const lessonLanguageSchema = problemLanguageSchema.extract(['Python', 'C', 'C++', 'SQL']) export const lessonLanguageSchema = problemLanguageSchema.extract(['Python', 'C', 'C++', 'SQL'])
+4 -4
View File
@@ -451,7 +451,7 @@ export const NO_CLASS = 'none'
* 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38, * 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38,
* 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。 * 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。
* *
* **老师在课堂看板布置过的课以布置为准**(`assigned`):题目和顺序照布置的,那天这个班 * **老师在课堂任务布置过的课以布置为准**(`assigned`):题目和顺序照布置的,那天这个班
* 交这几道题的提交全算这节课(不论隔了多久、几个人),完成率按全班算。这些提交不再参与上面 * 交这几道题的提交全算这节课(不论隔了多久、几个人),完成率按全班算。这些提交不再参与上面
* 的切段 —— 同一天没布置的另一节课照旧推断,也不会被算成零散提交。 * 的切段 —— 同一天没布置的另一节课照旧推断,也不会被算成零散提交。
*/ */
@@ -476,7 +476,7 @@ export const submissionLessonsSchema = z.object({
* 推断的课是这一段里 5 人以上做过的,做的人多的在前 * 推断的课是这一段里 5 人以上做过的,做的人多的在前
*/ */
problems: z.array(z.string()), problems: z.array(z.string()),
/** 老师在课堂看板布置的课(不是从提交推断的) */ /** 老师在课堂任务布置的课(不是从提交推断的) */
assigned: z.boolean(), assigned: z.boolean(),
/** /**
* 布置的课做什么(写代码 / 画流程图 / 先画再写);推断的课为 null。要画流程图的课,次数和 * 布置的课做什么(写代码 / 画流程图 / 先画再写);推断的课为 null。要画流程图的课,次数和
@@ -485,7 +485,7 @@ export const submissionLessonsSchema = z.object({
mode: lessonModeSchema.nullable(), mode: lessonModeSchema.nullable(),
/** /**
* 布置的课:班里把布置的题全做对了的人数(那天结束前,以前做对的也算;布置时选了语言的 * 布置的课:班里把布置的题全做对了的人数(那天结束前,以前做对的也算;布置时选了语言的
* 只认那个语言,流程图评到 A / S 也算 —— 和看板同一个口径)。推断的课为 null * 只认那个语言,流程图评到 A / S 也算 —— 和任务页同一个口径)。推断的课为 null
*/ */
doneCount: z.number().int().nullable(), doneCount: z.number().int().nullable(),
/** 多半是从题单里交的 —— 「题单 7 道」 */ /** 多半是从题单里交的 —— 「题单 7 道」 */
@@ -494,7 +494,7 @@ export const submissionLessonsSchema = z.object({
total: z.number().int(), total: z.number().int(),
/** 同上,判完的里面对了多少,百分数 */ /** 同上,判完的里面对了多少,百分数 */
correctRate: z.number(), correctRate: z.number(),
/** 最后一条在 15 分钟内:还在上课,界面上引去课堂看板 */ /** 最后一条在 15 分钟内:还在上课,界面上引去课堂任务 */
live: z.boolean(), live: z.boolean(),
/** 这节课的题里没过次数最多的那道;一次都没错过为 null */ /** 这节课的题里没过次数最多的那道;一次都没错过为 null */
hardest: z hardest: z
@@ -1,113 +1,40 @@
import { z } from 'zod' import { z } from 'zod'
import { judgeStatusSchema } from './judge-status' import { judgeStatusSchema } from './judge-status'
import { lessonLanguageSchema as lessonLanguageValueSchema, lessonModeSchema } from './language' import { lessonLanguageSchema, lessonModeSchema } from './language'
import { statisticInfoSchema } from './submission' import { statisticInfoSchema } from './submission'
/** 课堂条上一道题一步的状态(画 / 写),和 myStatus 同一套取值 */ /** 课堂条上一道题一步的状态(画 / 写),和 myStatus 同一套取值 */
const stepStatusSchema = z.enum(['accepted', 'tried', 'none']) export const lessonStepStatusSchema = z.enum(['accepted', 'tried', 'none'])
/**
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
* `day` 是东八区日历日;最近 7 天都没有这样的题时为 null,`problems` 为空。
*/
export const classActivityProblemSchema = z.object({
problemDisplayId: z.string(),
title: z.string(),
/** 那天做过这道题的同班人数(含自己) */
userCount: z.number().int(),
/** 其中那天通过了的人数 */
acceptedCount: z.number().int(),
/**
* 我自己在题库里(不含比赛)做这道题的状态,不限那一天。老师布置时选了语言的,
* 代码只认用那个语言交的(流程图照旧算)
*/
myStatus: z.enum(['accepted', 'tried', 'none']),
/**
* 今天布置的课要这道题画流程图 / 写代码时,这一步我做到哪了;不要这一步为 null。
* 推断出来的课两个都是 null(不分步,myStatus 照旧代码或流程图做对都算)。
*
* 一道题今天可能被布置了两次(先布置画流程图、再布置写代码):两节课的要求合起来算,
* 学生那边就和「先画再写」一样是两步 —— 老师开了写代码的那节,还没画完的人照样看得到要画
*/
draw: stepStatusSchema.nullable(),
code: stepStatusSchema.nullable(),
})
/** /**
* 老师布置这节课时选的语言(Python / C / C++ / SQL)。null = 没选:推断出来的课、加这一项之前 * 老师布置这节课时选的语言(Python / C / C++ / SQL)。null = 没选:推断出来的课、加这一项之前
* 布置的课。选了之后这份作业「做完」只认这个语言交对的(SQL 题例外,交对 SQL 就算), * 布置的课。选了之后这份作业「做完」只认这个语言交对的(SQL 题例外,交对 SQL 就算),
* 学生打开这几道题编辑器默认就是它 * 学生打开这几道题编辑器默认就是它
*/ */
const lessonLanguageSchema = lessonLanguageValueSchema.nullable() export const assignedLanguageSchema = lessonLanguageSchema.nullable()
export const classActivitySchema = z.object({
className: z.string().nullable(),
day: z.string().nullable(),
/** teacher = 老师在课堂看板里布置的;inferred = 从同班提交记录推断的 */
source: z.enum(['teacher', 'inferred']).nullable(),
/** 写代码那一步用的语言:今天最后布置的、要写代码的那节选的(只画流程图的课为 null) */
language: lessonLanguageSchema,
/** 今天最后布置的那节做什么;推断出来的课为 null */
mode: lessonModeSchema.nullable(),
problems: z.array(classActivityProblemSchema),
})
/** /**
* 首页的「上次来」卡(原来登录后弹的「登录速报」并进了首页,AI 那两句不要了)。 * 课堂任务里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉。
* 窗口是**上次登录到这次登录之间**,不是到现在 —— 这次登录期间卡片内容不变,
* 今天做的题不会混进「上次」里。只算题库提交,比赛的题号在题库里点不开。
*/
export const lastVisitSchema = z.object({
/** 上次登录的时刻。null = 头一回登录(或会话早于这个字段) */
previousLogin: z.string().nullable(),
/** null = 上次来已经是 30 天以前,或那次一条代码都没交 */
summary: z
.object({
submissionCount: z.number().int(),
/** 那次做对的题数(去重) */
solvedCount: z.number().int(),
/** 那次最后一条提交的时刻,前端拿它和「班里那天」比是不是同一天 */
lastSubmitTime: z.string(),
/**
* 那次交过、**到现在都还没做对**的题(这次登录后补做对了的不算),
* 按最后一次提交倒序,最多 3 道
*/
unsolved: z.array(
z.object({
problemDisplayId: z.string(),
title: z.string(),
attempts: z.number().int(),
lastResult: judgeStatusSchema,
}),
),
/** 那次做对的题,按第一次做对的先后,最多 12 道(总数看 solvedCount) */
solved: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
})
.nullable(),
})
/**
* 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉。
* `language` 是这份作业用什么语言做,null = 没选。 * `language` 是这份作业用什么语言做,null = 没选。
* *
* 一个班一天可以上几节内容不同的课,每次布置是一行(class_lesson.id): * 一个班一天可以上几节内容不同的课,每次布置是一行(class_lesson.id):
* - `lessonId`:改 / 清掉这一节(看板上的「改」); * - `lessonId`:改 / 清掉这一节(任务页上的「改」);
* - `newLesson`:今天再开一节(看板上的「新的一节」); * - `newLesson`:今天再开一节(任务页上的「新的一节」);
* - 两个都不带:改今天最后布置的那一节,今天还没有就新开 —— 一天只能一份时的老写法, * - 两个都不带:改今天最后布置的那一节,今天还没有就新开 —— 一天只能一份时的老写法,
* 开着旧页面的老师、没跟上的机房站点发的都是这种,别让它们每改一次就多出一节课 * 开着旧页面的老师、没跟上的机房站点发的都是这种,别让它们每改一次就多出一节课
*/ */
export const classLessonRequestSchema = z.object({ export const classLessonRequestSchema = z.object({
className: z.string().trim().min(1), className: z.string().trim().min(1),
problemDisplayIds: z.array(z.string().trim().min(1)).max(20), problemDisplayIds: z.array(z.string().trim().min(1)).max(20),
language: lessonLanguageSchema.nullable(), language: assignedLanguageSchema.nullable(),
/** 不带 = 写代码(开着的旧页面发的就是这种)。画流程图时 language 不用,存成 null */ /** 不带 = 写代码(开着的旧页面发的就是这种)。画流程图时 language 不用,存成 null */
mode: lessonModeSchema.default('code'), mode: lessonModeSchema.default('code'),
lessonId: z.number().int().positive().optional(), lessonId: z.number().int().positive().optional(),
newLesson: z.boolean().optional(), newLesson: z.boolean().optional(),
}) })
export const classBoardProblemSchema = z.object({ export const taskBoardProblemSchema = z.object({
problemId: z.number().int(), problemId: z.number().int(),
problemDisplayId: z.string(), problemDisplayId: z.string(),
title: z.string(), title: z.string(),
@@ -117,7 +44,7 @@ export const classBoardProblemSchema = z.object({
hasChallenges: z.boolean(), hasChallenges: z.boolean(),
}) })
export const classBoardCellSchema = z.object({ export const taskBoardCellSchema = z.object({
/** /**
* accepted 看的是题库里(不含比赛)有没有通过过,不限今天 —— 以前做过的也算做完。 * accepted 看的是题库里(不含比赛)有没有通过过,不限今天 —— 以前做过的也算做完。
* 布置时选了语言的,代码的次数和通过都只数那个语言(流程图照旧) * 布置时选了语言的,代码的次数和通过都只数那个语言(流程图照旧)
@@ -140,7 +67,7 @@ export const classBoardCellSchema = z.object({
* 代码这一步:交对过(不限今天)/ 交过没对 / 没交过。写代码的课和 status 一样;先画再写的课 * 代码这一步:交对过(不限今天)/ 交过没对 / 没交过。写代码的课和 status 一样;先画再写的课
* 要它分出「图画对了、代码还没对」 * 要它分出「图画对了、代码还没对」
*/ */
codeStatus: stepStatusSchema, codeStatus: lessonStepStatusSchema,
/** /**
* 流程图这一步,只在这节课要画流程图时有(mode 是 flowchart / both),否则 null。 * 流程图这一步,只在这节课要画流程图时有(mode 是 flowchart / both),否则 null。
* - `passedAt`:第一次画到 A / S 的时刻(不限今天) * - `passedAt`:第一次画到 A / S 的时刻(不限今天)
@@ -149,7 +76,7 @@ export const classBoardCellSchema = z.object({
*/ */
flowchart: z flowchart: z
.object({ .object({
status: stepStatusSchema, status: lessonStepStatusSchema,
passedAt: z.string().nullable(), passedAt: z.string().nullable(),
latest: z latest: z
.object({ .object({
@@ -193,12 +120,12 @@ export const classBoardCellSchema = z.object({
challenge: z.enum(['done', 'doing']).nullable(), challenge: z.enum(['done', 'doing']).nullable(),
}) })
export const classBoardStudentSchema = z.object({ export const taskBoardStudentSchema = z.object({
userId: z.number().int(), userId: z.number().int(),
username: z.string(), username: z.string(),
realName: z.string().nullable(), realName: z.string().nullable(),
/** 和 problems 同序 */ /** 和 problems 同序 */
cells: z.array(classBoardCellSchema), cells: z.array(taskBoardCellSchema),
/** 今天最后一次提交(任何题),没交过为 null */ /** 今天最后一次提交(任何题),没交过为 null */
lastSubmitAt: z.string().nullable(), lastSubmitAt: z.string().nullable(),
/** /**
@@ -207,7 +134,7 @@ export const classBoardStudentSchema = z.object({
*/ */
codeToday: z.boolean(), codeToday: z.boolean(),
drawnToday: z.boolean(), drawnToday: z.boolean(),
/** 最近几节课(不含今天,个数见 ClassBoard.recentLessons)里交过题的有几节 */ /** 最近几节课(不含今天,个数见 TaskBoard.recentLessons)里交过题的有几节 */
recentAttended: z.number().int(), recentAttended: z.number().int(),
/** /**
* 他此刻开着哪道题的代码编辑器(展示题号),没开着为 null。老师点它就能打开他正在写的 * 他此刻开着哪道题的代码编辑器(展示题号),没开着为 null。老师点它就能打开他正在写的
@@ -224,18 +151,18 @@ export const classBoardStudentSchema = z.object({
}) })
/** /**
* 课堂看板:一个班、今天、这节课的几道题 × 全班学生。「这节课」是今天最后布置的那一节, * 课堂任务:一个班、今天、这节课的几道题 × 全班学生。「这节课」是今天最后布置的那一节,
* 今天没布置过就按今天同班的提交推断。 * 今天没布置过就按今天同班的提交推断。
* className 为 null = 没指定班级、最近两小时也没有哪个班在交(没法猜)。 * className 为 null = 没指定班级、最近两小时也没有哪个班在交(没法猜)。
*/ */
export const classBoardSchema = z.object({ export const taskBoardSchema = z.object({
className: z.string().nullable(), className: z.string().nullable(),
day: z.string(), day: z.string(),
source: z.enum(['teacher', 'inferred']).nullable(), source: z.enum(['teacher', 'inferred']).nullable(),
/** 老师布置的这一节的 id(「改」带着它);推断出来的课为 null */ /** 老师布置的这一节的 id(「改」带着它);推断出来的课为 null */
lessonId: z.number().int().nullable(), lessonId: z.number().int().nullable(),
/** 这一节布置的语言(见 lessonLanguageSchema) */ /** 这一节布置的语言(见 assignedLanguageSchema) */
language: lessonLanguageSchema, language: assignedLanguageSchema,
/** 这一节做什么;推断出来的课为 null(代码交对或流程图画对都算) */ /** 这一节做什么;推断出来的课为 null(代码交对或流程图画对都算) */
mode: lessonModeSchema.nullable(), mode: lessonModeSchema.nullable(),
/** 这一节第一次布置的时刻,之后改题不变;推断出来的课为 null */ /** 这一节第一次布置的时刻,之后改题不变;推断出来的课为 null */
@@ -243,9 +170,9 @@ export const classBoardSchema = z.object({
/** 第一次布置的老师(真名,没有真名用用户名);推断出来的课为 null */ /** 第一次布置的老师(真名,没有真名用用户名);推断出来的课为 null */
assignedBy: z.string().nullable(), assignedBy: z.string().nullable(),
/** 这个班最近一次布置选的语言(含今天),给语言下拉当默认值;从没选过为 null */ /** 这个班最近一次布置选的语言(含今天),给语言下拉当默认值;从没选过为 null */
lastLanguage: lessonLanguageSchema, lastLanguage: assignedLanguageSchema,
problems: z.array(classBoardProblemSchema), problems: z.array(taskBoardProblemSchema),
students: z.array(classBoardStudentSchema), students: z.array(taskBoardStudentSchema),
/** /**
* 「最近几节课」一共几节(最多 5,不含今天)。一节课 = 这个班同学一起做题的一天 * 「最近几节课」一共几节(最多 5,不含今天)。一节课 = 这个班同学一起做题的一天
* (同班同一天 ≥ 5 人做同一道题,和「班里在做」同一个判据)。新班、开学头几周不满 5。 * (同班同一天 ≥ 5 人做同一道题,和「班里在做」同一个判据)。新班、开学头几周不满 5。
@@ -254,7 +181,7 @@ export const classBoardSchema = z.object({
}) })
/** /**
* 提交列表「这节课」那颗按钮要的:哪个班、这节课是哪几道题。和课堂看板同一套取法 * 提交列表「这节课」那颗按钮要的:哪个班、这节课是哪几道题。和课堂任务同一套取法
* (老师布置过就用布置的,否则按今天同班提交推断),但不带花名册 —— 列表只拿它当筛选条件。 * (老师布置过就用布置的,否则按今天同班提交推断),但不带花名册 —— 列表只拿它当筛选条件。
* className 为 null = 没指定、最近两小时也没有哪个班在交。 * className 为 null = 没指定、最近两小时也没有哪个班在交。
*/ */
@@ -265,13 +192,13 @@ export const classLessonSchema = z.object({
* 选了语言时,列表那边连语言一起筛。C / Python 的作业里夹了 SQL 题就是 null: * 选了语言时,列表那边连语言一起筛。C / Python 的作业里夹了 SQL 题就是 null:
* 列表只能筛一种语言,按 C 筛会把 SQL 题交的全藏起来,宁可多列一些 * 列表只能筛一种语言,按 C 筛会把 SQL 题交的全藏起来,宁可多列一些
*/ */
language: lessonLanguageSchema, language: assignedLanguageSchema,
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })), problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
}) })
/** /**
* 老师首页「今天的课」:今天在上课的每个班一行(同班 ≥5 人一起交题,或者老师在看板上布置过)。 * 老师首页「今天的课」:今天在上课的每个班一行(同班 ≥5 人一起交题,或者老师在任务页上布置过)。
* 题目的人数是今天的(和看板同一套取法),「全做对」看的是这几道题有没有做对过,不限今天 * 题目的人数是今天的(和任务页同一套取法),「全做对」看的是这几道题有没有做对过,不限今天
*/ */
export const classTodaySchema = z.object({ export const classTodaySchema = z.object({
classes: z.array( classes: z.array(
@@ -302,7 +229,7 @@ export const classTodaySchema = z.object({
}) })
/** /**
* 课上老师在看板(或后台)改了**今天这节**布置的题,推给这个班还开着页面的学生:告诉他们改了哪些, * 课上老师在任务页(或后台)改了**今天这节**布置的题,推给这个班还开着页面的学生:告诉他们改了哪些,
* 点「刷新」重新载入。只有学生看到的那一节(今天最后布置的)变了才推 —— 改的是今天早些时候的一节、 * 点「刷新」重新载入。只有学生看到的那一节(今天最后布置的)变了才推 —— 改的是今天早些时候的一节、
* 或者改完和原来一样,都不推。走 /ws/config 那条全局常驻的连接。 * 或者改完和原来一样,都不推。走 /ws/config 那条全局常驻的连接。
* *
@@ -315,7 +242,7 @@ export const lessonUpdateProblemSchema = z.object({ problemDisplayId: z.string()
export const lessonUpdateSchema = z.object({ export const lessonUpdateSchema = z.object({
type: z.literal('lesson_update'), type: z.literal('lesson_update'),
kind: z.enum(['assigned', 'changed', 'cleared']), kind: z.enum(['assigned', 'changed', 'cleared']),
language: lessonLanguageSchema, language: assignedLanguageSchema,
/** 清掉了为 null。languageChanged 连它一起算:改成画流程图、改成先画再写也是「做法变了」 */ /** 清掉了为 null。languageChanged 连它一起算:改成画流程图、改成先画再写也是「做法变了」 */
mode: lessonModeSchema.nullable(), mode: lessonModeSchema.nullable(),
problems: z.array(lessonUpdateProblemSchema), problems: z.array(lessonUpdateProblemSchema),
@@ -327,19 +254,16 @@ export const lessonUpdateSchema = z.object({
export type LessonUpdate = z.infer<typeof lessonUpdateSchema> export type LessonUpdate = z.infer<typeof lessonUpdateSchema>
export type ClassActivity = z.infer<typeof classActivitySchema>
export type ClassToday = z.infer<typeof classTodaySchema> export type ClassToday = z.infer<typeof classTodaySchema>
export type LastVisit = z.infer<typeof lastVisitSchema>
export type ClassLessonRequest = z.input<typeof classLessonRequestSchema> export type ClassLessonRequest = z.input<typeof classLessonRequestSchema>
export type ClassBoard = z.infer<typeof classBoardSchema> export type TaskBoard = z.infer<typeof taskBoardSchema>
export type ClassLesson = z.infer<typeof classLessonSchema> export type ClassLesson = z.infer<typeof classLessonSchema>
export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema> export type TaskBoardProblem = z.infer<typeof taskBoardProblemSchema>
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema> export type TaskBoardStudent = z.infer<typeof taskBoardStudentSchema>
export type ClassBoardCell = z.infer<typeof classBoardCellSchema> export type TaskBoardCell = z.infer<typeof taskBoardCellSchema>
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
/** /**
* 后台「课堂作业」:布置过的每一节课一行(class_lesson),新的在前。看板上当场布置的、 * 后台「课堂任务」:布置过的每一节课一行(class_lesson),新的在前。任务页上当场布置的、
* 后台补的都在这里,数据统计按它们切课。 * 后台补的都在这里,数据统计按它们切课。
*/ */
export const adminLessonSchema = z.object({ export const adminLessonSchema = z.object({
@@ -347,18 +271,18 @@ export const adminLessonSchema = z.object({
className: z.string(), className: z.string(),
/** 东八区的日子,`2026-10-08` */ /** 东八区的日子,`2026-10-08` */
day: z.string(), day: z.string(),
/** 布置的时刻(看板上第一次布置那一刻;后台补的是填的时刻) */ /** 布置的时刻(任务页上第一次布置那一刻;后台补的是填的时刻) */
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
/** 布置的老师:真名,没有真名用用户名(和看板「谁 布置」一样) */ /** 布置的老师:真名,没有真名用用户名(和任务页「谁 布置」一样) */
createdBy: z.string(), createdBy: z.string(),
language: lessonLanguageSchema, language: assignedLanguageSchema,
mode: lessonModeSchema, mode: lessonModeSchema,
/** 按布置的顺序;题后来被藏起来的 visible 为 false(统计、学生那边都不再出现) */ /** 按布置的顺序;题后来被藏起来的 visible 为 false(统计、学生那边都不再出现) */
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string(), visible: z.boolean() })), problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string(), visible: z.boolean() })),
/** 班里的学生数(STUDENT_ROLES、没禁用),和 doneCount 同一个花名册 */ /** 班里的学生数(STUDENT_ROLES、没禁用),和 doneCount 同一个花名册 */
classSize: z.number().int(), classSize: z.number().int(),
/** 把布置的题全做对了的人数(那天结束前;口径同看板) */ /** 把布置的题全做对了的人数(那天结束前;口径同任务页) */
doneCount: z.number().int(), doneCount: z.number().int(),
}) })
@@ -370,14 +294,14 @@ export const adminLessonListSchema = z.object({
}) })
/** /**
* 后台新补一节 / 改一节。日子和时刻按东八区填:补的是看板外上的课(没在看板布置、 * 后台新补一节 / 改一节。日子和时刻按东八区填:补的是任务页外上的课(没在任务页布置、
* 以前的课),时刻决定同一天几节课的先后。题目至少一道 —— 删掉一节走 DELETE * 以前的课),时刻决定同一天几节课的先后。题目至少一道 —— 删掉一节走 DELETE
*/ */
export const adminLessonRequestSchema = z.object({ export const adminLessonRequestSchema = z.object({
className: z.string().trim().min(1), className: z.string().trim().min(1),
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
language: lessonLanguageSchema, language: assignedLanguageSchema,
mode: lessonModeSchema.default('code'), mode: lessonModeSchema.default('code'),
problemDisplayIds: z.array(z.string().trim().min(1)).min(1).max(20), problemDisplayIds: z.array(z.string().trim().min(1)).min(1).max(20),
}) })