diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md index a59f6f7..7b2bccd 100644 --- a/apps/api/CLAUDE.md +++ b/apps/api/CLAUDE.md @@ -61,7 +61,7 @@ Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它 **外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION: - **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表 - (problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存 + (problem_tags)、专题/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存 (user_stat)。 - **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大 多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。 diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 919aa8f..120cffc 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -342,12 +342,12 @@ export const problemset = pgTable( createdById: integer('created_by_id').notNull(), /** * 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。 - * 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见 + * 布置期内,专题里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见 * services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。 * 两个都为空 = 没布置。 * * 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、 - * 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字 + * 不一定同时升级,改列名会让没升级的那边一查专题就报错,所以只改代码里的名字 */ assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }), assignedAt: timestamp('assigned_at', { withTimezone: true, mode: 'string' }), @@ -613,9 +613,9 @@ export const submission = pgTable( shared: boolean().default(false).notNull(), statisticInfo: jsonb('statistic_info').default({}).notNull(), username: text().notNull(), - // 来源题单:学生从题单入口(/problemset/:id/problem/:pid)提交时记下来, - // 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它, - // 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。 + // 来源专题:学生从专题入口(/problemset/:id/problem/:pid)提交时记下来, + // 提交列表据此标出「这条来自专题」。**只是来源标记**,专题进度、奖章一概不看它, + // 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有专题」记账。 // 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。 problemsetId: bigint('problemset_id', { mode: 'number' }), }, @@ -745,13 +745,13 @@ export const submission = pgTable( foreignColumns: [problem.id], name: 'submission_problem_id_76847b55_fk_problem_id', }), - // 部分索引:绝大多数提交不来自题单,全列索引等于给 12 万行白建一遍。 - // 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删题单时的外键检查 + // 部分索引:绝大多数提交不来自专题,全列索引等于给 12 万行白建一遍。 + // 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删专题时的外键检查 // 也能用上它——不然那条检查要顺序扫全表。 index('submission_problemset_id_idx') .using('btree', table.problemsetId.asc().nullsLast()) .where(sql`${table.problemsetId} is not null`), - // 删掉题单不该带走提交:置空来源标记就行,提交本身照旧存在。 + // 删掉专题不该带走提交:置空来源标记就行,提交本身照旧存在。 foreignKey({ columns: [table.problemsetId], foreignColumns: [problemset.id], diff --git a/apps/api/src/flowchart/run.ts b/apps/api/src/flowchart/run.ts index abd3544..9aa4e9a 100644 --- a/apps/api/src/flowchart/run.ts +++ b/apps/api/src/flowchart/run.ts @@ -268,9 +268,9 @@ export async function evaluateFlowchart(job: FlowchartJobData, { isFinalAttempt evaluationTime: new Date().toISOString(), }) .where(eq(schema.flowchartSubmission.id, row.flowchart.id)) - // 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的题单 —— 和判题那一路 + // 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的专题 —— 和判题那一路 // 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经 - // 落库,1.5 秒后跳回题单页看到的就是新数据。比赛题不进题单 + // 落库,1.5 秒后跳回专题页看到的就是新数据。比赛题不进专题 if (isFlowchartPass(result.grade) && row.problem.contestId === null) { await recordSolvedAndNotify(row.flowchart.userId, row.problem.id, new Date().toISOString()) } diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index 969b38d..3ef69c4 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -549,11 +549,11 @@ export async function judgeSubmission(job: JudgeJobData) { ) if (!saved) return - // 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress, - // 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。 + // 专题记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress, + // 只认路由参数里那一个专题:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。 // 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时 - // 进度已经落库,跳回题单页看到的就是新数据。 - // 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。 + // 进度已经落库,跳回专题页看到的就是新数据。 + // 比赛题不进专题(专题加题时卡了 contestId IS NULL),跳过。 if (row.submission.contestId === null && isAccepted(result)) { await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime) } diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 81c0b5b..317523e 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -525,7 +525,7 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => { // 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来: // 撞外键说明该用户还有历史数据,应当禁用而不是删除。 // - // 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、 + // 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、专题进度、 // AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE —— // 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。 // 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个 @@ -533,10 +533,10 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => { try { const deleted = await db.transaction(async (tx) => { /** - * 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** —— + * 外键拦得住成就、专题进度、比赛排名这些,**唯独提交拦不住** —— * `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经 * 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票: - * 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 —— + * 只交过题、没拿过成就没进过专题的学生照样删得掉,提交留在库里成了孤儿 —— * 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测: * 28 个已删账号留下 935 条这样的提交。 * diff --git a/apps/api/src/routes/admin/problemset.ts b/apps/api/src/routes/admin/problemset.ts index 57589c9..5cf3fe7 100644 --- a/apps/api/src/routes/admin/problemset.ts +++ b/apps/api/src/routes/admin/problemset.ts @@ -28,7 +28,7 @@ export const adminProblemSetRoutes = new Hono() type BadgeRow = typeof schema.problemsetBadge.$inferSelect /** - * 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 —— + * 取出专题并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 —— * 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。 */ async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) { @@ -124,7 +124,7 @@ function assignment(requested: string | null, current: { assignedAt: string | nu } } -// ---------------------------------------------------------------- 题单本体 +// ---------------------------------------------------------------- 专题本体 adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => { const limit = queryInteger(c.req.query('limit'), 10, { min: 1, max: 250 }) @@ -132,8 +132,8 @@ adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => { const user = c.get('user')! const filters = [] // 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供 - // 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失, - // 再也没法在界面上改回来。后台必须能看见自己管的全部题单。 + // 「切换可见性」的接口 —— 一旦把专题设成不可见,它就从后台列表里消失, + // 再也没法在界面上改回来。后台必须能看见自己管的全部专题。 if (user.adminType !== 'Super Admin') filters.push(eq(schema.problemset.createdById, user.id)) const keyword = c.req.query('keyword')?.trim() if (keyword) { @@ -174,13 +174,13 @@ adminProblemSetRoutes.post('/problem-sets', requireTeacher, async (c) => { adminProblemSetRoutes.get('/problem-sets/:id', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') return success(c, await serialize(row)) }) adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const parsed = await parseBody(c, updateProblemSetRequestSchema) if (!parsed.success) return parsed.response const period = assignment(parsed.data.assignedUntil, row) @@ -201,7 +201,7 @@ adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => { adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') // 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关 const [updated] = await db .update(schema.problemset) @@ -213,18 +213,18 @@ adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async adminProblemSetRoutes.delete('/problem-sets/:id', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') // 子表交给库级 CASCADE(0010):problemset_{badge,problem,progress,submission} 直接连坐, // user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。 await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id)) return success(c, null) }) -// ---------------------------------------------------------------- 题单里的题目 +// ---------------------------------------------------------------- 专题里的题目 adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const rows = await db .select({ item: schema.problemsetProblem, problem: schema.problem }) .from(schema.problemsetProblem) @@ -255,7 +255,7 @@ adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c */ adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const parsed = await parseBody(c, addProblemsToSetRequestSchema) if (!parsed.success) return parsed.response const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))] @@ -298,7 +298,7 @@ adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async ( // 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉 adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema) if (!parsed.success) return parsed.response const links = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id)) @@ -320,7 +320,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, as adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const parsed = await parseBody(c, updateProblemInSetRequestSchema, '参数错误') if (!parsed.success) return parsed.response const updated = await db @@ -328,7 +328,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, .set(parsed.data) .where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id))) .returning({ id: schema.problemsetProblem.id }) - if (updated.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该题单中') + if (updated.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该专题中') // 必做 / 选做变了,分母和「做完」跟着变 await resyncProgress(row.id) return success(c, null) @@ -336,12 +336,12 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, adminProblemSetRoutes.delete('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const deleted = await db .delete(schema.problemsetProblem) .where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id))) .returning({ id: schema.problemsetProblem.id }) - if (deleted.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该题单中') + if (deleted.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该专题中') await resyncProgress(row.id) return success(c, null) }) @@ -383,14 +383,14 @@ async function badgesWithCount(badges: BadgeRow[]) { adminProblemSetRoutes.get('/problem-sets/:id/badges', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id)) return success(c, await badgesWithCount(badges)) }) adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const parsed = await parseBody(c, createProblemSetBadgeRequestSchema) if (!parsed.success) return parsed.response const [created] = await db @@ -407,7 +407,7 @@ adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema) if (!parsed.success) return parsed.response const [updated] = await db @@ -422,11 +422,11 @@ adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, a adminProblemSetRoutes.delete('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => { const row = await loadOwned(c, c.get('user')!) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const badgeId = queryInteger(c.req.param('badgeId'), 0, { min: 1 }) - // 必须先确认这枚奖章确实属于本题单,再动 user_badge。 - // 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单, - // 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录, + // 必须先确认这枚奖章确实属于本专题,再动 user_badge。 + // 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定专题, + // 于是「自己的专题 id + 别人的奖章 id」会真删掉别人的获奖记录, // 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。 const [badge] = await db .select({ id: schema.problemsetBadge.id }) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 8632a5e..14125a1 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -38,7 +38,7 @@ function flowchartThrottleKey(userId: number) { } /** - * 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸 + * 专题防抄:专题布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸 * (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图 * 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。 */ @@ -59,7 +59,7 @@ function canView( return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id } -/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */ +/** 只有学生看自己的提交时才需要查专题;其余情况给一张空表,省一次查询 */ function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) { if (isTeacherOrAbove(user)) return Promise.resolve(new Map()) const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId) @@ -270,7 +270,7 @@ async function flowchartUserFilter(username: string, exact = false) { const flowchartListColumns = { flowchart: { id: schema.flowchartSubmission.id, - // showLink 判定要(本人 + 题单闸门),序列化本身用不到 + // showLink 判定要(本人 + 专题闸门),序列化本身用不到 userId: schema.flowchartSubmission.userId, problemId: schema.flowchartSubmission.problemId, status: schema.flowchartSubmission.status, @@ -711,7 +711,7 @@ flowchartRoutes.post('/flowcharts/:id/retry', requireAuth, async (c) => { }) /** - * 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。 + * 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入专题之前的(专题闸门)。 * 结果页签的历次分数(scores)从这一份出。 * * 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次 @@ -740,7 +740,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) { /** * 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条, - * 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。 + * 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被专题闸门藏起来的次数,界面上给一句说明。 */ flowchartRoutes.get('/problems/:id/flowchart/scores', requireAuth, async (c) => { const problemId = queryInteger(c.req.param('id'), 0, { min: 1 }) diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index d5cf774..40889d3 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -27,7 +27,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] -/** 必做题的 problemId;一道必做都没标的题单退回全部(和 computeProgress 同一个口径) */ +/** 必做题的 problemId;一道必做都没标的专题退回全部(和 computeProgress 同一个口径) */ function gradedIds(links: { problemId: number; isRequired: boolean }[]) { const required = links.filter((link) => link.isRequired) return new Set((required.length ? required : links).map((link) => link.problemId)) @@ -56,7 +56,7 @@ function progressSummary(progress: ProgressRow | undefined) { } /** - * 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生 + * 学生能看到的专题:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生 * 看到的样子、看全班情况,不该被 404 挡在外面。 */ async function loadVisible(id: number, user: AuthUser | null | undefined) { @@ -99,7 +99,7 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime } /** - * 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。 + * 一次把整页专题的附属数据全查回来,再在内存里按 problemsetId 分组。 * 固定 6 条查询,与行数无关(按行查就是 N+1)。 */ async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) { @@ -153,7 +153,7 @@ async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boole assignedUntil: row.assignedUntil, assigning, problemsCount: countBySet.get(row.id)?.value ?? 0, - // 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径 + // 一道必做都没标的专题退回「全部都算必做」,和 computeProgress 同一个口径 requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0), joinedCount: joinedBySet.get(row.id) ?? 0, userProgress: progressSummary(progress), @@ -207,7 +207,7 @@ problemsetRoutes.get('/problem-sets/locks', requireAuth, async (c) => { problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => { const row = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user')) - if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在') const [data] = await serializeProblemSets([row], c.get('user')?.id) return success(c, data) }) @@ -215,10 +215,10 @@ problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => { problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => { const user = c.get('user') const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), user) - if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在') const id = found.set.id // order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标 - // 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变 + // 编号的(1、2、3),老专题里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变 const rows = await db .select({ link: schema.problemsetProblem, @@ -295,14 +295,14 @@ problemsetRoutes.post('/problem-set-progress', requireAuth, async (c) => { if (!parsed.success) return parsed.response const user = c.get('user')! const found = await loadVisible(parsed.data.problemSetId, user) - if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在') const problemSet = found.set const [existing] = await db .select({ id: schema.problemsetProgress.id }) .from(schema.problemsetProgress) .where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))) .limit(1) - if (existing) return failure(c, 409, 'already-joined', '已经加入该题单') + if (existing) return failure(c, 409, 'already-joined', '已经加入该专题') await db.transaction(async (tx) => { const links = await tx .select({ @@ -359,9 +359,9 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => { }) /** - * 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。 + * 老师看一个班在这个专题里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。 * - * 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计 + * 所有老师都能看,不只是专题的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计 * 也是任何老师看任何班,这里和它们一致。 * * 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它, @@ -370,7 +370,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => { */ problemsetRoutes.get('/problem-sets/:id/class-view', requireTeacher, async (c) => { const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user')) - if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在') + if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在') const id = found.set.id const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES]) const [links, joinedClasses] = await Promise.all([ diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 6398171..ad66649 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -101,10 +101,10 @@ submissionRoutes.post('/submissions', requireAuth, async (c) => { return failure(c, 400, 'language-not-allowed', `${parsed.data.language} is not allowed in the problem`) } - // 来源题单:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了 - // 在提交列表里标出「这条是刷题单刷出来的」。校验只确认这道题确实在那个题单里 —— - // 不查 visible / status,因为藏起来的题单里还困着已加入的学生(他们照样在做题), - // 也不查有没有加入:没加入照样能从题单页点进题目,标记来源不该比入口本身更严。 + // 来源专题:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了 + // 在提交列表里标出「这条是刷专题刷出来的」。校验只确认这道题确实在那个专题里 —— + // 不查 visible / status,因为藏起来的专题里还困着已加入的学生(他们照样在做题), + // 也不查有没有加入:没加入照样能从专题页点进题目,标记来源不该比入口本身更严。 // 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。 let problemsetId: number | null = null if (contestId === null && parsed.data.problemSetId) { @@ -241,7 +241,7 @@ function canViewSubmission( problemSetCutoffs?: Map, ) { if (!user) return false - // 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。 + // 专题防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。 // 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。 if (row.userId === user.id && !isTeacherOrAbove(user)) { const cutoff = problemSetCutoffs?.get(row.problemId) @@ -273,7 +273,7 @@ const submissionListColumns = { id: schema.submission.id, createTime: schema.submission.createTime, userId: schema.submission.userId, - // 题单闸门要按题定位,序列化本身用不到它 + // 专题闸门要按题定位,序列化本身用不到它 problemId: schema.submission.problemId, /** * 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的 @@ -287,8 +287,8 @@ const submissionListColumns = { result: schema.submission.result, language: schema.submission.language, statisticInfo: schema.submission.statisticInfo, - // 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来 - // 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜 + // 只取 id,专题标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来 + // 会动到下面那条调过的分页查询,而每页最多两三个不同的专题,PK 查一次更便宜 problemsetId: schema.submission.problemsetId, /** * 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。 @@ -331,7 +331,7 @@ function listCaseSummary(row: { language: string; casesTotal: number | null; cas /** * 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的 - * 代码在题单布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。 + * 代码在专题布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。 */ async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) { const [row] = await db @@ -355,7 +355,7 @@ async function submissionDetail(id: string, user: AuthUser, withAttempts: boolea // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 const cutoffs = isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId]) const visible = canViewSubmission(user, row.submission, row.problem, cutoffs) - // 走到这里还看不了、又是自己的,只剩题单布置期这一种(见 canViewSubmission) + // 走到这里还看不了、又是自己的,只剩专题布置期这一种(见 canViewSubmission) if (!visible && !own) return { status: 'forbidden' } as const // info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端: // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 @@ -489,8 +489,8 @@ async function paginateSubmissionRows(where: SQL | undefined, limit: number, off } /** - * 这一页里出现过的来源题单,id → 标题。传进来的数组允许带 null 和重复值。 - * 一页最多 250 行、实际能落到的题单数是个位数,按主键 IN 查一次就完了。 + * 这一页里出现过的来源专题,id → 标题。传进来的数组允许带 null 和重复值。 + * 一页最多 250 行、实际能落到的专题数是个位数,按主键 IN 查一次就完了。 */ async function problemsetTitleMap(ids: Array) { const unique = [...new Set(ids.filter((id): id is number => id !== null))] @@ -545,7 +545,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => { user && !isTeacherOrAbove(user) ? problemSetLockCutoffs(user.id, [...new Set(rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId))]) : undefined, - // 来源题单的标题。一页里不同题单最多几个,按主键查一次就够 + // 来源专题的标题。一页里不同专题最多几个,按主键查一次就够 problemsetTitleMap(rows.map((row) => row.submission.problemsetId)), ]) return success(c, { @@ -563,7 +563,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => { result: submission.result, language: submission.language, statisticInfo: asRecord(submission.statisticInfo), - // 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 + // 专题被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId) ? { @@ -609,7 +609,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo .offset(offset), displayId ? unknownDisplayIds(displayId, contest.id) : [], ]) - // 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了 + // 这里不挂专题防作弊闸门(对比公开列表):专题里的题必定是非比赛题——加题时卡了 // `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交, // 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。 // 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。 @@ -628,7 +628,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo result: submission.result, language: submission.language, statisticInfo: asRecord(submission.statisticInfo), - // 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 + // 比赛提交没有来源专题:专题只收非比赛题(admin/problemset.ts 加题时卡了 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 problemSet: null, // 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema) diff --git a/apps/api/src/scripts/recount.ts b/apps/api/src/scripts/recount.ts index 7f9d5c2..e34d2f6 100644 --- a/apps/api/src/scripts/recount.ts +++ b/apps/api/src/scripts/recount.ts @@ -25,7 +25,7 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from '../se * * **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info * 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后 - * user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts)。 + * user_achievement 随成就级联,漂不了)、专题进度与奖章(判题这一路自己记账,见 services/problemset.ts)。 * * 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate: * diff --git a/apps/api/src/services/achievement-metrics.ts b/apps/api/src/services/achievement-metrics.ts index db68521..64345f7 100644 --- a/apps/api/src/services/achievement-metrics.ts +++ b/apps/api/src/services/achievement-metrics.ts @@ -53,11 +53,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [ name: '参加比赛', helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)', }, - { key: 'badge_count', name: '题单奖章', helpText: '获得的题单奖章数量' }, + { key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' }, { key: 'problemset_completed', - name: '做完的题单', - helpText: '完成的题单数量', + name: '做完的专题', + helpText: '完成的专题数量', }, { key: 'first_try_ac_count', diff --git a/apps/api/src/services/permissions.ts b/apps/api/src/services/permissions.ts index b4ae690..8bb853c 100644 --- a/apps/api/src/services/permissions.ts +++ b/apps/api/src/services/permissions.ts @@ -4,7 +4,7 @@ import type { AuthUser } from '../auth/session' import { db, schema } from '../db' /** - * 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、题单、 + * 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、专题、 * 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里 * 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。 */ diff --git a/apps/api/src/services/problem-progress.ts b/apps/api/src/services/problem-progress.ts index 19ac2c7..e152271 100644 --- a/apps/api/src/services/problem-progress.ts +++ b/apps/api/src/services/problem-progress.ts @@ -12,7 +12,7 @@ import { cached } from './cache' * 一个人在公开题库里的做题状态:做对了哪些、交过哪些。 * * 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到 - * A / S 也算做对、交过流程图就算交过 —— 和题单、课堂条一个口径(题目页设计文档 + * A / S 也算做对、交过流程图就算交过 —— 和专题、课堂条一个口径(题目页设计文档 * 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。 */ export async function problemStates(userId: number) { diff --git a/apps/api/src/services/problemset.ts b/apps/api/src/services/problemset.ts index b52409d..45fd1b8 100644 --- a/apps/api/src/services/problemset.ts +++ b/apps/api/src/services/problemset.ts @@ -11,7 +11,7 @@ type ProblemLink = { problemId: number; isRequired: boolean } type BadgeCheck = Pick /** - * 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。 + * 专题进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。 * * 以前两边各写一遍,于是各自漂了一段:后台那份不碰 is_completed(加一道题之后分母变大, * 人还标着「已完成」)、不清理 progress_detail(删掉一道题之后没做的题被算成做了)。 @@ -19,7 +19,7 @@ type BadgeCheck = Pick, links: ProblemLink[], previousCompleteTime: string | null, now = new Date().toISOString()) { const inSet = new Set(links.map((link) => String(link.problemId))) - // 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。 + // 已经移出专题的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。 // 老数据的每一项还带着 score(分数已经拿掉了),原样留着无害 const kept: Record = {} for (const [key, value] of Object.entries(detail)) { @@ -27,17 +27,17 @@ export function computeProgress(detail: Record, links: ProblemL } // 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems // 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到 - // (题单 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。 + // (专题 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。 // - // 一道必做都没标的题单退回「全部都算必做」:那种题单多半是没用这个字段,而不是 + // 一道必做都没标的专题退回「全部都算必做」:那种专题多半是没用这个字段,而不是 // 真的整单选做;不兜住的话它永远完不成。 const required = links.filter((link) => link.isRequired) const graded = required.length ? required : links const gradedKeys = new Set(graded.map((link) => String(link.problemId))) const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length const total = graded.length - // total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」, - // 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。 + // total > 0 这个前提不能省:0 === 0 同样成立,没有题目的专题会让人一加入就算「完成」, + // 还会写下 complete_time、计进「完成专题数」成就,而且后面补上题目也不会自愈。 const isCompleted = total > 0 && completed === total return { progressDetail: kept, @@ -46,9 +46,9 @@ export function computeProgress(detail: Record, links: ProblemL isCompleted, // 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。 // - // 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 题单 8 那批人在它 + // 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 专题 8 那批人在它 // 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。 - // 反过来清空的代价是不可逆:往一个 100 人已完成的题单里加一道题、再改主意删掉, + // 反过来清空的代价是不可逆:往一个 100 人已完成的专题里加一道题、再改主意删掉, // 这 100 个人的历史完成时间就一起被冲成了「现在」。 completeTime: previousCompleteTime ?? (isCompleted ? now : null), } @@ -57,7 +57,7 @@ export function computeProgress(detail: Record, links: ProblemL type ProgressWrite = ReturnType & { id: number } /** - * 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的题单就是上百次往返, + * 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的专题就是上百次往返, * 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。 */ async function writeProgress(rows: ProgressWrite[]) { @@ -93,11 +93,11 @@ async function writeProgress(rows: ProgressWrite[]) { } /** - * 奖章达标判定的唯一实现。学生做出一题、后台改题单、补发脚本三处都调它 —— + * 奖章达标判定的唯一实现。学生做出一题、后台改专题、补发脚本三处都调它 —— * 以前是三份各写一遍。 * * problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount - * (自从分母只算必做,那个只数必做题)。老师当初是按题单的总题数设阈值的:题单 5 + * (自从分母只算必做,那个只数必做题)。老师当初是按专题的总题数设阈值的:专题 5 * 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间 * 不可得,76 个已经拿到的人被 recalculateBadge 收回。 */ @@ -144,11 +144,11 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & { } /** - * 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。 + * 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这个专题的奖章。 * * 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了, * 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete - * 上重算全部参与者的进度、再重算该题单全部奖章的资格。重写时 views 里看不到显式调用, + * 上重算全部参与者的进度、再重算该专题全部奖章的资格。重写时 views 里看不到显式调用, * 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。 * * 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补 @@ -177,16 +177,16 @@ export async function resyncProgress(problemsetId: number) { } /** - * 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。 + * 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的专题。 * * 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调 - * 路由参数里那一个题单。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失; + * 路由参数里那一个专题。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失; * 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次 * 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。 * * 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。 * - * 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。 + * 不按 visible / status 过滤:进度是学生自己的记录,老师把专题藏起来不该让它停止累积。 */ export async function recordSolvedProblem(userId: number, problemId: number, solvedAt: string) { const joined = await db @@ -250,7 +250,7 @@ export async function recordSolvedProblem(userId: number, problemId: number, sol } /** - * 记题单进度,并把这一下拿到的徽章、解锁的题单类成就推给学生。判题(judge/run.ts)和 + * 记专题进度,并把这一下拿到的徽章、解锁的专题类成就推给学生。判题(judge/run.ts)和 * 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。 * 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。 */ @@ -289,16 +289,16 @@ export async function recordSolvedAndNotify(userId: number, problemId: number, s } } -/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */ +/** 专题现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */ export const assigningSql = sql`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())` /** - * 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻 + * 专题防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻 * (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts) * 共用这一道闸。 * - * 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前** - * 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的 + * 规则(2026-10 题单重设计,用户拍板):专题**布置期内**,专题里这几道题,学生在**布置开始之前** + * 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习专题:快照里 16% 的 * 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。 * * 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了; @@ -306,11 +306,11 @@ export const assigningSql = sql`(${schema.problemset.assignedAt} is not * 在这一两周里也看不到自己的旧代码,用户认了。 * * 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡: - * - 题单不在布置期(没布置,或者布置期过了) - * - 题单没公开 - * - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key) + * - 专题不在布置期(没布置,或者布置期过了) + * - 专题没公开 + * - 已经在这个专题里做对了这道题(progress_detail 里有这道题的 key) * - * 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。 + * 一道题可能同时落在几个正在布置的专题里,取最晚的布置开始 ——「任一专题要求遮挡就遮挡」。 */ export async function problemSetLockCutoffs(userId: number, problemIds: number[]) { const cutoffs = new Map() @@ -338,7 +338,7 @@ export async function problemSetLockCutoffs(userId: number, problemIds: number[] return cutoffs } -/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */ +/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的专题里 */ export async function problemSetLocksFor(userId: number, problemId: number) { return db .select({ diff --git a/apps/web/src/admin/achievement/list.vue b/apps/web/src/admin/achievement/list.vue index 78a519d..86c0513 100644 --- a/apps/web/src/admin/achievement/list.vue +++ b/apps/web/src/admin/achievement/list.vue @@ -25,7 +25,7 @@ const editing = ref(null) const offline = computed(() => list.value.filter((a) => !a.visible).length) -/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛题单 → 趣味),组内按门槛从低到高 */ +/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛和专题 → 趣味),组内按门槛从低到高 */ const groups = computed(() => { const byMetric = new Map() for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a]) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 18b6e62..02088c4 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -604,7 +604,7 @@ export function updateACMHelperChecked(contest_id: number, rank_id: number, prob } satisfies UpdateAcmHelperRequest) } -// 题单管理 API +// 专题管理 API export function getProblemSetList(offset = 0, limit = 10, keyword = '') { return api.get('admin/problem-sets', { params: { offset, limit, keyword }, @@ -639,7 +639,7 @@ export function toggleProblemSetVisible(id: number) { return api.put(`admin/problem-sets/${id}/visibility`) } -// 题单题目管理 API +// 专题题目管理 API export function getProblemSetProblems(problemSetId: number) { return api.get(`admin/problem-sets/${problemSetId}/problems`) } @@ -667,7 +667,7 @@ export function removeProblemFromSet(problemSetId: number, itemId: number) { return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`) } -// 题单奖章管理 API +// 专题奖章管理 API export function getProblemSetBadges(problemSetId: number) { return api.get(`admin/problem-sets/${problemSetId}/badges`) } diff --git a/apps/web/src/admin/home/index.vue b/apps/web/src/admin/home/index.vue index 31906f8..da78b66 100644 --- a/apps/web/src/admin/home/index.vue +++ b/apps/web/src/admin/home/index.vue @@ -16,7 +16,7 @@ import { getBaseInfo, randomUser10 } from '../api' /** * 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、 * 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、 - * 提交、比赛、题单、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次; + * 提交、比赛、专题、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次; * 最下面是常用的四个入口和「抽一个人」。 * * 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 —— @@ -52,7 +52,7 @@ const siteTiles = computed(() => { { label: '提交', value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` }, { label: '这周做题的人', value: n(site.weekActive), note: '周一起交过题的' }, { label: '比赛', value: n(site.contests), note: '', to: '/admin/contest/list' }, - { label: '题单', value: n(site.problemSets), note: '', to: '/admin/problemset/list' }, + { label: '专题', value: n(site.problemSets), note: '', to: '/admin/problemset/list' }, { label: '自学', value: n(site.tutorials), note: '课', to: '/admin/tutorial/list' }, ] }) diff --git a/apps/web/src/admin/problemset/edit.vue b/apps/web/src/admin/problemset/edit.vue index d3404f4..fd20bf8 100644 --- a/apps/web/src/admin/problemset/edit.vue +++ b/apps/web/src/admin/problemset/edit.vue @@ -24,11 +24,11 @@ import { import BadgeModal from './components/BadgeModal.vue' /** - * 新建 / 编辑题单,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开; + * 新建 / 编辑专题,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开; * 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。 * * 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。 - * 新建时题目先攒在页面上,保存时建好题单再一次加进去;编辑时加、删、排、切选做当场生效。 + * 新建时题目先攒在页面上,保存时建好专题再一次加进去;编辑时加、删、排、切选做当场生效。 */ const props = defineProps<{ problemSetId?: string }>() @@ -196,7 +196,7 @@ function remove(item: Item) { return } dialog.warning({ - title: '从题单里拿掉这道题?', + title: '从专题里拿掉这道题?', content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`, positiveText: '拿掉', negativeText: '不拿', @@ -312,7 +312,7 @@ async function load() { async function submit() { if (!form.title.trim()) { - message.error('题单还没起名字') + message.error('专题还没起名字') return } if (untilMs.value !== null && untilMs.value <= Date.now()) { @@ -346,7 +346,7 @@ async function submit() { } } } - message.success(items.value.length ? `题单建好了,${items.value.length} 道题都加进去了` : '题单建好了') + message.success(items.value.length ? `专题建好了,${items.value.length} 道题都加进去了` : '专题建好了') router.replace({ name: 'admin problemset edit', params: { problemSetId: created.id } }) } catch (err) { message.error(errorMessage(err)) @@ -358,7 +358,7 @@ async function submit() { function removeSet() { const set = current.value! dialog.error({ - title: `删除题单「${set.title}」?`, + title: `删除专题「${set.title}」?`, content: set.participantCount ? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。` : '还没有人加入。删掉找不回来。', @@ -380,9 +380,9 @@ onMounted(load) - +
flowchartStore.evaluatedSeq, diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index fa48c2f..9ac1095 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -80,8 +80,8 @@ const sampleCheck = computed(() => { }) /** - * 通过之后给「这节课的下一题」。比赛有自己的题目列表;从题单入口进来的,通过后 - * 1.5 秒会自动跳回题单(SubmissionEffects),这两种都不给 + * 通过之后给「这节课的下一题」。比赛有自己的题目列表;从专题入口进来的,通过后 + * 1.5 秒会自动跳回专题(SubmissionEffects),这两种都不给 */ const ctx = useProblemPageContext() const showLessonNext = computed(() => !props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext) diff --git a/apps/web/src/oj/problem/composables/challengeTone.ts b/apps/web/src/oj/problem/composables/challengeTone.ts index 70ff58a..0f6efe2 100644 --- a/apps/web/src/oj/problem/composables/challengeTone.ts +++ b/apps/web/src/oj/problem/composables/challengeTone.ts @@ -1,5 +1,5 @@ /** - * 挑战的颜色:紫。课堂条是绿、题单条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。 + * 挑战的颜色:紫。课堂条是绿、专题条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。 * Naive 的主题里没有紫,暗色下换浅一档,深紫放在暗底上看不清 */ export function useChallengeTone() { diff --git a/apps/web/src/oj/problem/composables/draftKey.ts b/apps/web/src/oj/problem/composables/draftKey.ts index e417036..183df03 100644 --- a/apps/web/src/oj/problem/composables/draftKey.ts +++ b/apps/web/src/oj/problem/composables/draftKey.ts @@ -6,8 +6,8 @@ import { useProblemStore } from 'oj/store/problem' * (删草稿)都用这一份 —— 原来键在编辑器里拼、再作为 prop 传给工具栏,手机上「重置」 * 挪进页签行的「⋯」之后就够不着了,再拼一份迟早和编辑器那份对不上。 * - * 题单入口单独一份:原来题库和题单共用 `problem_题号_contest_null_语言`,加入题单之前 - * 在题库里写对的代码,一进题单就摆在编辑器里(设计文档第 11 节 4)。 + * 专题入口单独一份:原来题库和专题共用 `problem_题号_contest_null_语言`,加入专题之前 + * 在题库里写对的代码,一进专题就摆在编辑器里(设计文档第 11 节 4)。 * **键的最后一段必须是语言**,编辑器换语言时靠它判断草稿是不是这门语言的。 */ export function useDraftKey() { diff --git a/apps/web/src/oj/problem/composables/editorMenu.ts b/apps/web/src/oj/problem/composables/editorMenu.ts index 4e6ed19..a87b6d7 100644 --- a/apps/web/src/oj/problem/composables/editorMenu.ts +++ b/apps/web/src/oj/problem/composables/editorMenu.ts @@ -42,7 +42,7 @@ export function useEditorMenu(statisticsInline: Ref) { const options = computed(() => { const items: DropdownOption[] = [] - // 「本题提交」挪到了「我的提交」抽屉的底部;题单入口没有抽屉,给管理员角色留在这里 + // 「本题提交」挪到了「我的提交」抽屉的底部;专题入口没有抽屉,给管理员角色留在这里 if (ctx.value.allSubmissionsInMenu && userStore.isAdminRole) { items.push({ label: '本题提交', key: 'allSubmissions' }) } diff --git a/apps/web/src/oj/problem/composables/problemPageContext.ts b/apps/web/src/oj/problem/composables/problemPageContext.ts index d3c36d0..b60dee7 100644 --- a/apps/web/src/oj/problem/composables/problemPageContext.ts +++ b/apps/web/src/oj/problem/composables/problemPageContext.ts @@ -1,10 +1,10 @@ import type { RouteLocationNormalizedLoaded } from 'vue-router' /** - * 题目页的三种入口:题库(含课堂)、题单、比赛。同一个 detail.vue 挂在三条路由上。 + * 题目页的三种入口:题库(含课堂)、专题、比赛。同一个 detail.vue 挂在三条路由上。 * * 入口之间的差异**只在这张表里判断**。原来散在各个组件里各查各的路由:同一件事 - * 在题单里是「禁用」、在比赛里是「隐藏」,加一处功能就得把每个组件翻一遍 + * 在专题里是「禁用」、在比赛里是「隐藏」,加一处功能就得把每个组件翻一遍 * (见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 8 节)。 */ export type ProblemEntry = 'problem' | 'problemset' | 'contest' @@ -27,13 +27,13 @@ interface EntryRules { /** 通过之后给「这节课的下一题」 */ lessonNext: boolean /** - * 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。题单里通过后 1.5 秒就跳回去, + * 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。专题里通过后 1.5 秒就跳回去, * 比赛提交不判挑战 */ challenges: boolean /** 通过之后 1.5 秒弹强制点评 */ reviewAfterAccepted: boolean - /** 通过之后 1.5 秒回题单 */ + /** 通过之后 1.5 秒回专题 */ backToProblemSet: boolean /** 举手求助 */ help: boolean @@ -45,13 +45,13 @@ interface EntryRules { collab: boolean /** * 工具栏上教师的「课堂统计」(新标签开数据统计页)。题库入口已经并进「统计」页签的老师 - * 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的题单入口还要这个按钮。 + * 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的专题入口还要这个按钮。 * 比赛不给:统计接口按 displayId 在公开题库里找题,比赛题号查到的是另一道公开题,或者 404 */ classStats: boolean /** * 「看这道题所有人的提交」放进「⋯」(只给管理员角色)。它平时在「我的提交」抽屉底部, - * 题单入口没有抽屉,老师就没地方点了 + * 专题入口没有抽屉,老师就没地方点了 */ allSubmissionsInMenu: boolean } @@ -70,15 +70,15 @@ const RULES: Record = { classStats: false, allSubmissionsInMenu: false, }, - // 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案 + // 专题里什么「以前的」都不给看:加入专题之前的提交、统计、点评都能拿来抄答案 problemset: { drawers: [], statusTag: false, - // 点进相似题就离开了题单 + // 点进相似题就离开了专题 similar: false, lessonNext: false, challenges: false, - // 1.5 秒后要跳回题单页,弹了也会被冲掉 + // 1.5 秒后要跳回专题页,弹了也会被冲掉 reviewAfterAccepted: false, backToProblemSet: true, help: true, diff --git a/apps/web/src/oj/problem/detail.vue b/apps/web/src/oj/problem/detail.vue index ff64ef0..9110ba7 100644 --- a/apps/web/src/oj/problem/detail.vue +++ b/apps/web/src/oj/problem/detail.vue @@ -12,7 +12,7 @@ import { useFlowchartStore } from 'oj/store/flowchart' import { useCollabStore } from 'shared/store/collab' import type { RouteLocationNormalized } from 'vue-router' import NotFound from 'shared/components/NotFound.vue' -// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好 +// 判完之后该发生的事(烟花、点评、回专题)。静态引入:它得在第一次判完之前就挂好 import SubmissionEffects from './components/SubmissionEffects.vue' import ResultTabLabel from './components/ResultTabLabel.vue' import ContextBar from './components/ContextBar.vue' @@ -21,8 +21,8 @@ import { DRAWER_TITLE, useProblemPageContext, type ProblemDrawer as DrawerKey } import { useCollabHere, useTeacherCollab } from './composables/teacherCollab' // 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。 -// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版, -// 没有协作、也没有错误标记,于是题单里「求助」老师接不上、「把中文标点都换成英文」 +// 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版, +// 没有协作、也没有错误标记,于是专题里「求助」老师接不上、「把中文标点都换成英文」 // 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节) const loadProblemEditor = () => import('./components/ProblemEditor.vue') const ProblemEditor = defineAsyncComponent(loadProblemEditor) @@ -95,7 +95,7 @@ const leftPane = useTemplateRef('leftPane') const theme = useThemeVars() /** - * 分隔条两头按像素卡,不按比例:左栏窄于 360 时题单条的「下一题」箭头被裁掉、三个抽屉按钮 + * 分隔条两头按像素卡,不按比例:左栏窄于 360 时专题条的「下一题」箭头被裁掉、三个抽屉按钮 * 折成两行;右栏窄于 460 时工具栏收进「⋯」之后也放不下、语言下拉被挤成两行(原来 0.8 在 1280 屏上只给右栏 256) */ const { width: windowWidth } = useWindowSize() @@ -234,12 +234,12 @@ async function init() { } onMounted(init) -/** 题不存在时回哪:比赛里回比赛,题单里回题单,否则回题目列表 */ +/** 题不存在时回哪:比赛里回比赛,专题里回专题,否则回题目列表 */ const backTo = computed(() => contestID ? { to: `/contest/${contestID}`, toLabel: '回到比赛' } : problemSetId - ? { to: `/problemset/${problemSetId}`, toLabel: '回到题单' } + ? { to: `/problemset/${problemSetId}`, toLabel: '回到专题' } : { to: '/problem', toLabel: '去题目列表' }, ) watch(() => problemID, init) diff --git a/apps/web/src/oj/problemset/components/ClassView.vue b/apps/web/src/oj/problemset/components/ClassView.vue index a3e316b..176bf45 100644 --- a/apps/web/src/oj/problemset/components/ClassView.vue +++ b/apps/web/src/oj/problemset/components/ClassView.vue @@ -9,7 +9,7 @@ import { parseTime } from 'utils/functions' import type { ProblemSetClassView } from 'utils/types' /** - * 老师看一个班在题单里的情况(设计稿「题单重设计」老师 · 全班情况):真名 × 每道题, + * 老师看一个班在专题里的情况(设计稿「题单重设计」老师 · 全班情况):真名 × 每道题, * 格子里写几点做对 / 错了几次;紫色角 = 加入之前就做对过(可能是凭记忆默写的)。 * 没加入的人在上面点名。班级默认是服务端推断的(最近在做的那个班),可以换。 * 点格子到提交列表看这个学生这道题的代码。 @@ -70,7 +70,7 @@ const error = computed(() => tone('error'))
还没有学生加入这个题单
+
还没有学生加入这个专题