style(专题): 「题单」改名「专题」,顶栏、列表、详情、后台、成就说法统一
Deploy / deploy (push) Canceled after 0s

只改界面文字和注释:网址 /problemset、接口、表名、字段名都不动,老链接照常能打开。
设计稿页名「题单重设计」和已执行的迁移文件保留原样。
线上 3 个成就(题单收藏家 / 入门 / 通关)已同步改名。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-03 00:12:18 +08:00
1 parent 959c326bc9
commit 1b703d13ae
68 files changed
+306 -306

No files matched your search

+1 -1
View File
@@ -61,7 +61,7 @@ Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它
**外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION: **外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION:
- **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表 - **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表
(problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存 (problem_tags)、专题/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存
(user_stat)。 (user_stat)。
- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大 - **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大
多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。 多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
+8 -8
View File
@@ -342,12 +342,12 @@ export const problemset = pgTable(
createdById: integer('created_by_id').notNull(), createdById: integer('created_by_id').notNull(),
/** /**
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。 * 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见 * 布置期内,专题里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。 * services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
* 两个都为空 = 没布置。 * 两个都为空 = 没布置。
* *
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、 * 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字 * 不一定同时升级,改列名会让没升级的那边一查专题就报错,所以只改代码里的名字
*/ */
assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }), assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }),
assignedAt: timestamp('assigned_at', { 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(), shared: boolean().default(false).notNull(),
statisticInfo: jsonb('statistic_info').default({}).notNull(), statisticInfo: jsonb('statistic_info').default({}).notNull(),
username: text().notNull(), username: text().notNull(),
// 来源题单:学生从题单入口(/problemset/:id/problem/:pid)提交时记下来, // 来源专题:学生从专题入口(/problemset/:id/problem/:pid)提交时记下来,
// 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它, // 提交列表据此标出「这条来自专题」。**只是来源标记**,专题进度、奖章一概不看它,
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。 // 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有专题」记账。
// 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。 // 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。
problemsetId: bigint('problemset_id', { mode: 'number' }), problemsetId: bigint('problemset_id', { mode: 'number' }),
}, },
@@ -745,13 +745,13 @@ export const submission = pgTable(
foreignColumns: [problem.id], foreignColumns: [problem.id],
name: 'submission_problem_id_76847b55_fk_problem_id', name: 'submission_problem_id_76847b55_fk_problem_id',
}), }),
// 部分索引:绝大多数提交不来自题单,全列索引等于给 12 万行白建一遍。 // 部分索引:绝大多数提交不来自专题,全列索引等于给 12 万行白建一遍。
// 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删题单时的外键检查 // 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删专题时的外键检查
// 也能用上它——不然那条检查要顺序扫全表。 // 也能用上它——不然那条检查要顺序扫全表。
index('submission_problemset_id_idx') index('submission_problemset_id_idx')
.using('btree', table.problemsetId.asc().nullsLast()) .using('btree', table.problemsetId.asc().nullsLast())
.where(sql`${table.problemsetId} is not null`), .where(sql`${table.problemsetId} is not null`),
// 删掉题单不该带走提交:置空来源标记就行,提交本身照旧存在。 // 删掉专题不该带走提交:置空来源标记就行,提交本身照旧存在。
foreignKey({ foreignKey({
columns: [table.problemsetId], columns: [table.problemsetId],
foreignColumns: [problemset.id], foreignColumns: [problemset.id],
+2 -2
View File
@@ -268,9 +268,9 @@ export async function evaluateFlowchart(job: FlowchartJobData, { isFinalAttempt
evaluationTime: new Date().toISOString(), evaluationTime: new Date().toISOString(),
}) })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id)) .where(eq(schema.flowchartSubmission.id, row.flowchart.id))
// 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的题单 —— 和判题那一路 // 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的专题 —— 和判题那一路
// 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经 // 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经
// 落库,1.5 秒后跳回题单页看到的就是新数据。比赛题不进题单 // 落库,1.5 秒后跳回专题页看到的就是新数据。比赛题不进专题
if (isFlowchartPass(result.grade) && row.problem.contestId === null) { if (isFlowchartPass(result.grade) && row.problem.contestId === null) {
await recordSolvedAndNotify(row.flowchart.userId, row.problem.id, new Date().toISOString()) await recordSolvedAndNotify(row.flowchart.userId, row.problem.id, new Date().toISOString())
} }
+4 -4
View File
@@ -549,11 +549,11 @@ export async function judgeSubmission(job: JudgeJobData) {
) )
if (!saved) return if (!saved) return
// 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress, // 专题记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
// 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。 // 只认路由参数里那一个专题:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时 // 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
// 进度已经落库,跳回题单页看到的就是新数据。 // 进度已经落库,跳回专题页看到的就是新数据。
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。 // 比赛题不进专题(专题加题时卡了 contestId IS NULL),跳过。
if (row.submission.contestId === null && isAccepted(result)) { if (row.submission.contestId === null && isAccepted(result)) {
await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime) await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime)
} }
+3 -3
View File
@@ -525,7 +525,7 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => {
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来: // 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。 // 撞外键说明该用户还有历史数据,应当禁用而不是删除。
// //
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、 // 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、专题进度、
// AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE —— // AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE ——
// 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。 // 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。
// 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个 // 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个
@@ -533,10 +533,10 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => {
try { try {
const deleted = await db.transaction(async (tx) => { const deleted = await db.transaction(async (tx) => {
/** /**
* 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** —— * 外键拦得住成就、专题进度、比赛排名这些,**唯独提交拦不住** ——
* `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经 * `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经
* 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票: * 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票:
* 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 —— * 只交过题、没拿过成就没进过专题的学生照样删得掉,提交留在库里成了孤儿 ——
* 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测: * 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测:
* 28 个已删账号留下 935 条这样的提交。 * 28 个已删账号留下 935 条这样的提交。
* *
+23 -23
View File
@@ -28,7 +28,7 @@ export const adminProblemSetRoutes = new Hono<AppEnv>()
type BadgeRow = typeof schema.problemsetBadge.$inferSelect type BadgeRow = typeof schema.problemsetBadge.$inferSelect
/** /**
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 —— * 取出专题并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。 * 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
*/ */
async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) { 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) => { adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => {
const limit = queryInteger(c.req.query('limit'), 10, { min: 1, max: 250 }) 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 user = c.get('user')!
const filters = [] const filters = []
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供 // 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
// 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失, // 「切换可见性」的接口 —— 一旦把专题设成不可见,它就从后台列表里消失,
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。 // 再也没法在界面上改回来。后台必须能看见自己管的全部专题。
if (user.adminType !== 'Super Admin') filters.push(eq(schema.problemset.createdById, user.id)) if (user.adminType !== 'Super Admin') filters.push(eq(schema.problemset.createdById, user.id))
const keyword = c.req.query('keyword')?.trim() const keyword = c.req.query('keyword')?.trim()
if (keyword) { if (keyword) {
@@ -174,13 +174,13 @@ adminProblemSetRoutes.post('/problem-sets', requireTeacher, async (c) => {
adminProblemSetRoutes.get('/problem-sets/:id', requireTeacher, async (c) => { adminProblemSetRoutes.get('/problem-sets/:id', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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)) return success(c, await serialize(row))
}) })
adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => { adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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) const parsed = await parseBody(c, updateProblemSetRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const period = assignment(parsed.data.assignedUntil, row) 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) => { adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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 不传目标值。保持不变:前端按钮就是个开关 // 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
const [updated] = await db const [updated] = await db
.update(schema.problemset) .update(schema.problemset)
@@ -213,18 +213,18 @@ adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async
adminProblemSetRoutes.delete('/problem-sets/:id', requireTeacher, async (c) => { adminProblemSetRoutes.delete('/problem-sets/:id', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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} 直接连坐, // 子表交给库级 CASCADE(0010):problemset_{badge,problem,progress,submission} 直接连坐,
// user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。 // user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。
await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id)) await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id))
return success(c, null) return success(c, null)
}) })
// ---------------------------------------------------------------- 题单里的题目 // ---------------------------------------------------------------- 专题里的题目
adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c) => { adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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 const rows = await db
.select({ item: schema.problemsetProblem, problem: schema.problem }) .select({ item: schema.problemsetProblem, problem: schema.problem })
.from(schema.problemsetProblem) .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) => { adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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) const parsed = await parseBody(c, addProblemsToSetRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))] 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 吃掉 // 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉
adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, async (c) => { adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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) const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema)
if (!parsed.success) return parsed.response 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)) 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) => { adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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, '参数错误') const parsed = await parseBody(c, updateProblemInSetRequestSchema, '参数错误')
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const updated = await db const updated = await db
@@ -328,7 +328,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher,
.set(parsed.data) .set(parsed.data)
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id))) .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 }) .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) await resyncProgress(row.id)
return success(c, null) 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) => { adminProblemSetRoutes.delete('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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 const deleted = await db
.delete(schema.problemsetProblem) .delete(schema.problemsetProblem)
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id))) .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 }) .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) await resyncProgress(row.id)
return success(c, null) return success(c, null)
}) })
@@ -383,14 +383,14 @@ async function badgesWithCount(badges: BadgeRow[]) {
adminProblemSetRoutes.get('/problem-sets/:id/badges', requireTeacher, async (c) => { adminProblemSetRoutes.get('/problem-sets/:id/badges', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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)) 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)) return success(c, await badgesWithCount(badges))
}) })
adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) => { adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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) const parsed = await parseBody(c, createProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const [created] = await db 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) => { adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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) const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const [updated] = await db 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) => { adminProblemSetRoutes.delete('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
const row = await loadOwned(c, c.get('user')!) 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 }) const badgeId = queryInteger(c.req.param('badgeId'), 0, { min: 1 })
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。 // 必须先确认这枚奖章确实属于本专题,再动 user_badge。
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单, // 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定专题,
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录, // 于是「自己的专题 id + 别人的奖章 id」会真删掉别人的获奖记录,
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。 // 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
const [badge] = await db const [badge] = await db
.select({ id: schema.problemsetBadge.id }) .select({ id: schema.problemsetBadge.id })
+5 -5
View File
@@ -38,7 +38,7 @@ function flowchartThrottleKey(userId: number) {
} }
/** /**
* 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸 * 专题防抄:专题布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图 * (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
* 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。 * 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
*/ */
@@ -59,7 +59,7 @@ function canView(
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
} }
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */ /** 只有学生看自己的提交时才需要查专题;其余情况给一张空表,省一次查询 */
function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) { function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>()) if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId) 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 = { const flowchartListColumns = {
flowchart: { flowchart: {
id: schema.flowchartSubmission.id, id: schema.flowchartSubmission.id,
// showLink 判定要(本人 + 题单闸门),序列化本身用不到 // showLink 判定要(本人 + 专题闸门),序列化本身用不到
userId: schema.flowchartSubmission.userId, userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId, problemId: schema.flowchartSubmission.problemId,
status: schema.flowchartSubmission.status, status: schema.flowchartSubmission.status,
@@ -711,7 +711,7 @@ flowchartRoutes.post('/flowcharts/:id/retry', requireAuth, async (c) => {
}) })
/** /**
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。 * 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入专题之前的(专题闸门)。
* 结果页签的历次分数(scores)从这一份出。 * 结果页签的历次分数(scores)从这一份出。
* *
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次 * 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
@@ -740,7 +740,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
/** /**
* 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条, * 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条,
* 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。 * 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被专题闸门藏起来的次数,界面上给一句说明。
*/ */
flowchartRoutes.get('/problems/:id/flowchart/scores', requireAuth, async (c) => { flowchartRoutes.get('/problems/:id/flowchart/scores', requireAuth, async (c) => {
const problemId = queryInteger(c.req.param('id'), 0, { min: 1 }) const problemId = queryInteger(c.req.param('id'), 0, { min: 1 })
+12 -12
View File
@@ -27,7 +27,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 必做题的 problemId;一道必做都没标的题单退回全部(和 computeProgress 同一个口径) */ /** 必做题的 problemId;一道必做都没标的专题退回全部(和 computeProgress 同一个口径) */
function gradedIds(links: { problemId: number; isRequired: boolean }[]) { function gradedIds(links: { problemId: number; isRequired: boolean }[]) {
const required = links.filter((link) => link.isRequired) const required = links.filter((link) => link.isRequired)
return new Set((required.length ? required : links).map((link) => link.problemId)) return new Set((required.length ? required : links).map((link) => link.problemId))
@@ -56,7 +56,7 @@ function progressSummary(progress: ProgressRow | undefined) {
} }
/** /**
* 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生 * 学生能看到的专题:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
* 看到的样子、看全班情况,不该被 404 挡在外面。 * 看到的样子、看全班情况,不该被 404 挡在外面。
*/ */
async function loadVisible(id: number, user: AuthUser | null | undefined) { 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)。 * 固定 6 条查询,与行数无关(按行查就是 N+1)。
*/ */
async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) { 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, assignedUntil: row.assignedUntil,
assigning, assigning,
problemsCount: countBySet.get(row.id)?.value ?? 0, problemsCount: countBySet.get(row.id)?.value ?? 0,
// 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径 // 一道必做都没标的专题退回「全部都算必做」,和 computeProgress 同一个口径
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0), requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
joinedCount: joinedBySet.get(row.id) ?? 0, joinedCount: joinedBySet.get(row.id) ?? 0,
userProgress: progressSummary(progress), userProgress: progressSummary(progress),
@@ -207,7 +207,7 @@ problemsetRoutes.get('/problem-sets/locks', requireAuth, async (c) => {
problemsetRoutes.get('/problem-sets/:id', optionalAuth, 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')) 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) const [data] = await serializeProblemSets([row], c.get('user')?.id)
return success(c, data) 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) => { problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => {
const user = c.get('user') const user = c.get('user')
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), 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 const id = found.set.id
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标 // order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
// 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变 // 编号的(1、2、3),老专题里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
const rows = await db const rows = await db
.select({ .select({
link: schema.problemsetProblem, link: schema.problemsetProblem,
@@ -295,14 +295,14 @@ problemsetRoutes.post('/problem-set-progress', requireAuth, async (c) => {
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const user = c.get('user')! const user = c.get('user')!
const found = await loadVisible(parsed.data.problemSetId, 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 problemSet = found.set
const [existing] = await db const [existing] = await db
.select({ id: schema.problemsetProgress.id }) .select({ id: schema.problemsetProgress.id })
.from(schema.problemsetProgress) .from(schema.problemsetProgress)
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))) .where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id)))
.limit(1) .limit(1)
if (existing) return failure(c, 409, 'already-joined', '已经加入该题单') if (existing) return failure(c, 409, 'already-joined', '已经加入该专题')
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const links = await tx const links = await tx
.select({ .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) => { 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')) 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 id = found.set.id
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES]) const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
const [links, joinedClasses] = await Promise.all([ const [links, joinedClasses] = await Promise.all([
+16 -16
View File
@@ -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`) return failure(c, 400, 'language-not-allowed', `${parsed.data.language} is not allowed in the problem`)
} }
// 来源题单:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了 // 来源专题:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了
// 在提交列表里标出「这条是刷题单刷出来的」。校验只确认这道题确实在那个题单里 —— // 在提交列表里标出「这条是刷专题刷出来的」。校验只确认这道题确实在那个专题里 ——
// 不查 visible / status,因为藏起来的题单里还困着已加入的学生(他们照样在做题), // 不查 visible / status,因为藏起来的专题里还困着已加入的学生(他们照样在做题),
// 也不查有没有加入:没加入照样能从题单页点进题目,标记来源不该比入口本身更严。 // 也不查有没有加入:没加入照样能从专题页点进题目,标记来源不该比入口本身更严。
// 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。 // 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。
let problemsetId: number | null = null let problemsetId: number | null = null
if (contestId === null && parsed.data.problemSetId) { if (contestId === null && parsed.data.problemSetId) {
@@ -241,7 +241,7 @@ function canViewSubmission(
problemSetCutoffs?: Map<number, string>, problemSetCutoffs?: Map<number, string>,
) { ) {
if (!user) return false if (!user) return false
// 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。 // 专题防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。 // 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
if (row.userId === user.id && !isTeacherOrAbove(user)) { if (row.userId === user.id && !isTeacherOrAbove(user)) {
const cutoff = problemSetCutoffs?.get(row.problemId) const cutoff = problemSetCutoffs?.get(row.problemId)
@@ -273,7 +273,7 @@ const submissionListColumns = {
id: schema.submission.id, id: schema.submission.id,
createTime: schema.submission.createTime, createTime: schema.submission.createTime,
userId: schema.submission.userId, userId: schema.submission.userId,
// 题单闸门要按题定位,序列化本身用不到它 // 专题闸门要按题定位,序列化本身用不到它
problemId: schema.submission.problemId, problemId: schema.submission.problemId,
/** /**
* 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的 * 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的
@@ -287,8 +287,8 @@ const submissionListColumns = {
result: schema.submission.result, result: schema.submission.result,
language: schema.submission.language, language: schema.submission.language,
statisticInfo: schema.submission.statisticInfo, statisticInfo: schema.submission.statisticInfo,
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来 // 只取 id,专题标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜 // 会动到下面那条调过的分页查询,而每页最多两三个不同的专题,PK 查一次更便宜
problemsetId: schema.submission.problemsetId, problemsetId: schema.submission.problemsetId,
/** /**
* 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。 * 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。
@@ -331,7 +331,7 @@ function listCaseSummary(row: { language: string; casesTotal: number | null; cas
/** /**
* 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的 * 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的
* 代码在题单布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。 * 代码在专题布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。
*/ */
async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) { async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) {
const [row] = await db const [row] = await db
@@ -355,7 +355,7 @@ async function submissionDetail(id: string, user: AuthUser, withAttempts: boolea
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
const cutoffs = isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId]) const cutoffs = isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId])
const visible = canViewSubmission(user, row.submission, row.problem, cutoffs) const visible = canViewSubmission(user, row.submission, row.problem, cutoffs)
// 走到这里还看不了、又是自己的,只剩题单布置期这一种(见 canViewSubmission) // 走到这里还看不了、又是自己的,只剩专题布置期这一种(见 canViewSubmission)
if (!visible && !own) return { status: 'forbidden' } as const if (!visible && !own) return { status: 'forbidden' } as const
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端: // info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
@@ -489,8 +489,8 @@ async function paginateSubmissionRows(where: SQL | undefined, limit: number, off
} }
/** /**
* 这一页里出现过的来源题单,id → 标题。传进来的数组允许带 null 和重复值。 * 这一页里出现过的来源专题,id → 标题。传进来的数组允许带 null 和重复值。
* 一页最多 250 行、实际能落到的题单数是个位数,按主键 IN 查一次就完了。 * 一页最多 250 行、实际能落到的专题数是个位数,按主键 IN 查一次就完了。
*/ */
async function problemsetTitleMap(ids: Array<number | null>) { async function problemsetTitleMap(ids: Array<number | null>) {
const unique = [...new Set(ids.filter((id): id is number => id !== null))] 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) user && !isTeacherOrAbove(user)
? problemSetLockCutoffs(user.id, [...new Set(rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId))]) ? problemSetLockCutoffs(user.id, [...new Set(rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId))])
: undefined, : undefined,
// 来源题单的标题。一页里不同题单最多几个,按主键查一次就够 // 来源专题的标题。一页里不同专题最多几个,按主键查一次就够
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)), problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
]) ])
return success(c, { return success(c, {
@@ -563,7 +563,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => {
result: submission.result, result: submission.result,
language: submission.language, language: submission.language,
statisticInfo: asRecord(submission.statisticInfo), statisticInfo: asRecord(submission.statisticInfo),
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 // 专题被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
problemSet: problemSet:
submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId) submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
? { ? {
@@ -609,7 +609,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo
.offset(offset), .offset(offset),
displayId ? unknownDisplayIds(displayId, contest.id) : [], displayId ? unknownDisplayIds(displayId, contest.id) : [],
]) ])
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了 // 这里不挂专题防作弊闸门(对比公开列表):专题里的题必定是非比赛题——加题时卡了
// `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交, // `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交,
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。 // 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。 // 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。
@@ -628,7 +628,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo
result: submission.result, result: submission.result,
language: submission.language, language: submission.language,
statisticInfo: asRecord(submission.statisticInfo), statisticInfo: asRecord(submission.statisticInfo),
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // 比赛提交没有来源专题:专题只收非比赛题(admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null, problemSet: null,
// 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema) // 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema)
+1 -1
View File
@@ -25,7 +25,7 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from '../se
* *
* **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info * **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info
* 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后 * 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后
* user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts)。 * user_achievement 随成就级联,漂不了)、专题进度与奖章(判题这一路自己记账,见 services/problemset.ts)。
* *
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate: * 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate:
* *
+3 -3
View File
@@ -53,11 +53,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
name: '参加比赛', name: '参加比赛',
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)', helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)',
}, },
{ key: 'badge_count', name: '题单奖章', helpText: '获得的题单奖章数量' }, { key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
{ {
key: 'problemset_completed', key: 'problemset_completed',
name: '做完的题单', name: '做完的专题',
helpText: '完成的题单数量', helpText: '完成的专题数量',
}, },
{ {
key: 'first_try_ac_count', key: 'first_try_ac_count',
+1 -1
View File
@@ -4,7 +4,7 @@ import type { AuthUser } from '../auth/session'
import { db, schema } from '../db' import { db, schema } from '../db'
/** /**
* 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、题单、 * 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、专题、
* 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里 * 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里
* 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。 * 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@ import { cached } from './cache'
* 一个人在公开题库里的做题状态:做对了哪些、交过哪些。 * 一个人在公开题库里的做题状态:做对了哪些、交过哪些。
* *
* 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到 * 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到
* A / S 也算做对、交过流程图就算交过 —— 和题单、课堂条一个口径(题目页设计文档 * A / S 也算做对、交过流程图就算交过 —— 和专题、课堂条一个口径(题目页设计文档
* 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。 * 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。
*/ */
export async function problemStates(userId: number) { export async function problemStates(userId: number) {
+26 -26
View File
@@ -11,7 +11,7 @@ type ProblemLink = { problemId: number; isRequired: boolean }
type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCount' | 'progressDetail'> type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCount' | 'progressDetail'>
/** /**
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。 * 专题进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
* *
* 以前两边各写一遍,于是各自漂了一段:后台那份不碰 is_completed(加一道题之后分母变大, * 以前两边各写一遍,于是各自漂了一段:后台那份不碰 is_completed(加一道题之后分母变大,
* 人还标着「已完成」)、不清理 progress_detail(删掉一道题之后没做的题被算成做了)。 * 人还标着「已完成」)、不清理 progress_detail(删掉一道题之后没做的题被算成做了)。
@@ -19,7 +19,7 @@ type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCou
*/ */
export function computeProgress(detail: Record<string, unknown>, links: ProblemLink[], previousCompleteTime: string | null, now = new Date().toISOString()) { export function computeProgress(detail: Record<string, unknown>, links: ProblemLink[], previousCompleteTime: string | null, now = new Date().toISOString()) {
const inSet = new Set(links.map((link) => String(link.problemId))) const inSet = new Set(links.map((link) => String(link.problemId)))
// 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。 // 已经移出专题的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。
// 老数据的每一项还带着 score(分数已经拿掉了),原样留着无害 // 老数据的每一项还带着 score(分数已经拿掉了),原样留着无害
const kept: Record<string, unknown> = {} const kept: Record<string, unknown> = {}
for (const [key, value] of Object.entries(detail)) { for (const [key, value] of Object.entries(detail)) {
@@ -27,17 +27,17 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
} }
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems // 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到 // 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
// (题单 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。 // (专题 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。
// //
// 一道必做都没标的题单退回「全部都算必做」:那种题单多半是没用这个字段,而不是 // 一道必做都没标的专题退回「全部都算必做」:那种专题多半是没用这个字段,而不是
// 真的整单选做;不兜住的话它永远完不成。 // 真的整单选做;不兜住的话它永远完不成。
const required = links.filter((link) => link.isRequired) const required = links.filter((link) => link.isRequired)
const graded = required.length ? required : links const graded = required.length ? required : links
const gradedKeys = new Set(graded.map((link) => String(link.problemId))) const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
const total = graded.length const total = graded.length
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」, // total > 0 这个前提不能省:0 === 0 同样成立,没有题目的专题会让人一加入就算「完成」,
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。 // 还会写下 complete_time、计进「完成专题数」成就,而且后面补上题目也不会自愈。
const isCompleted = total > 0 && completed === total const isCompleted = total > 0 && completed === total
return { return {
progressDetail: kept, progressDetail: kept,
@@ -46,9 +46,9 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
isCompleted, isCompleted,
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。 // 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
// //
// 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 题单 8 那批人在它 // 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 专题 8 那批人在它
// 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。 // 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。
// 反过来清空的代价是不可逆:往一个 100 人已完成的题单里加一道题、再改主意删掉, // 反过来清空的代价是不可逆:往一个 100 人已完成的专题里加一道题、再改主意删掉,
// 这 100 个人的历史完成时间就一起被冲成了「现在」。 // 这 100 个人的历史完成时间就一起被冲成了「现在」。
completeTime: previousCompleteTime ?? (isCompleted ? now : null), completeTime: previousCompleteTime ?? (isCompleted ? now : null),
} }
@@ -57,7 +57,7 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
type ProgressWrite = ReturnType<typeof computeProgress> & { id: number } type ProgressWrite = ReturnType<typeof computeProgress> & { id: number }
/** /**
* 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的题单就是上百次往返, * 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的专题就是上百次往返,
* 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。 * 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。
*/ */
async function writeProgress(rows: ProgressWrite[]) { async function writeProgress(rows: ProgressWrite[]) {
@@ -93,11 +93,11 @@ async function writeProgress(rows: ProgressWrite[]) {
} }
/** /**
* 奖章达标判定的唯一实现。学生做出一题、后台改题单、补发脚本三处都调它 —— * 奖章达标判定的唯一实现。学生做出一题、后台改专题、补发脚本三处都调它 ——
* 以前是三份各写一遍。 * 以前是三份各写一遍。
* *
* problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount * problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount
* (自从分母只算必做,那个只数必做题)。老师当初是按题单的总题数设阈值的:题单 5 * (自从分母只算必做,那个只数必做题)。老师当初是按专题的总题数设阈值的:专题 5
* 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间 * 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间
* 不可得,76 个已经拿到的人被 recalculateBadge 收回。 * 不可得,76 个已经拿到的人被 recalculateBadge 收回。
*/ */
@@ -144,11 +144,11 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
} }
/** /**
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。 * 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这个专题的奖章。
* *
* 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了, * 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了,
* 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete * 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete
* 上重算全部参与者的进度、再重算该题单全部奖章的资格。重写时 views 里看不到显式调用, * 上重算全部参与者的进度、再重算该专题全部奖章的资格。重写时 views 里看不到显式调用,
* 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。 * 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。
* *
* 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补 * 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补
@@ -177,16 +177,16 @@ export async function resyncProgress(problemsetId: number) {
} }
/** /**
* 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。 * 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的专题。
* *
* 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调 * 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调
* 路由参数里那一个题单。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失; * 路由参数里那一个专题。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失;
* 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次 * 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次
* 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。 * 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。
* *
* 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。 * 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。
* *
* 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。 * 不按 visible / status 过滤:进度是学生自己的记录,老师把专题藏起来不该让它停止累积。
*/ */
export async function recordSolvedProblem(userId: number, problemId: number, solvedAt: string) { export async function recordSolvedProblem(userId: number, problemId: number, solvedAt: string) {
const joined = await db 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)两条路都在「这道题做完了」的那一刻调它。 * 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。
* 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。 * 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。
*/ */
@@ -289,16 +289,16 @@ export async function recordSolvedAndNotify(userId: number, problemId: number, s
} }
} }
/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */ /** 专题现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())` export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())`
/** /**
* 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻 * 专题防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
* (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts) * (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)
* 共用这一道闸。 * 共用这一道闸。
* *
* 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前** * 规则(2026-10 题单重设计,用户拍板):专题**布置期内**,专题里这几道题,学生在**布置开始之前**
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的 * 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习专题:快照里 16% 的
* 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。 * 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
* *
* 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了; * 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
@@ -306,11 +306,11 @@ export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not
* 在这一两周里也看不到自己的旧代码,用户认了。 * 在这一两周里也看不到自己的旧代码,用户认了。
* *
* 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡: * 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
* - 题单不在布置期(没布置,或者布置期过了) * - 专题不在布置期(没布置,或者布置期过了)
* - 题单没公开 * - 专题没公开
* - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key) * - 已经在这个专题里做对了这道题(progress_detail 里有这道题的 key)
* *
* 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。 * 一道题可能同时落在几个正在布置的专题里,取最晚的布置开始 ——「任一专题要求遮挡就遮挡」。
*/ */
export async function problemSetLockCutoffs(userId: number, problemIds: number[]) { export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
const cutoffs = new Map<number, string>() const cutoffs = new Map<number, string>()
@@ -338,7 +338,7 @@ export async function problemSetLockCutoffs(userId: number, problemIds: number[]
return cutoffs return cutoffs
} }
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */ /** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的专题里 */
export async function problemSetLocksFor(userId: number, problemId: number) { export async function problemSetLocksFor(userId: number, problemId: number) {
return db return db
.select({ .select({
+1 -1
View File
@@ -25,7 +25,7 @@ const editing = ref<AdminAchievement | null>(null)
const offline = computed(() => list.value.filter((a) => !a.visible).length) const offline = computed(() => list.value.filter((a) => !a.visible).length)
/** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛题单 → 趣味),组内按门槛从低到高 */ /** 按指标分组,指标按后端给的顺序(做题 → 坚持 → 比赛和专题 → 趣味),组内按门槛从低到高 */
const groups = computed(() => { const groups = computed(() => {
const byMetric = new Map<string, AdminAchievement[]>() const byMetric = new Map<string, AdminAchievement[]>()
for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a]) for (const a of list.value) byMetric.set(a.metric, [...(byMetric.get(a.metric) ?? []), a])
+3 -3
View File
@@ -604,7 +604,7 @@ export function updateACMHelperChecked(contest_id: number, rank_id: number, prob
} satisfies UpdateAcmHelperRequest) } satisfies UpdateAcmHelperRequest)
} }
// 题单管理 API // 专题管理 API
export function getProblemSetList(offset = 0, limit = 10, keyword = '') { export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
return api.get<AdminProblemSetList>('admin/problem-sets', { return api.get<AdminProblemSetList>('admin/problem-sets', {
params: { offset, limit, keyword }, params: { offset, limit, keyword },
@@ -639,7 +639,7 @@ export function toggleProblemSetVisible(id: number) {
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`) return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
} }
// 题单题目管理 API // 专题题目管理 API
export function getProblemSetProblems(problemSetId: number) { export function getProblemSetProblems(problemSetId: number) {
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`) return api.get<AdminProblemSetProblem[]>(`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}`) return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`)
} }
// 题单奖章管理 API // 专题奖章管理 API
export function getProblemSetBadges(problemSetId: number) { export function getProblemSetBadges(problemSetId: number) {
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`) return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
} }
+2 -2
View File
@@ -16,7 +16,7 @@ import { getBaseInfo, randomUser10 } from '../api'
/** /**
* 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、 * 后台首页(设计稿「后台首页」AdminHome):一块看板。上面一排看「现在」—— 判题机在不在、
* 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、 * 今天交了多少其中有没有系统错误、现在几个人在线、哪个班在上课;中间一排全站的数(用户、题目、
* 提交、比赛、题单、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次; * 提交、比赛、专题、自学、这周做题的人,用户要的「类似一个看板」)和最近 30 天每天交了几次;
* 最下面是常用的四个入口和「抽一个人」。 * 最下面是常用的四个入口和「抽一个人」。
* *
* 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 —— * 原来是总用户数 / 今日提交 / 近期比赛 / 判题机四个数,加一张要手输班级前缀的班级排名表 ——
@@ -52,7 +52,7 @@ const siteTiles = computed(() => {
{ label: '提交', value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` }, { label: '提交', value: n(site.submissions), note: `这学期 ${n(site.termSubmissions)} 次` },
{ label: '这周做题的人', value: n(site.weekActive), note: '周一起交过题的' }, { label: '这周做题的人', value: n(site.weekActive), note: '周一起交过题的' },
{ label: '比赛', value: n(site.contests), note: '', to: '/admin/contest/list' }, { 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' }, { label: '自学', value: n(site.tutorials), note: '课', to: '/admin/tutorial/list' },
] ]
}) })
+12 -12
View File
@@ -24,11 +24,11 @@ import {
import BadgeModal from './components/BadgeModal.vue' import BadgeModal from './components/BadgeModal.vue'
/** /**
* 新建 / 编辑题单,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开; * 新建 / 编辑专题,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开;
* 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。 * 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。
* *
* 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。 * 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。
* 新建时题目先攒在页面上,保存时建好题单再一次加进去;编辑时加、删、排、切选做当场生效。 * 新建时题目先攒在页面上,保存时建好专题再一次加进去;编辑时加、删、排、切选做当场生效。
*/ */
const props = defineProps<{ problemSetId?: string }>() const props = defineProps<{ problemSetId?: string }>()
@@ -196,7 +196,7 @@ function remove(item: Item) {
return return
} }
dialog.warning({ dialog.warning({
title: '从题单里拿掉这道题?', title: '从专题里拿掉这道题?',
content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`, content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`,
positiveText: '拿掉', positiveText: '拿掉',
negativeText: '不拿', negativeText: '不拿',
@@ -312,7 +312,7 @@ async function load() {
async function submit() { async function submit() {
if (!form.title.trim()) { if (!form.title.trim()) {
message.error('题单还没起名字') message.error('专题还没起名字')
return return
} }
if (untilMs.value !== null && untilMs.value <= Date.now()) { 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 } }) router.replace({ name: 'admin problemset edit', params: { problemSetId: created.id } })
} catch (err) { } catch (err) {
message.error(errorMessage(err)) message.error(errorMessage(err))
@@ -358,7 +358,7 @@ async function submit() {
function removeSet() { function removeSet() {
const set = current.value! const set = current.value!
dialog.error({ dialog.error({
title: `删除题单「${set.title}」?`, title: `删除专题「${set.title}」?`,
content: set.participantCount content: set.participantCount
? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。` ? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。`
: '还没有人加入。删掉找不回来。', : '还没有人加入。删掉找不回来。',
@@ -380,9 +380,9 @@ onMounted(load)
</script> </script>
<template> <template>
<PageHeader :title="editing ? '编辑题单' : '新建题单'" :back="{ name: 'admin problemset list' }"> <PageHeader :title="editing ? '编辑专题' : '新建专题'" :back="{ name: 'admin problemset list' }">
<template v-if="editing" #actions> <template v-if="editing" #actions>
<n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank"> 到前台看这个题单 </n-button> <n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank"> 到前台看这个专题 </n-button>
</template> </template>
</PageHeader> </PageHeader>
@@ -410,7 +410,7 @@ onMounted(load)
</n-flex> </n-flex>
<span class="hint">{{ assignHint }}</span> <span class="hint">{{ assignHint }}</span>
<span class="hint"> <span class="hint">
布置期内,题单里这几道题学生以前交的代码先藏起来(不管加没加入),在题单里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。 布置期内,专题里这几道题学生以前交的代码先藏起来(不管加没加入),在专题里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。
</span> </span>
</div> </div>
</div> </div>
@@ -418,7 +418,7 @@ onMounted(load)
<div class="field"> <div class="field">
<span class="label">公开</span> <span class="label">公开</span>
<n-switch v-model:value="form.visible" /> <n-switch v-model:value="form.visible" />
<span class="hint">{{ form.visible ? '学生能在题单列表里看到' : '藏起来,学生看不到也进不去' }}</span> <span class="hint">{{ form.visible ? '学生能在专题列表里看到' : '藏起来,学生看不到也进不去' }}</span>
</div> </div>
<div v-if="current" class="stats"> <div v-if="current" class="stats">
@@ -434,7 +434,7 @@ onMounted(load)
><template v-if="editing"> · {{ badges.length }} 个奖章</template></span ><template v-if="editing"> · {{ badges.length }} 个奖章</template></span
> >
<div class="spacer"></div> <div class="spacer"></div>
<n-button v-if="editing" text type="error" @click="removeSet">删除题单</n-button> <n-button v-if="editing" text type="error" @click="removeSet">删除专题</n-button>
</div> </div>
</section> </section>
@@ -503,7 +503,7 @@ onMounted(load)
<div class="spacer"></div> <div class="spacer"></div>
<n-button v-if="editing" size="small" @click="openBadge(null)">+ 加奖章</n-button> <n-button v-if="editing" size="small" @click="openBadge(null)">+ 加奖章</n-button>
</div> </div>
<div v-if="!editing" class="empty hint">先保存题单,再加奖章</div> <div v-if="!editing" class="empty hint">先保存专题,再加奖章</div>
<div v-else-if="badges.length" class="list"> <div v-else-if="badges.length" class="list">
<div v-for="badge in badges" :key="badge.id" class="item badge"> <div v-for="badge in badges" :key="badge.id" class="item badge">
<img :src="badge.icon" alt="" class="badge-icon" /> <img :src="badge.icon" alt="" class="badge-icon" />
+7 -7
View File
@@ -10,7 +10,7 @@ import { useBreakpoints } from 'shared/composables/breakpoints'
import { useListLoader } from 'shared/composables/listLoader' import { useListLoader } from 'shared/composables/listLoader'
/** /**
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」), * 后台专题列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑 * 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
*/ */
const router = useRouter() const router = useRouter()
@@ -42,7 +42,7 @@ function renderVisible(row: AdminProblemSet) {
}) })
} }
/** 手机上一个题单一张卡:名字一行(点了编辑),题数 · 加入 / 做完 · 奖章 · 创建者 · 日期一行小字 */ /** 手机上一个专题一张卡:名字一行(点了编辑),题数 · 加入 / 做完 · 奖章 · 创建者 · 日期一行小字 */
function renderMobileCard(row: AdminProblemSet) { function renderMobileCard(row: AdminProblemSet) {
const items = [ const items = [
`${row.problemsCount} 道题`, `${row.problemsCount} 道题`,
@@ -62,14 +62,14 @@ function renderMobileCard(row: AdminProblemSet) {
} }
const mobileColumns: DataTableColumn<AdminProblemSet>[] = [ const mobileColumns: DataTableColumn<AdminProblemSet>[] = [
{ title: '题单', key: 'title', render: renderMobileCard }, { title: '专题', key: 'title', render: renderMobileCard },
{ title: '公开', key: 'visible', width: 72, align: 'center', render: renderVisible }, { title: '公开', key: 'visible', width: 72, align: 'center', render: renderVisible },
] ]
const desktopColumns: DataTableColumn<AdminProblemSet>[] = [ const desktopColumns: DataTableColumn<AdminProblemSet>[] = [
{ title: 'ID', key: 'id', width: 70 }, { title: 'ID', key: 'id', width: 70 },
{ {
title: '题单', title: '专题',
key: 'title', key: 'title',
minWidth: 200, minWidth: 200,
render: renderTitle, render: renderTitle,
@@ -131,12 +131,12 @@ watch(() => [query.page, query.limit], listProblemSets)
</script> </script>
<template> <template>
<PageHeader title="题单列表"> <PageHeader title="专题列表">
<template #actions> <template #actions>
<n-button type="primary" @click="router.push({ name: 'admin problemset create' })"> 新建题单 </n-button> <n-button type="primary" @click="router.push({ name: 'admin problemset create' })"> 新建专题 </n-button>
</template> </template>
<template #filters> <template #filters>
<n-input v-model:value="query.keyword" placeholder="搜题单名字" clearable @clear="clearQuery" :style="{ width: isMobile ? '100%' : '220px' }" /> <n-input v-model:value="query.keyword" placeholder="搜专题名字" clearable @clear="clearQuery" :style="{ width: isMobile ? '100%' : '220px' }" />
</template> </template>
</PageHeader> </PageHeader>
<n-data-table :loading="loading" striped class="set-table" :columns="columns" :data="problemSets" /> <n-data-table :loading="loading" striped class="set-table" :columns="columns" :data="problemSets" />
+1 -1
View File
@@ -9,7 +9,7 @@ body {
机房屏 1366×768 的可用高度只有 640 左右,行高 48 是和用户商量过的:题目列表在 机房屏 1366×768 的可用高度只有 640 左右,行高 48 是和用户商量过的:题目列表在
这个高度还能看到 8 行。题目详情页(编辑器)不吃这张表,它按 640 高逐像素排过。 */ 这个高度还能看到 8 行。题目详情页(编辑器)不吃这张表,它按 640 高逐像素排过。 */
:root { :root {
--oj-page-width: 1200px; /* 阅读型页面(比赛、题单、公告……)的限宽,居中 */ --oj-page-width: 1200px; /* 阅读型页面(比赛、专题、公告……)的限宽,居中 */
--oj-fs-title: 24px; /* 页标题 */ --oj-fs-title: 24px; /* 页标题 */
--oj-fs-h2: 17px; /* 区块标题 */ --oj-fs-h2: 17px; /* 区块标题 */
--oj-fs-body: 15px; /* 列表主文字:题目名、比赛名 */ --oj-fs-body: 15px; /* 列表主文字:题目名、比赛名 */
@@ -6,11 +6,11 @@ import type { Group } from '../series'
import Medal from './Medal.vue' import Medal from './Medal.vue'
import SeriesRow from './SeriesRow.vue' import SeriesRow from './SeriesRow.vue'
/** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「题单和比赛」组末尾是拿到的题单奖章 */ /** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「专题和比赛」组末尾是拿到的专题奖章 */
const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>() const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>()
const theme = useThemeVars() const theme = useThemeVars()
const showBadges = computed(() => props.group.name === '题单和比赛' && !!props.badges?.length) const showBadges = computed(() => props.group.name === '专题和比赛' && !!props.badges?.length)
</script> </script>
<template> <template>
@@ -27,7 +27,7 @@ const showBadges = computed(() => props.group.name === '题单和比赛' && !!pr
</div> </div>
</div> </div>
<div v-if="showBadges" class="badges"> <div v-if="showBadges" class="badges">
<span class="masked-title">拿到的题单奖章</span> <span class="masked-title">拿到的专题奖章</span>
<div class="badge-list"> <div class="badge-list">
<n-tooltip v-for="b in badges" :key="b.id" :delay="150"> <n-tooltip v-for="b in badges" :key="b.id" :delay="150">
<template #trigger> <template #trigger>
@@ -36,7 +36,7 @@ const showBadges = computed(() => props.group.name === '题单和比赛' && !!pr
<span>{{ b.badge.name }}</span> <span>{{ b.badge.name }}</span>
</router-link> </router-link>
</template> </template>
{{ b.badge.description }} · 来自题单「{{ b.problemSet.title }}」 {{ b.badge.description }} · 来自专题「{{ b.problemSet.title }}」
</n-tooltip> </n-tooltip>
</div> </div>
</div> </div>
+2 -2
View File
@@ -37,9 +37,9 @@ const got = computed(() => data.value?.achievements.filter((item) => item.unlock
const rarities: AchievementRarity[] = ['platinum', 'gold', 'silver', 'bronze'] const rarities: AchievementRarity[] = ['platinum', 'gold', 'silver', 'bronze']
const countOf = (rarity: AchievementRarity) => got.value.filter((item) => item.rarity === rarity).length const countOf = (rarity: AchievementRarity) => got.value.filter((item) => item.rarity === rarity).length
// 桌面两栏:左边做题、其他、彩蛋,右边坚持、题单和比赛 —— 两边差不多高 // 桌面两栏:左边做题、其他、彩蛋,右边坚持、专题和比赛 —— 两边差不多高
const LEFT: GroupName[] = ['做题', '其他', '彩蛋'] const LEFT: GroupName[] = ['做题', '其他', '彩蛋']
const RIGHT: GroupName[] = ['坚持', '题单和比赛'] const RIGHT: GroupName[] = ['坚持', '专题和比赛']
const pick = (names: GroupName[]) => groups.value.filter((group) => names.includes(group.name)) const pick = (names: GroupName[]) => groups.value.filter((group) => names.includes(group.name))
async function load() { async function load() {
+8 -8
View File
@@ -7,7 +7,7 @@ import type { Achievement } from 'utils/types'
* 指标是后台可配的,这里没登记的指标归到「其他」、拿第一档的名字当线名 —— 不会丢,只是不好看, * 指标是后台可配的,这里没登记的指标归到「其他」、拿第一档的名字当线名 —— 不会丢,只是不好看,
* 在后台加了新指标记得回来补一行。 * 在后台加了新指标记得回来补一行。
*/ */
export const GROUPS = ['做题', '坚持', '题单和比赛', '其他', '彩蛋'] as const export const GROUPS = ['做题', '坚持', '专题和比赛', '其他', '彩蛋'] as const
export type GroupName = (typeof GROUPS)[number] export type GroupName = (typeof GROUPS)[number]
interface MetricInfo { interface MetricInfo {
@@ -55,19 +55,19 @@ const METRICS: Record<string, MetricInfo> = {
}, },
submission_count: { group: '坚持', label: '交了几次', unit: '次', remain: (r) => `再交 ${r} 次` }, submission_count: { group: '坚持', label: '交了几次', unit: '次', remain: (r) => `再交 ${r} 次` },
problemset_completed: { problemset_completed: {
group: '题单和比赛', group: '专题和比赛',
label: '做完的题单', label: '做完的专题',
unit: '个', unit: '个',
remain: (r) => `再做完 ${r} 个题单`, remain: (r) => `再做完 ${r} 个专题`,
}, },
badge_count: { badge_count: {
group: '题单和比赛', group: '专题和比赛',
label: '题单奖章', label: '专题奖章',
unit: '枚', unit: '枚',
remain: (r) => `再拿 ${r} 枚题单奖章`, remain: (r) => `再拿 ${r} 枚专题奖章`,
}, },
contest_joined: { contest_joined: {
group: '题单和比赛', group: '专题和比赛',
label: '参加的比赛', label: '参加的比赛',
unit: '场', unit: '场',
remain: (r) => `再参加 ${r} 场比赛`, remain: (r) => `再参加 ${r} 场比赛`,
+4 -4
View File
@@ -463,12 +463,12 @@ export function retryFlowchartSubmission(submissionId: string) {
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`) return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
} }
/** 自己在这道题上评完的每一次(早的在前),题单布置期内藏着的已经滤掉 */ /** 自己在这道题上评完的每一次(早的在前),专题布置期内藏着的已经滤掉 */
export function getFlowchartScores(problemId: number) { export function getFlowchartScores(problemId: number) {
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`) return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
} }
// ==================== 题单相关API ==================== // ==================== 专题相关API ====================
export function getProblemSetList(offset = 0, limit = 10, keyword = '') { export function getProblemSetList(offset = 0, limit = 10, keyword = '') {
return api.get<ProblemSetList>('problem-sets', { params: { offset, limit, keyword } }) return api.get<ProblemSetList>('problem-sets', { params: { offset, limit, keyword } })
@@ -490,14 +490,14 @@ export function getUserBadges(username?: string) {
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? 'me')}/badges`) return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? 'me')}/badges`)
} }
/** 老师看某个班在题单里的情况;不传班级就看服务端推断的那个班 */ /** 老师看某个班在专题里的情况;不传班级就看服务端推断的那个班 */
export function getProblemSetClassView(problemSetId: number, className = '') { export function getProblemSetClassView(problemSetId: number, className = '') {
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, { return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
params: className ? { className } : {}, params: className ? { className } : {},
}) })
} }
/** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */ /** 这道题以前的代码为什么藏着:落在哪些正在布置的专题里。传主键或题号都行 */
export function getProblemSetLocks(problem: { id: number } | { displayId: string }) { export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
return api.get<ProblemSetLock[]>('problem-sets/locks', { return api.get<ProblemSetLock[]>('problem-sets/locks', {
params: 'id' in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId }, params: 'id' in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
@@ -3,7 +3,7 @@ import type { Exercise } from 'utils/types'
/** /**
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染: * 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
* *
* - `heading`:二级标题单独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮; * - `heading`:二级标专题独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮;
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框; * - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块; * - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示; * 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
@@ -18,11 +18,11 @@ import ProblemChips, { type ChipItem } from './ProblemChips.vue'
* 左栏顶上的一条:「这道题是从哪来的」。按入口路由决定是哪一种,最多一条: * 左栏顶上的一条:「这道题是从哪来的」。按入口路由决定是哪一种,最多一条:
* *
* - 课堂:这道题在「班里在做」的列表里 —— 做完几道 + 每道的题号 + 上一题 / 下一题 * - 课堂:这道题在「班里在做」的列表里 —— 做完几道 + 每道的题号 + 上一题 / 下一题
* - 题单:题单名 + 进度 + 第几题 + 上一题 / 下一题 * - 专题:专题名 + 进度 + 第几题 + 上一题 / 下一题
* - 比赛:比赛名 + 每道的题号 + 还剩多久 + 排名 * - 比赛:比赛名 + 每道的题号 + 还剩多久 + 排名
* - 都不是:不显示 * - 都不是:不显示
* *
* 一道题既在这节课里、又是从题单进来的,按入口显示题单条。 * 一道题既在这节课里、又是从专题进来的,按入口显示专题条。
* 换题用的都是普通链接,通过之后欠着的那次强制点评由 SubmissionEffects 的路由守卫拦下。 * 换题用的都是普通链接,通过之后欠着的那次强制点评由 SubmissionEffects 的路由守卫拦下。
*/ */
@@ -35,7 +35,7 @@ const theme = useThemeVars()
const isDark = useDark() const isDark = useDark()
/** /**
* 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、题单浅蓝、比赛浅黄。 * 三种入口各一种底色(设计稿「上下文条与协作状态」):课堂浅绿、专题浅蓝、比赛浅黄。
* 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清 * 字色在暗色下换成浅一档的,深绿、深蓝、深黄放在暗底上看不清
*/ */
const tone = computed(() => ({ const tone = computed(() => ({
@@ -119,7 +119,7 @@ onSolvedHere((step) => {
lessonStore.load(true) lessonStore.load(true)
}) })
// ==================== 题单 ==================== // ==================== 专题 ====================
const problemSet = ref<ProblemSet | null>(null) const problemSet = ref<ProblemSet | null>(null)
const setProblems = ref<ProblemSetProblem[]>([]) const setProblems = ref<ProblemSetProblem[]>([])
@@ -131,12 +131,12 @@ watch(
if (!id) return if (!id) return
try { try {
const [detail, problems] = await Promise.all([getProblemSetDetail(Number(id)), getProblemSetProblems(Number(id))]) const [detail, problems] = await Promise.all([getProblemSetDetail(Number(id)), getProblemSetProblems(Number(id))])
// 拉的这一会儿已经离开了这个题单 // 拉的这一会儿已经离开了这个专题
if (ctx.value.problemSetId !== id) return if (ctx.value.problemSetId !== id) return
problemSet.value = detail problemSet.value = detail
setProblems.value = problems setProblems.value = problems
} catch { } catch {
// 拉不到就没有题单条,不影响做题 // 拉不到就没有专题条,不影响做题
} }
}, },
{ immediate: true }, { immediate: true },
@@ -153,8 +153,8 @@ const setBar = computed(() => {
params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id }, params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id },
} }
: null : null
// 进度只数必做题,和题单页的「完成进度 2 / 8(另有 3 道选做)」同一个口径; // 进度只数必做题,和专题页的「完成进度 2 / 8(另有 3 道选做)」同一个口径;
// 一道必做都没有的题单才数全部 // 一道必做都没有的专题才数全部
const required = list.some((item) => item.isRequired) ? list.filter((item) => item.isRequired) : list const required = list.some((item) => item.isRequired) ? list.filter((item) => item.isRequired) : list
const done = required.filter((item) => item.isCompleted).length const done = required.filter((item) => item.isCompleted).length
return { return {
@@ -180,7 +180,7 @@ async function joinHere() {
await joinProblemSet(Number(id)) await joinProblemSet(Number(id))
problemSet.value = await getProblemSetDetail(Number(id)) problemSet.value = await getProblemSetDetail(Number(id))
} catch { } catch {
// 加入失败就还是那条提示,回题单页再点一次 // 加入失败就还是那条提示,回专题页再点一次
} finally { } finally {
joiningSet.value = false joiningSet.value = false
} }
@@ -296,10 +296,10 @@ onSolvedHere(() => {
</template> </template>
</div> </div>
<!-- 题单 --> <!-- 专题 -->
<div v-else-if="setBar" class="context-bar problemset"> <div v-else-if="setBar" class="context-bar problemset">
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`"> ‹ {{ setBar.title }} </router-link> <router-link :to="setBar.home" class="back set-back" :title="`回到专题:${setBar.title}`"> ‹ {{ setBar.title }} </router-link>
<!-- 手机上一排放不下题单名 + 进度条 + 做完几道 + 两个大箭头,进度条让掉,「做完 x/y」还在 --> <!-- 手机上一排放不下专题名 + 进度条 + 做完几道 + 两个大箭头,进度条让掉,「做完 x/y」还在 -->
<div v-if="setBar.joined && !isMobile" class="set-progress" aria-hidden="true"> <div v-if="setBar.joined && !isMobile" class="set-progress" aria-hidden="true">
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" /> <div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
</div> </div>
@@ -309,7 +309,7 @@ onSolvedHere(() => {
</span> </span>
<template v-else> <template v-else>
<span class="meta">{{ isMobile ? '没加入,不算进度' : '还没加入,在这里做对了不算进度' }}</span> <span class="meta">{{ isMobile ? '没加入,不算进度' : '还没加入,在这里做对了不算进度' }}</span>
<n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere">加入题单</n-button> <n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere">加入专题</n-button>
</template> </template>
<div class="spacer" /> <div class="spacer" />
<component <component
@@ -226,7 +226,7 @@ function goCode() {
<span class="attempt pending">第 {{ scores.length + 1 }} 次</span> <span class="attempt pending">第 {{ scores.length + 1 }} 次</span>
</template> </template>
</div> </div>
<p v-if="hiddenCount" class="note">另外 {{ hiddenCount }} 次是题单布置之前画的,先藏起来了:在题单里画到 A 或 S,或者布置期结束就解锁</p> <p v-if="hiddenCount" class="note">另外 {{ hiddenCount }} 次是专题布置之前画的,先藏起来了:在专题里画到 A 或 S,或者布置期结束就解锁</p>
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 --> <!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
<template v-if="phase === 'evaluating' && viewingLatest"> <template v-if="phase === 'evaluating' && viewingLatest">
@@ -74,17 +74,17 @@ const lessonProblemIds = computed(() => lessonStore.activity?.problems.map((item
<template v-else> <template v-else>
<div class="text"> <div class="text">
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span> <span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span v-if="!challenges" class="detail">想接着练,可以去题单里挑一个</span> <span v-if="!challenges" class="detail">想接着练,可以去专题里挑一个</span>
</div> </div>
<router-link v-if="!challenges" to="/problemset" class="next secondary"> <router-link v-if="!challenges" to="/problemset" class="next secondary">
<span class="next-label">去题单继续练</span> <span class="next-label">去专题继续练</span>
</router-link> </router-link>
</template> </template>
</div> </div>
<!-- 做完了:先给这节课里还能挑战的,再给题单(设计稿 StkDone)。挑战一条都没有时只剩题单那句 --> <!-- 做完了:先给这节课里还能挑战的,再给专题(设计稿 StkDone)。挑战一条都没有时只剩专题那句 -->
<template v-if="lesson && !lesson.next && challenges"> <template v-if="lesson && !lesson.next && challenges">
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" /> <ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
<router-link to="/problemset" class="more">去题单继续练</router-link> <router-link to="/problemset" class="more">去专题继续练</router-link>
</template> </template>
</template> </template>
@@ -28,12 +28,12 @@ const ctx = useProblemPageContext()
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果 // SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value)) const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value))
// 相似题推荐:做对了、或者错了 3 次以上才给(比赛、题单里不给,见 problemPageContext) // 相似题推荐:做对了、或者错了 3 次以上才给(比赛、专题里不给,见 problemPageContext)
const showSimilar = computed(() => ctx.value.similar && (problem.value?.myStatus === 0 || problemStore.failCount >= 3)) const showSimilar = computed(() => ctx.value.similar && (problem.value?.myStatus === 0 || problemStore.failCount >= 3))
/** /**
* 标题下的状态标签。原来是题面最上面两条整宽的提示(「本题已经被你解决啦」「尝试过但还没有 * 标题下的状态标签。原来是题面最上面两条整宽的提示(「本题已经被你解决啦」「尝试过但还没有
* 通过」),占掉一屏里最值钱的那几行。题单里不给(入口差异表)。 * 通过」),占掉一屏里最值钱的那几行。专题里不给(入口差异表)。
*/ */
const myStatus = computed(() => { const myStatus = computed(() => {
if (!ctx.value.statusTag || !userStore.isAuthed) return null if (!ctx.value.statusTag || !userStore.isAuthed) return null
@@ -94,7 +94,7 @@ const mobileExtensions = [...desktopExtensions, ...hangWrapExtensions]
const editorExtensions = computed(() => (isDesktop.value ? desktopExtensions : mobileExtensions)) const editorExtensions = computed(() => (isDesktop.value ? desktopExtensions : mobileExtensions))
const contestID = route.params.contestID || null const contestID = route.params.contestID || null
// 草稿存在哪个键下(题单入口单独一份),和「重置代码」共用一份算法 // 草稿存在哪个键下(专题入口单独一份),和「重置代码」共用一份算法
const storageKey = useDraftKey() const storageKey = useDraftKey()
// 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。 // 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。
@@ -120,8 +120,8 @@ onMounted(loadCode)
watch(() => problem.value?._id, loadCode) watch(() => problem.value?._id, loadCode)
/** /**
* 流程图草稿:每道题一份;题单入口单独一份 —— 加入题单之前在题库里画到 A 的那张,不能一进 * 流程图草稿:每道题一份;专题入口单独一份 —— 加入专题之前在题库里画到 A 的那张,不能一进
* 题单就摆在画布上(设计文档第 11 节 3,和代码草稿同理)。 * 专题就摆在画布上(设计文档第 11 节 3,和代码草稿同理)。
* 键名带 v2:2026-10 之前的草稿是 vue-flow 的格式,读不了,也不去覆盖它 * 键名带 v2:2026-10 之前的草稿是 vue-flow 的格式,读不了,也不去覆盖它
*/ */
const flowchartDraftKey = computed(() => { const flowchartDraftKey = computed(() => {
@@ -44,7 +44,7 @@ const teacherCollab = useTeacherCollab()
const peer = computed(() => (teacherCollab.value ? (collabStore.room?.peerName ?? null) : null)) const peer = computed(() => (teacherCollab.value ? (collabStore.room?.peerName ?? null) : null))
// ==================== 班上第几个做对的 ==================== // ==================== 班上第几个做对的 ====================
// 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现 // 只在题库入口给:比赛有自己的排名,专题里整个抽屉都不出现
const rank = ref<ProblemRank | null>(null) const rank = ref<ProblemRank | null>(null)
const rankLine = computed(() => { const rankLine = computed(() => {
@@ -109,7 +109,7 @@ async function listSubmissions() {
/** /**
* 「我的提交统计」:这一页里各种结果各几次(「答案错误 × 3」「答案正确 × 1」),和原来一样 * 「我的提交统计」:这一页里各种结果各几次(「答案错误 × 3」「答案正确 × 1」),和原来一样
* 只数当前这一页。被题单闸门锁住的那几次不数 —— 它们在列表里只露一行说明,这里也不该露出结果 * 只数当前这一页。被专题闸门锁住的那几次不数 —— 它们在列表里只露一行说明,这里也不该露出结果
*/ */
const statusDistribution = computed(() => { const statusDistribution = computed(() => {
const counts = new Map<number, number>() const counts = new Map<number, number>()
@@ -137,7 +137,7 @@ type Loaded = { code: string; language: LANGUAGE }
const details = reactive<Record<string, Loaded | 'loading' | 'failed'>>({}) const details = reactive<Record<string, Loaded | 'loading' | 'failed'>>({})
async function toggle(row: SubmissionListItem) { async function toggle(row: SubmissionListItem) {
// 题单布置期内藏着的旧提交,点不开(后端也不会给代码) // 专题布置期内藏着的旧提交,点不开(后端也不会给代码)
if (!row.showLink) return if (!row.showLink) return
if (expanded.value === row.id) { if (expanded.value === row.id) {
expanded.value = null expanded.value = null
@@ -154,7 +154,7 @@ async function toggle(row: SubmissionListItem) {
} }
} }
/** 这一页里被题单闸门藏起来的次数 */ /** 这一页里被专题闸门藏起来的次数 */
const lockedCount = computed(() => submissions.value.filter((row) => !row.showLink).length) const lockedCount = computed(() => submissions.value.filter((row) => !row.showLink).length)
/** 展开的代码先只露 5 行,「一共 11 行」点一下再全摆出来(设计稿) */ /** 展开的代码先只露 5 行,「一共 11 行」点一下再全摆出来(设计稿) */
@@ -304,7 +304,7 @@ watch(query, () => {
</div> </div>
</div> </div>
</template> </template>
<!-- 题单布置期内藏着的那几次:合成一行,点不开(后端也不给代码) --> <!-- 专题布置期内藏着的那几次:合成一行,点不开(后端也不给代码) -->
<div v-if="lockedCount" class="locked"> <div v-if="lockedCount" class="locked">
<svg <svg
width="13" width="13"
@@ -12,11 +12,11 @@ import { useProblemPageContext } from '../composables/problemPageContext'
import { caseRevealDuration } from '../utils/caseReveal' import { caseRevealDuration } from '../utils/caseReveal'
/** /**
* 判完之后「该发生什么」:失败计数、标成已解决、烟花、点评、回题单。 * 判完之后「该发生什么」:失败计数、标成已解决、烟花、点评、回专题。
* *
* 挂在题目页这一层(detail.vue),和编辑器、提交按钮都无关 —— 原来这些写在提交按钮 * 挂在题目页这一层(detail.vue),和编辑器、提交按钮都无关 —— 原来这些写在提交按钮
* 那个组件(SubmitCode)里,手机上切一下页签、桌面切一下分栏模式,编辑器一卸载它们 * 那个组件(SubmitCode)里,手机上切一下页签、桌面切一下分栏模式,编辑器一卸载它们
* 就跟着没了:判完不放烟花、不计失败次数、不弹点评、题单里不回题单。 * 就跟着没了:判完不放烟花、不计失败次数、不弹点评、专题里不回专题。
* 状态本身在 submission store,这里只是看着它做事。 * 状态本身在 submission store,这里只是看着它做事。
*/ */
@@ -167,7 +167,7 @@ const { start: goToProblemSetDelayed, stop: cancelGoToProblemSet } = useTimeoutF
) )
/** /**
* 换题时收掉通过之后等着的点评和回题单 —— 评价弹窗是按**当前**题目去查、去弹的, * 换题时收掉通过之后等着的点评和回专题 —— 评价弹窗是按**当前**题目去查、去弹的,
* 不取消的话就会给一道还没做的题弹评价。欠着的点评走不到这里:离开这道题的导航 * 不取消的话就会给一道还没做的题弹评价。欠着的点评走不到这里:离开这道题的导航
* 已经被 holdForReview 拦下、评完才放行;还能走到这里的只有弹窗开着时按了浏览器后退。 * 已经被 holdForReview 拦下、评完才放行;还能走到这里的只有弹窗开着时按了浏览器后退。
* 结果面板、还在跟的那条提交由 submission store 自己收。 * 结果面板、还在跟的那条提交由 submission store 自己收。
@@ -221,14 +221,14 @@ watch(
// 1. 刷新题目状态 // 1. 刷新题目状态
problem.value!.myStatus = 0 problem.value!.myStatus = 0
// 题单进度不在这里更新了。以前是 AC 之后回调 PUT /problem-set-progress,只认路由 // 专题进度不在这里更新了。以前是 AC 之后回调 PUT /problem-set-progress,只认路由
// 参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖、页面提前关掉进度 // 参数里那一个专题:从普通题库入口做出同一道题不计进度,网络一抖、页面提前关掉进度
// 就静默丢失。现在判题那一路直接记账(judge/run.ts),而且是记进所有已加入且包含 // 就静默丢失。现在判题那一路直接记账(judge/run.ts),而且是记进所有已加入且包含
// 这道题的题单;收到「判完了」的时候进度已经落库,跳回题单页看到的就是新数据。 // 这道题的专题;收到「判完了」的时候进度已经落库,跳回专题页看到的就是新数据。
if (result !== SubmissionStatus.accepted) return if (result !== SubmissionStatus.accepted) return
// 3. 烟花放一会儿弹出评价框。比赛里不打扰;题单里 1.5 秒后要跳回题单页,弹了也会被冲掉 // 3. 烟花放一会儿弹出评价框。比赛里不打扰;专题里 1.5 秒后要跳回专题页,弹了也会被冲掉
// (评价是欠着的这件事马上记下:方块还在亮的时候离开这道题,也要先评) // (评价是欠着的这件事马上记下:方块还在亮的时候离开这道题,也要先评)
if (ctx.value.reviewAfterAccepted) reviewOwed.value = true if (ctx.value.reviewAfterAccepted) reviewOwed.value = true
@@ -241,15 +241,15 @@ watch(
}) })
if (ctx.value.backToProblemSet) { if (ctx.value.backToProblemSet) {
// 延迟回到题单页面 // 延迟回到专题页面
goToProblemSetDelayed() goToProblemSetDelayed()
} }
}, },
) )
// ==================== 流程图画到 A / S ==================== // ==================== 流程图画到 A / S ====================
// 题单里流程图作业拿到 A 或 S 就算这道题做完(后端评分那一路已经记了账), // 专题里流程图作业拿到 A 或 S 就算这道题做完(后端评分那一路已经记了账),
// 和代码通过一样 1.5 秒后回题单(设计文档第 3 节决定 4、5)。只认这一页上刚评完的那一次 // 和代码通过一样 1.5 秒后回专题(设计文档第 3 节决定 4、5)。只认这一页上刚评完的那一次
const flowchartStore = useFlowchartStore() const flowchartStore = useFlowchartStore()
watch( watch(
() => flowchartStore.evaluatedSeq, () => flowchartStore.evaluatedSeq,
@@ -80,8 +80,8 @@ const sampleCheck = computed(() => {
}) })
/** /**
* 通过之后给「这节课的下一题」。比赛有自己的题目列表;从题单入口进来的,通过后 * 通过之后给「这节课的下一题」。比赛有自己的题目列表;从专题入口进来的,通过后
* 1.5 秒会自动跳回题单(SubmissionEffects),这两种都不给 * 1.5 秒会自动跳回专题(SubmissionEffects),这两种都不给
*/ */
const ctx = useProblemPageContext() const ctx = useProblemPageContext()
const showLessonNext = computed(() => !props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext) const showLessonNext = computed(() => !props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext)
@@ -1,5 +1,5 @@
/** /**
* 挑战的颜色:紫。课堂条是绿、题单条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。 * 挑战的颜色:紫。课堂条是绿、专题条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。
* Naive 的主题里没有紫,暗色下换浅一档,深紫放在暗底上看不清 * Naive 的主题里没有紫,暗色下换浅一档,深紫放在暗底上看不清
*/ */
export function useChallengeTone() { export function useChallengeTone() {
@@ -6,8 +6,8 @@ import { useProblemStore } from 'oj/store/problem'
* (删草稿)都用这一份 —— 原来键在编辑器里拼、再作为 prop 传给工具栏,手机上「重置」 * (删草稿)都用这一份 —— 原来键在编辑器里拼、再作为 prop 传给工具栏,手机上「重置」
* 挪进页签行的「⋯」之后就够不着了,再拼一份迟早和编辑器那份对不上。 * 挪进页签行的「⋯」之后就够不着了,再拼一份迟早和编辑器那份对不上。
* *
* 题单入口单独一份:原来题库和题单共用 `problem_题号_contest_null_语言`,加入题单之前 * 专题入口单独一份:原来题库和专题共用 `problem_题号_contest_null_语言`,加入专题之前
* 在题库里写对的代码,一进题单就摆在编辑器里(设计文档第 11 节 4)。 * 在题库里写对的代码,一进专题就摆在编辑器里(设计文档第 11 节 4)。
* **键的最后一段必须是语言**,编辑器换语言时靠它判断草稿是不是这门语言的。 * **键的最后一段必须是语言**,编辑器换语言时靠它判断草稿是不是这门语言的。
*/ */
export function useDraftKey() { export function useDraftKey() {
@@ -42,7 +42,7 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
const options = computed<DropdownOption[]>(() => { const options = computed<DropdownOption[]>(() => {
const items: DropdownOption[] = [] const items: DropdownOption[] = []
// 「本题提交」挪到了「我的提交」抽屉的底部;题单入口没有抽屉,给管理员角色留在这里 // 「本题提交」挪到了「我的提交」抽屉的底部;专题入口没有抽屉,给管理员角色留在这里
if (ctx.value.allSubmissionsInMenu && userStore.isAdminRole) { if (ctx.value.allSubmissionsInMenu && userStore.isAdminRole) {
items.push({ label: '本题提交', key: 'allSubmissions' }) items.push({ label: '本题提交', key: 'allSubmissions' })
} }
@@ -1,10 +1,10 @@
import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { RouteLocationNormalizedLoaded } from 'vue-router'
/** /**
* 题目页的三种入口:题库(含课堂)、题单、比赛。同一个 detail.vue 挂在三条路由上。 * 题目页的三种入口:题库(含课堂)、专题、比赛。同一个 detail.vue 挂在三条路由上。
* *
* 入口之间的差异**只在这张表里判断**。原来散在各个组件里各查各的路由:同一件事 * 入口之间的差异**只在这张表里判断**。原来散在各个组件里各查各的路由:同一件事
* 在题单里是「禁用」、在比赛里是「隐藏」,加一处功能就得把每个组件翻一遍 * 在专题里是「禁用」、在比赛里是「隐藏」,加一处功能就得把每个组件翻一遍
* (见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 8 节)。 * (见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 8 节)。
*/ */
export type ProblemEntry = 'problem' | 'problemset' | 'contest' export type ProblemEntry = 'problem' | 'problemset' | 'contest'
@@ -27,13 +27,13 @@ interface EntryRules {
/** 通过之后给「这节课的下一题」 */ /** 通过之后给「这节课的下一题」 */
lessonNext: boolean lessonNext: boolean
/** /**
* 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。题单里通过后 1.5 秒就跳回去, * 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。专题里通过后 1.5 秒就跳回去,
* 比赛提交不判挑战 * 比赛提交不判挑战
*/ */
challenges: boolean challenges: boolean
/** 通过之后 1.5 秒弹强制点评 */ /** 通过之后 1.5 秒弹强制点评 */
reviewAfterAccepted: boolean reviewAfterAccepted: boolean
/** 通过之后 1.5 秒回题单 */ /** 通过之后 1.5 秒回专题 */
backToProblemSet: boolean backToProblemSet: boolean
/** 举手求助 */ /** 举手求助 */
help: boolean help: boolean
@@ -45,13 +45,13 @@ interface EntryRules {
collab: boolean collab: boolean
/** /**
* 工具栏上教师的「课堂统计」(新标签开数据统计页)。题库入口已经并进「统计」页签的老师 * 工具栏上教师的「课堂统计」(新标签开数据统计页)。题库入口已经并进「统计」页签的老师
* 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的题单入口还要这个按钮。 * 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的专题入口还要这个按钮。
* 比赛不给:统计接口按 displayId 在公开题库里找题,比赛题号查到的是另一道公开题,或者 404 * 比赛不给:统计接口按 displayId 在公开题库里找题,比赛题号查到的是另一道公开题,或者 404
*/ */
classStats: boolean classStats: boolean
/** /**
* 「看这道题所有人的提交」放进「⋯」(只给管理员角色)。它平时在「我的提交」抽屉底部, * 「看这道题所有人的提交」放进「⋯」(只给管理员角色)。它平时在「我的提交」抽屉底部,
* 题单入口没有抽屉,老师就没地方点了 * 专题入口没有抽屉,老师就没地方点了
*/ */
allSubmissionsInMenu: boolean allSubmissionsInMenu: boolean
} }
@@ -70,15 +70,15 @@ const RULES: Record<ProblemEntry, EntryRules> = {
classStats: false, classStats: false,
allSubmissionsInMenu: false, allSubmissionsInMenu: false,
}, },
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案 // 专题里什么「以前的」都不给看:加入专题之前的提交、统计、点评都能拿来抄答案
problemset: { problemset: {
drawers: [], drawers: [],
statusTag: false, statusTag: false,
// 点进相似题就离开了题单 // 点进相似题就离开了专题
similar: false, similar: false,
lessonNext: false, lessonNext: false,
challenges: false, challenges: false,
// 1.5 秒后要跳回题单页,弹了也会被冲掉 // 1.5 秒后要跳回专题页,弹了也会被冲掉
reviewAfterAccepted: false, reviewAfterAccepted: false,
backToProblemSet: true, backToProblemSet: true,
help: true, help: true,
+6 -6
View File
@@ -12,7 +12,7 @@ import { useFlowchartStore } from 'oj/store/flowchart'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
import type { RouteLocationNormalized } from 'vue-router' import type { RouteLocationNormalized } from 'vue-router'
import NotFound from 'shared/components/NotFound.vue' import NotFound from 'shared/components/NotFound.vue'
// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好 // 判完之后该发生的事(烟花、点评、回专题)。静态引入:它得在第一次判完之前就挂好
import SubmissionEffects from './components/SubmissionEffects.vue' import SubmissionEffects from './components/SubmissionEffects.vue'
import ResultTabLabel from './components/ResultTabLabel.vue' import ResultTabLabel from './components/ResultTabLabel.vue'
import ContextBar from './components/ContextBar.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' import { useCollabHere, useTeacherCollab } from './composables/teacherCollab'
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。 // 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版, // 题库、比赛、专题三种入口用的是同一个编辑器:原来比赛和专题用的是另一个精简版,
// 没有协作、也没有错误标记,于是题单里「求助」老师接不上、「把中文标点都换成英文」 // 没有协作、也没有错误标记,于是专题里「求助」老师接不上、「把中文标点都换成英文」
// 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节) // 点了没反应(见 docs/specs/2026-09-28-problem-page-redesign-design.md 第 10 节)
const loadProblemEditor = () => import('./components/ProblemEditor.vue') const loadProblemEditor = () => import('./components/ProblemEditor.vue')
const ProblemEditor = defineAsyncComponent(loadProblemEditor) const ProblemEditor = defineAsyncComponent(loadProblemEditor)
@@ -95,7 +95,7 @@ const leftPane = useTemplateRef<HTMLElement>('leftPane')
const theme = useThemeVars() const theme = useThemeVars()
/** /**
* 分隔条两头按像素卡,不按比例:左栏窄于 360 时题单条的「下一题」箭头被裁掉、三个抽屉按钮 * 分隔条两头按像素卡,不按比例:左栏窄于 360 时专题条的「下一题」箭头被裁掉、三个抽屉按钮
* 折成两行;右栏窄于 460 时工具栏收进「⋯」之后也放不下、语言下拉被挤成两行(原来 0.8 在 1280 屏上只给右栏 256) * 折成两行;右栏窄于 460 时工具栏收进「⋯」之后也放不下、语言下拉被挤成两行(原来 0.8 在 1280 屏上只给右栏 256)
*/ */
const { width: windowWidth } = useWindowSize() const { width: windowWidth } = useWindowSize()
@@ -234,12 +234,12 @@ async function init() {
} }
onMounted(init) onMounted(init)
/** 题不存在时回哪:比赛里回比赛,题单里回题单,否则回题目列表 */ /** 题不存在时回哪:比赛里回比赛,专题里回专题,否则回题目列表 */
const backTo = computed(() => const backTo = computed(() =>
contestID contestID
? { to: `/contest/${contestID}`, toLabel: '回到比赛' } ? { to: `/contest/${contestID}`, toLabel: '回到比赛' }
: problemSetId : problemSetId
? { to: `/problemset/${problemSetId}`, toLabel: '回到题单' } ? { to: `/problemset/${problemSetId}`, toLabel: '回到专题' }
: { to: '/problem', toLabel: '去题目列表' }, : { to: '/problem', toLabel: '去题目列表' },
) )
watch(() => problemID, init) watch(() => problemID, init)
@@ -9,7 +9,7 @@ import { parseTime } from 'utils/functions'
import type { ProblemSetClassView } from 'utils/types' import type { ProblemSetClassView } from 'utils/types'
/** /**
* 老师看一个班在题单里的情况(设计稿「题单重设计」老师 · 全班情况):真名 × 每道题, * 老师看一个班在专题里的情况(设计稿「题单重设计」老师 · 全班情况):真名 × 每道题,
* 格子里写几点做对 / 错了几次;紫色角 = 加入之前就做对过(可能是凭记忆默写的)。 * 格子里写几点做对 / 错了几次;紫色角 = 加入之前就做对过(可能是凭记忆默写的)。
* 没加入的人在上面点名。班级默认是服务端推断的(最近在做的那个班),可以换。 * 没加入的人在上面点名。班级默认是服务端推断的(最近在做的那个班),可以换。
* 点格子到提交列表看这个学生这道题的代码。 * 点格子到提交列表看这个学生这道题的代码。
@@ -70,7 +70,7 @@ const error = computed(() => tone('error'))
<template> <template>
<section class="card"> <section class="card">
<div v-if="data && !data.className" class="empty muted">还没有学生加入这个题单</div> <div v-if="data && !data.className" class="empty muted">还没有学生加入这个专题</div>
<template v-else-if="data"> <template v-else-if="data">
<div class="summary"> <div class="summary">
<n-select v-model:value="className" :options="classOptions" size="small" class="pick" :consistent-menu-width="false" /> <n-select v-model:value="className" :options="classOptions" size="small" class="pick" :consistent-menu-width="false" />
@@ -6,8 +6,8 @@ import type { ProblemSetLock } from 'utils/types'
/** /**
* 「旧代码为什么藏着」那一句(设计稿「题单重设计」旧代码藏着时学生看到的): * 「旧代码为什么藏着」那一句(设计稿「题单重设计」旧代码藏着时学生看到的):
* 题单《X》布置中,这道题以前的代码先藏着 · 在题单里做对,或 M月D日 之后就能看。 * 专题《X》布置中,这道题以前的代码先藏着 · 在专题里做对,或 M月D日 之后就能看。
* 规则在后端 problemSetLockCutoffs;查不到是哪个题单(比如刚过期)就说通用的一句 * 规则在后端 problemSetLockCutoffs;查不到是哪个专题(比如刚过期)就说通用的一句
*/ */
const props = defineProps<{ problem: { id: number } | { displayId: string } }>() const props = defineProps<{ problem: { id: number } | { displayId: string } }>()
const theme = useThemeVars() const theme = useThemeVars()
@@ -31,10 +31,10 @@ const first = computed(() => locks.value[0])
<template> <template>
<span v-if="first"> <span v-if="first">
题单<router-link :to="{ name: 'problemset', params: { problemSetId: first.problemSetId } }">《{{ first.title }}》</router-link 专题<router-link :to="{ name: 'problemset', params: { problemSetId: first.problemSetId } }">《{{ first.title }}》</router-link
>布置中,这道题以前的代码先藏着 · 在题单里做对,或 {{ parseTime(first.assignedUntil, 'M月D日') }} 之后就能看 >布置中,这道题以前的代码先藏着 · 在专题里做对,或 {{ parseTime(first.assignedUntil, 'M月D日') }} 之后就能看
</span> </span>
<span v-else>这道题正在题单里布置,以前的代码先藏着 · 在题单里做对,或者布置期结束就能看</span> <span v-else>这道题正在专题里布置,以前的代码先藏着 · 在专题里做对,或者布置期结束就能看</span>
</template> </template>
<style scoped> <style scoped>
+10 -10
View File
@@ -14,9 +14,9 @@ import BadgeLadder from './components/BadgeLadder.vue'
import ClassView from './components/ClassView.vue' import ClassView from './components/ClassView.vue'
/** /**
* 题单详情(设计稿「题单重设计」详情 B):顶上一条是进度和奖章阶梯,下面题目分两列。 * 专题详情(设计稿「题单重设计」详情 B):顶上一条是进度和奖章阶梯,下面题目分两列。
* *
* - 还没加入:题目看得见、点不进去,右边一个大按钮「加入题单,开始做」。加入是用户要留的 * - 还没加入:题目看得见、点不进去,右边一个大按钮「加入专题,开始做」。加入是用户要留的
* 一步(「只是打开就算加入的话,会有疑惑的」),所以这里要让人一眼看懂为什么要点。 * 一步(「只是打开就算加入的话,会有疑惑的」),所以这里要让人一眼看懂为什么要点。
* - 布置期内以前做对过的题标「以前做对过 · 旧代码先藏着」,规则见后端 problemSetLockCutoffs。 * - 布置期内以前做对过的题标「以前做对过 · 旧代码先藏着」,规则见后端 problemSetLockCutoffs。
* - 做完了:整条变绿,写几点做完、用了多久。烟花只在做完的那一刻放(做对最后一道、 * - 做完了:整条变绿,写几点做完、用了多久。烟花只在做完的那一刻放(做对最后一道、
@@ -79,7 +79,7 @@ const spent = computed(() => {
}) })
// 两栏放得下(每栏 420 起)才拆;拆完在窄屏上叠起来,就成了两张没有标题的卡片, // 两栏放得下(每栏 420 起)才拆;拆完在窄屏上叠起来,就成了两张没有标题的卡片,
// 看着像题单分了两组 // 看着像专题分了两组
const twoColumns = useMediaQuery('(min-width: 960px)') const twoColumns = useMediaQuery('(min-width: 960px)')
const columns = computed(() => { const columns = computed(() => {
@@ -132,14 +132,14 @@ const purple = { color: '#5b3fa8', background: 'rgba(122, 95, 208, 0.12)' }
</script> </script>
<template> <template>
<NotFound v-if="missing" title="这个题单不存在" hint="可能还没公开,或者已经删掉了" to="/problemset" to-label="回到题单列表" /> <NotFound v-if="missing" title="这个专题不存在" hint="可能还没公开,或者已经删掉了" to="/problemset" to-label="回到专题列表" />
<div v-else-if="set" class="page oj-page"> <div v-else-if="set" class="page oj-page">
<section class="band" :class="{ done: progress?.isCompleted }"> <section class="band" :class="{ done: progress?.isCompleted }">
<span v-if="progress?.isCompleted" class="done-mark" aria-hidden="true">✓</span> <span v-if="progress?.isCompleted" class="done-mark" aria-hidden="true">✓</span>
<div class="head"> <div class="head">
<div class="row"> <div class="row">
<router-link :to="{ name: 'problemsets' }" class="back">‹ 题单</router-link> <router-link :to="{ name: 'problemsets' }" class="back">‹ 专题</router-link>
<h2 class="ell">{{ set.title }}</h2> <h2 class="ell">{{ set.title }}</h2>
<span v-if="set.assigning" class="pill assign">布置到 {{ parseTime(set.assignedUntil!, 'M月D日') }}</span> <span v-if="set.assigning" class="pill assign">布置到 {{ parseTime(set.assignedUntil!, 'M月D日') }}</span>
</div> </div>
@@ -164,7 +164,7 @@ const purple = { color: '#5b3fa8', background: 'rgba(122, 95, 208, 0.12)' }
<div v-else class="muted small"> <div v-else class="muted small">
{{ set.createdBy.username }} 出的 · {{ set.problemsCount }} 道<template v-if="optional > 0">,{{ optional }} 道选做</template {{ set.createdBy.username }} 出的 · {{ set.problemsCount }} 道<template v-if="optional > 0">,{{ optional }} 道选做</template
><template v-else>,全部必做</template> ><template v-else>,全部必做</template>
<template v-if="!set.badges.length"> · 这个题单没有奖章</template> <template v-if="!set.badges.length"> · 这个专题没有奖章</template>
</div> </div>
<div v-if="set.description && set.description !== set.title" class="sec small desc"> <div v-if="set.description && set.description !== set.title" class="sec small desc">
{{ set.description }} {{ set.description }}
@@ -176,7 +176,7 @@ const purple = { color: '#5b3fa8', background: 'rgba(122, 95, 208, 0.12)' }
</template> </template>
<div class="spacer"></div> <div class="spacer"></div>
<div v-if="!joined && !userStore.isTeacherOrAbove" class="join"> <div v-if="!joined && !userStore.isTeacherOrAbove" class="join">
<n-button type="primary" size="large" :loading="joining" @click="join"> 加入题单,开始做 </n-button> <n-button type="primary" size="large" :loading="joining" @click="join"> 加入专题,开始做 </n-button>
</div> </div>
</section> </section>
@@ -196,14 +196,14 @@ const purple = { color: '#5b3fa8', background: 'rgba(122, 95, 208, 0.12)' }
<rect x="5" y="11" width="14" height="9" rx="2" /> <rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" /> <path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg> </svg>
其中 <b>{{ hiddenCount }}</b> 道你以前做对过。布置期内(到 {{ parseTime(set.assignedUntil!, 'M月D日') }})这几道的旧代码先藏起来,在题单里重新做对就能看。 其中 <b>{{ hiddenCount }}</b> 道你以前做对过。布置期内(到 {{ parseTime(set.assignedUntil!, 'M月D日') }})这几道的旧代码先藏起来,在专题里重新做对就能看。
</div> </div>
<div v-if="userStore.isTeacherOrAbove" class="tabs"> <div v-if="userStore.isTeacherOrAbove" class="tabs">
<button class="seg" :class="{ on: tab !== 'class' }" @click="tab = 'problems'">题目</button> <button class="seg" :class="{ on: tab !== 'class' }" @click="tab = 'problems'">题目</button>
<button class="seg" :class="{ on: tab === 'class' }" @click="tab = 'class'">全班情况</button> <button class="seg" :class="{ on: tab === 'class' }" @click="tab = 'class'">全班情况</button>
<div class="spacer"></div> <div class="spacer"></div>
<n-button v-if="canEdit" size="small" @click="router.push({ name: 'admin problemset edit', params: { problemSetId: set.id } })"> 编辑题单 </n-button> <n-button v-if="canEdit" size="small" @click="router.push({ name: 'admin problemset edit', params: { problemSetId: set.id } })"> 编辑专题 </n-button>
</div> </div>
<ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" /> <ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" />
@@ -249,7 +249,7 @@ const purple = { color: '#5b3fa8', background: 'rgba(122, 95, 208, 0.12)' }
</component> </component>
</section> </section>
</div> </div>
<n-empty v-else description="这个题单还没有题目" /> <n-empty v-else description="这个专题还没有题目" />
</template> </template>
</div> </div>
</template> </template>
+10 -10
View File
@@ -10,10 +10,10 @@ import type { ProblemSet } from 'utils/types'
import { badgeCondition, ladder, nextBadge } from './badges' import { badgeCondition, ladder, nextBadge } from './badges'
/** /**
* 题单列表(设计稿「题单重设计」列表 A):老师正在布置的题单单独一排大卡片 —— 做对几道、 * 专题列表(设计稿「题单重设计」列表 A):老师正在布置的专题单独一排大卡片 —— 做对几道、
* 布置到哪天、再做对几道拿哪个奖章;其余题单一排排小卡片,加入过的在前。 * 布置到哪天、再做对几道拿哪个奖章;其余专题一排排小卡片,加入过的在前。
* *
* 题单一共十几个,一次全取回来在前端分组,不分页 * 专题一共十几个,一次全取回来在前端分组,不分页
*/ */
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
@@ -82,11 +82,11 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
<template> <template>
<div class="page oj-page"> <div class="page oj-page">
<div class="top"> <div class="top">
<h2>题单</h2> <h2>专题</h2>
<div class="spacer"></div> <div class="spacer"></div>
<n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" /> <n-input v-model:value="keyword" placeholder="搜专题" clearable class="search" />
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 --> <!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/problemset/create">新建题单</AdminLink> <AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/problemset/create">新建专题</AdminLink>
</div> </div>
<template v-if="assigning.length"> <template v-if="assigning.length">
@@ -128,13 +128,13 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
<img v-for="badge in ladder(set)" :key="badge.id" :src="badge.icon" :alt="badge.name" :title="badge.name" class="icon" /> <img v-for="badge in ladder(set)" :key="badge.id" :src="badge.icon" :alt="badge.name" :title="badge.name" class="icon" />
<span>奖章全拿到了</span> <span>奖章全拿到了</span>
</div> </div>
<div v-else class="muted hint">这个题单没有奖章</div> <div v-else class="muted hint">这个专题没有奖章</div>
</a> </a>
</div> </div>
</template> </template>
<div v-if="others.length" class="sect"> <div v-if="others.length" class="sect">
{{ assigning.length ? '其他题单' : '题单' }} {{ assigning.length ? '其他专题' : '专题' }}
<span class="muted">{{ others.length }} 个</span> <span class="muted">{{ others.length }} 个</span>
</div> </div>
<div class="small-grid"> <div class="small-grid">
@@ -169,7 +169,7 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
</a> </a>
</div> </div>
<n-empty v-if="loaded && !shown.length" :description="keyword ? '没有找到这样的题单' : '还没有题单'" /> <n-empty v-if="loaded && !shown.length" :description="keyword ? '没有找到这样的专题' : '还没有专题'" />
</div> </div>
</template> </template>
@@ -254,7 +254,7 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
align-items: baseline; align-items: baseline;
} }
/* 1200 限宽下布置中是一行三张、其他题单一行四张;原来一行四张的大卡只有 280 宽, /* 1200 限宽下布置中是一行三张、其他专题一行四张;原来一行四张的大卡只有 280 宽,
「还没加入 · 9 道」和「另有 1 道选做」都被挤成两行 */ 「还没加入 · 9 道」和「另有 1 道选做」都被挤成两行 */
.big-grid { .big-grid {
display: grid; display: grid;
@@ -56,7 +56,7 @@ const gradeColor = computed(() => ({
C: tone('error').solid, C: tone('error').solid,
})) }))
/** 拿到 A / S 算完成(和题单、课堂条的口径一致) */ /** 拿到 A / S 算完成(和专题、课堂条的口径一致) */
const goodCount = computed(() => { const goodCount = computed(() => {
// 查几道题就要每道都拿到 A / S;没填题号时拿到一道就算 // 查几道题就要每道都拿到 A / S;没填题号时拿到一道就算
const need = Math.max(1, props.data.problemCount) const need = Math.max(1, props.data.problemCount)
@@ -92,7 +92,7 @@ function attendance(lesson: Lesson) {
/** 要画流程图的课写出来:这一行的次数和正确率连流程图一起数(画到 A / S 算对) */ /** 要画流程图的课写出来:这一行的次数和正确率连流程图一起数(画到 A / S 算对) */
function problemText(lesson: Lesson) { function problemText(lesson: Lesson) {
if (lesson.assigned) return `布置 ${lesson.problems.length} 道${lesson.mode && lesson.mode !== 'code' ? ` · ${LESSON_MODE_LABEL[lesson.mode]}` : ''}` if (lesson.assigned) return `布置 ${lesson.problems.length} 道${lesson.mode && lesson.mode !== 'code' ? ` · ${LESSON_MODE_LABEL[lesson.mode]}` : ''}`
return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道` return `${lesson.fromProblemSet ? '专题 ' : ''}${lesson.problems.length} 道`
} }
// ---------- 时间轴(只在今天以内) ---------- // ---------- 时间轴(只在今天以内) ----------
+4 -4
View File
@@ -65,11 +65,11 @@ export const useFlowchartStore = defineStore('flowchart', () => {
const latestRating = ref<FlowchartRating>({ score: 0, grade: '' }) const latestRating = ref<FlowchartRating>({ score: 0, grade: '' })
const phase = ref<FlowchartPhase>('idle') const phase = ref<FlowchartPhase>('idle')
/** /**
* 这一页上**刚刚**评完一次就加一(不含进页面时读到的历史)。课堂条标做完、题单里 * 这一页上**刚刚**评完一次就加一(不含进页面时读到的历史)。课堂条标做完、专题里
* 1.5 秒后跳回题单,都只认这一下 —— 进页面读到以前的 A 不该再跳一次 * 1.5 秒后跳回专题,都只认这一下 —— 进页面读到以前的 A 不该再跳一次
*/ */
const evaluatedSeq = ref(0) const evaluatedSeq = ref(0)
/** 这道题评完的每一次,早的在前;hidden = 题单布置期内被藏起来的次数 */ /** 这道题评完的每一次,早的在前;hidden = 专题布置期内被藏起来的次数 */
const scores = ref<FlowchartScores['scores']>([]) const scores = ref<FlowchartScores['scores']>([])
const hiddenCount = ref(0) const hiddenCount = ref(0)
const submissionCount = computed(() => scores.value.length) const submissionCount = computed(() => scores.value.length)
@@ -201,7 +201,7 @@ export const useFlowchartStore = defineStore('flowchart', () => {
} }
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => { const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
// 流程图画到 A/S 算完成之后,后端会在同一条用户通道上推题单徽章、成就(flowchart/run.ts 的 // 流程图画到 A/S 算完成之后,后端会在同一条用户通道上推专题徽章、成就(flowchart/run.ts 的
// recordSolvedAndNotify)。原来这里只认评分那两种,交代码的监视器又不一定开着,要等下次 // recordSolvedAndNotify)。原来这里只认评分那两种,交代码的监视器又不一定开着,要等下次
// 换路由拉 pending 才弹。队列按 id 去重,两条连接各收一遍也只弹一次 // 换路由拉 pending 才弹。队列按 id 去重,两条连接各收一遍也只弹一次
const frame = data as unknown as { type: string; achievements?: QueuedAchievement[] } const frame = data as unknown as { type: string; achievements?: QueuedAchievement[] }
+2 -2
View File
@@ -11,7 +11,7 @@ export const useProblemStore = defineStore('problem', () => {
* 本次会话里新增的失败提交数。**只是增量**,历史失败数看 `problem.myFailedCount`。 * 本次会话里新增的失败提交数。**只是增量**,历史失败数看 `problem.myFailedCount`。
* *
* 原来 failCount 就是这一个从 0 起数的 ref,于是「失败 3 次解锁 AI 提示」实际变成了 * 原来 failCount 就是这一个从 0 起数的 ref,于是「失败 3 次解锁 AI 提示」实际变成了
* 「在当前这次页面会话里再失败 3 次」:刷新一下、从题单跳进跳出一次就清零, * 「在当前这次页面会话里再失败 3 次」:刷新一下、从专题跳进跳出一次就清零,
* 昨天在这题上撞了十次墙的学生今天进来照样看不到按钮。而后端的闸门数的是数据库里 * 昨天在这题上撞了十次墙的学生今天进来照样看不到按钮。而后端的闸门数的是数据库里
* 的历史失败数,两边根本不是一回事。 * 的历史失败数,两边根本不是一回事。
*/ */
@@ -28,7 +28,7 @@ export const useProblemStore = defineStore('problem', () => {
const failCount = computed(() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value) const failCount = computed(() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value)
/** /**
* 这道题能不能画流程图。比赛里不给(入口差异表);题单里原来也不给,现在给了,画到 A/S * 这道题能不能画流程图。比赛里不给(入口差异表);专题里原来也不给,现在给了,画到 A/S
* 算完成(设计文档第 3 节决定 4)。手机上原来不给(节点库是 HTML5 拖放,拖不动), * 算完成(设计文档第 3 节决定 4)。手机上原来不给(节点库是 HTML5 拖放,拖不动),
* 2026-10 换成插入式编辑器之后只用点,手机上也能画 * 2026-10 换成插入式编辑器之后只用点,手机上也能画
*/ */
+4 -4
View File
@@ -17,10 +17,10 @@ import type { SubmitCodePayload } from 'utils/types'
* *
* 原来它们都长在提交按钮那个组件(SubmitCode)里,而那个组件在编辑器的工具栏里 —— * 原来它们都长在提交按钮那个组件(SubmitCode)里,而那个组件在编辑器的工具栏里 ——
* 手机上交完切到「描述」页签(Naive 的页签默认切走即卸载)、桌面切到「题目 / 自测」 * 手机上交完切到「描述」页签(Naive 的页签默认切走即卸载)、桌面切到「题目 / 自测」
* 模式,编辑器一卸载,判题结果、AI 提示、失败计数、通过后的点评和回题单全都丢了。 * 模式,编辑器一卸载,判题结果、AI 提示、失败计数、通过后的点评和回专题全都丢了。
* 挪到 store 之后组件卸了再挂回来,状态还在。 * 挪到 store 之后组件卸了再挂回来,状态还在。
* *
* 判完之后「该发生什么」(失败计数、标成已解决、烟花、点评、回题单)不在这里, * 判完之后「该发生什么」(失败计数、标成已解决、烟花、点评、回专题)不在这里,
* 在页面这一层的 SubmissionEffects —— 它们要路由守卫和弹窗,得挂在一直在的组件上。 * 在页面这一层的 SubmissionEffects —— 它们要路由守卫和弹窗,得挂在一直在的组件上。
*/ */
export type ResultSegment = 'submit' | 'samples' | 'custom' export type ResultSegment = 'submit' | 'samples' | 'custom'
@@ -155,8 +155,8 @@ export const useSubmissionStore = defineStore('submission', () => {
if (entry.contestId) { if (entry.contestId) {
data.contestId = parseInt(entry.contestId) data.contestId = parseInt(entry.contestId)
} }
// 从题单入口进来的,把来源题单一起报上去:提交列表要据此标出「来自题单」。 // 从专题入口进来的,把来源专题一起报上去:提交列表要据此标出「来自专题」。
// 只是来源标记,题单进度仍由后端判完之后自己记账(judge/run.ts) // 只是来源标记,专题进度仍由后端判完之后自己记账(judge/run.ts)
if (entry.problemSetId) { if (entry.problemSetId) {
data.problemSetId = parseInt(entry.problemSetId) data.problemSetId = parseInt(entry.problemSetId)
} }
@@ -193,9 +193,9 @@ const canRetry = computed(
<div v-if="!row.showLink" class="locked"> <div v-if="!row.showLink" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" /> <Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 --> <!-- 列表项里没有 userId,分不出是别人的还是自己被专题挡住的,文案两种都说得通 -->
<span class="locked-title">这张流程图看不到</span> <span class="locked-title">这张流程图看不到</span>
<span class="muted">别人画的看不到;自己以前画的在题单布置期内先藏着,在题单里做对或者布置期结束就能看到。</span> <span class="muted">别人画的看不到;自己以前画的在专题布置期内先藏着,在专题里做对或者布置期结束就能看到。</span>
</div> </div>
<div v-else class="body"> <div v-else class="body">
<div class="graph-box"> <div class="graph-box">
@@ -172,9 +172,9 @@ function openStandalone() {
class="problemset" class="problemset"
:href="'/problemset/' + row.problemSet.id" :href="'/problemset/' + row.problemSet.id"
target="_blank" target="_blank"
:title="'从题单「' + row.problemSet.title + '」交的'" :title="'从专题「' + row.problemSet.title + '」交的'"
> >
题单 {{ row.problemSet.title }} 专题 {{ row.problemSet.title }}
</a> </a>
</span> </span>
<div class="spacer"></div> <div class="spacer"></div>
@@ -243,7 +243,7 @@ function openStandalone() {
<n-button type="primary" @click="emit('openProblem', row)"> 去做 {{ row.problemDisplayId }} {{ row.problemTitle }} </n-button> <n-button type="primary" @click="emit('openProblem', row)"> 去做 {{ row.problemDisplayId }} {{ row.problemTitle }} </n-button>
</template> </template>
<template v-else-if="locked === 'problemset'"> <template v-else-if="locked === 'problemset'">
<span class="locked-title">这份代码在题单布置期内先藏着</span> <span class="locked-title">这份代码在专题布置期内先藏着</span>
<span class="muted"><LockNote :problem="{ displayId: row.problemDisplayId }" /></span> <span class="muted"><LockNote :problem="{ displayId: row.problemDisplayId }" /></span>
</template> </template>
<template v-else> <template v-else>
@@ -132,7 +132,7 @@ function attemptTime(time: string) {
/** 除了正在看的这次,还有别的能点开的才提 ← → */ /** 除了正在看的这次,还有别的能点开的才提 ← → */
const canStep = computed(() => attempts.value.some((item) => item.showLink && item.id !== shown.value?.id)) const canStep = computed(() => attempts.value.some((item) => item.showLink && item.id !== shown.value?.id))
/** 「← →」一次次往前往后翻,跳过题单藏着的那几次 */ /** 「← →」一次次往前往后翻,跳过专题藏着的那几次 */
function step(delta: -1 | 1) { function step(delta: -1 | 1) {
const list = attempts.value const list = attempts.value
for (let i = attemptIndex.value + delta; i >= 0 && i < list.length; i += delta) { for (let i = attemptIndex.value + delta; i >= 0 && i < list.length; i += delta) {
@@ -233,8 +233,8 @@ function handleMore(key: string) {
<span class="problem-id">{{ shown.problemDisplayId }}</span> <span class="problem-id">{{ shown.problemDisplayId }}</span>
{{ shown.problemTitle }} {{ shown.problemTitle }}
</a> </a>
<a v-if="shown.problemSet" class="problemset" :href="'/problemset/' + shown.problemSet.id" :title="'从题单「' + shown.problemSet.title + '」交的'"> <a v-if="shown.problemSet" class="problemset" :href="'/problemset/' + shown.problemSet.id" :title="'从专题「' + shown.problemSet.title + '」交的'">
题单 {{ shown.problemSet.title }} 专题 {{ shown.problemSet.title }}
</a> </a>
<div class="spacer"></div> <div class="spacer"></div>
<div class="actions"> <div class="actions">
@@ -297,7 +297,7 @@ function handleMore(key: string) {
<div v-if="!shown.showLink" class="locked"> <div v-if="!shown.showLink" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" /> <Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<span class="locked-title">这份代码在题单布置期内先藏着</span> <span class="locked-title">这份代码在专题布置期内先藏着</span>
<span class="muted"><LockNote :problem="{ displayId: shown.problemDisplayId }" /></span> <span class="muted"><LockNote :problem="{ displayId: shown.problemDisplayId }" /></span>
</div> </div>
<CodeView <CodeView
+4 -4
View File
@@ -531,7 +531,7 @@ function languageText(row: SubmissionListItem) {
return LANGUAGE_SHOW_VALUE[row.language] ?? row.language return LANGUAGE_SHOW_VALUE[row.language] ?? row.language
} }
/** 学生看别人的那几行:名字淡一档、带一把锁(自己的看不了是题单那道闸,另说) */ /** 学生看别人的那几行:名字淡一档、带一把锁(自己的看不了是专题那道闸,另说) */
/** /**
* 演示模式(超管把界面装成学生投屏):后端认的还是超管,每一行都给了 showLink。 * 演示模式(超管把界面装成学生投屏):后端认的还是超管,每一行都给了 showLink。
* 学生是看不到别人代码的,投屏时也不该露出来 —— 别人的行一律当成看不到 * 学生是看不到别人代码的,投屏时也不该露出来 —— 别人的行一律当成看不到
@@ -795,7 +795,7 @@ function dayBreak(index: number) {
<span class="title"> <span class="title">
<span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }} <span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }}
</span> </span>
<span v-if="row.problemSet" class="ps" :title="'从题单「' + row.problemSet.title + '」交的'"> 题单 </span> <span v-if="row.problemSet" class="ps" :title="'从专题「' + row.problemSet.title + '」交的'"> 专题 </span>
</span> </span>
<span class="c-lang">{{ languageText(row) }}</span> <span class="c-lang">{{ languageText(row) }}</span>
<span class="c-state"><StatusPill :result="row.result" /></span> <span class="c-state"><StatusPill :result="row.result" /></span>
@@ -804,7 +804,7 @@ function dayBreak(index: number) {
<span <span
v-if="!row.showLink" v-if="!row.showLink"
class="lock-hint" class="lock-hint"
:title="othersRow(row) ? '别人的代码看不到' : '题单布置期内先藏着:在题单里做对,或者布置期结束就能看'" :title="othersRow(row) ? '别人的代码看不到' : '专题布置期内先藏着:在专题里做对,或者布置期结束就能看'"
> >
<Icon icon="ph:lock-simple" :width="13" /> <Icon icon="ph:lock-simple" :width="13" />
</span> </span>
@@ -819,7 +819,7 @@ function dayBreak(index: number) {
<div class="m-line sub"> <div class="m-line sub">
<span class="m-time">{{ submissionClockText(row.createTime) }}</span> <span class="m-time">{{ submissionClockText(row.createTime) }}</span>
<span class="title" :title="`${row.problemDisplayId} ${row.problemTitle}`"> {{ row.problemDisplayId }} {{ row.problemTitle }} </span> <span class="title" :title="`${row.problemDisplayId} ${row.problemTitle}`"> {{ row.problemDisplayId }} {{ row.problemTitle }} </span>
<span v-if="row.problemSet" class="ps">题单</span> <span v-if="row.problemSet" class="ps">专题</span>
<div class="spacer"></div> <div class="spacer"></div>
<span class="m-lang">{{ languageText(row) }}</span> <span class="m-lang">{{ languageText(row) }}</span>
<Icon v-if="!teacher && !row.showLink" icon="ph:lock-simple" :width="13" class="m-lock" /> <Icon v-if="!teacher && !row.showLink" icon="ph:lock-simple" :width="13" class="m-lock" />
+1 -1
View File
@@ -309,7 +309,7 @@ export const admins: RouteRecordRaw = {
component: () => import('admin/task/list.vue'), component: () => import('admin/task/list.vue'),
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
// 题单管理路由 // 专题管理路由
{ {
path: 'problemset/list', path: 'problemset/list',
name: 'admin problemset list', name: 'admin problemset list',
@@ -4,7 +4,7 @@ import { Icon } from '@iconify/vue'
// 成就图标统一走 iconify(noto 彩色 emoji 图标集),渲染出来是 SVG, // 成就图标统一走 iconify(noto 彩色 emoji 图标集),渲染出来是 SVG,
// 老浏览器缺 emoji 字体也能正常显示。 // 老浏览器缺 emoji 字体也能正常显示。
// //
// 三种形态按顺序判断,顺序不能换:题单奖章的 icon 是图片 URL, // 三种形态按顺序判断,顺序不能换:专题奖章的 icon 是图片 URL,
// 而 https:// 里也带冒号,先判 iconify 会把 URL 当成图标名。 // 而 https:// 里也带冒号,先判 iconify 会把 URL 当成图标名。
// 存量成就的 icon 还可能是 emoji 字符,最后原样当文本兜底。 // 存量成就的 icon 还可能是 emoji 字符,最后原样当文本兜底。
const props = withDefaults(defineProps<{ icon: string; size?: number }>(), { const props = withDefaults(defineProps<{ icon: string; size?: number }>(), {
+2 -2
View File
@@ -2,8 +2,8 @@
import { RouterLink, type RouteLocationRaw } from 'vue-router' import { RouterLink, type RouteLocationRaw } from 'vue-router'
/** /**
* 前台页面上直达后台对应功能的按钮(「编辑这一课」「新建比赛」……),和题单页的 * 前台页面上直达后台对应功能的按钮(「编辑这一课」「新建比赛」……),和专题页的
* 「编辑题单」、比赛页的「比赛设置」同一种样子。渲染成真的 <a>:老师常要一边看前台 * 「编辑专题」、比赛页的「比赛设置」同一种样子。渲染成真的 <a>:老师常要一边看前台
* 一边改,中键 / Ctrl 点开新标签页得能用。给谁看由调用方用 v-if 管,要和 * 一边改,中键 / Ctrl 点开新标签页得能用。给谁看由调用方用 v-if 管,要和
* routes.ts 里目标路由的 meta 对齐,否则点下去被守卫静默踢回首页。 * routes.ts 里目标路由的 meta 对齐,否则点下去被守卫静默踢回首页。
*/ */
+2 -2
View File
@@ -80,7 +80,7 @@ function renderIcon(icon: string) {
* 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。 * 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。
* `key` 就是一级路径,用来判定高亮(见上面的 active)。 * `key` 就是一级路径,用来判定高亮(见上面的 active)。
* *
* 顺序是 自学 / 题目 / 提交 / 题单 / 比赛 / 排名 / 公告。「自学」进自学首页(/learn), * 顺序是 自学 / 题目 / 提交 / 专题 / 比赛 / 排名 / 公告。「自学」进自学首页(/learn),
* 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。 * 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
* 公告 2026-10-13 回到顶栏(用户要的):首页只放置顶那条了,别的公告得有个地方点得到。 * 公告 2026-10-13 回到顶栏(用户要的):首页只放置顶那条了,别的公告得有个地方点得到。
* 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。 * 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。
@@ -97,7 +97,7 @@ const navLinks = computed<NavLink[]>(() =>
{ key: 'learn', label: '自学', to: '/learn' }, { key: 'learn', label: '自学', to: '/learn' },
{ key: 'problem', label: '题目', to: '/problem' }, { key: 'problem', label: '题目', to: '/problem' },
{ key: 'submission', label: '提交', to: '/submission', show: userStore.showSubmissions }, { key: 'submission', label: '提交', to: '/submission', show: userStore.showSubmissions },
{ key: 'problemset', label: '题单', to: '/problemset' }, { key: 'problemset', label: '专题', to: '/problemset' },
{ key: 'contest', label: '比赛', to: '/contest' }, { key: 'contest', label: '比赛', to: '/contest' },
{ key: 'rank', label: '排名', to: '/rank' }, { key: 'rank', label: '排名', to: '/rank' },
{ key: 'announcement', label: '公告', to: '/announcement' }, { key: 'announcement', label: '公告', to: '/announcement' },
@@ -5,7 +5,7 @@ import { useThemeVars } from 'naive-ui'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
/** /**
* 课上老师改了这节课的题:右下角一张卡,写清改了哪些,点「刷新」重新载入页面拿新的题单。 * 课上老师改了这节课的题:右下角一张卡,写清改了哪些,点「刷新」重新载入页面拿新的专题。
* 不自己消失 —— 学生可能正低头写代码,几秒就没的提示等于没提示。代码草稿边写边存,刷新不丢。 * 不自己消失 —— 学生可能正低头写代码,几秒就没的提示等于没提示。代码草稿边写边存,刷新不丢。
*/ */
const update = pendingLessonUpdate const update = pendingLessonUpdate
+1 -1
View File
@@ -4,7 +4,7 @@ import { useThemeVars } from 'naive-ui'
import type { RouteLocationRaw } from 'vue-router' import type { RouteLocationRaw } from 'vue-router'
/** /**
* 「找不到」的整页提示:地址打错、题目 / 比赛 / 题单不存在或没公开、比赛还没开始。 * 「找不到」的整页提示:地址打错、题目 / 比赛 / 专题不存在或没公开、比赛还没开始。
* 原来各页各写各的 —— 打错地址是一整页空白、题号不对只有一个「无数据」,学生看不出出了什么事。 * 原来各页各写各的 —— 打错地址是一整页空白、题号不对只有一个「无数据」,学生看不出出了什么事。
* 样子跟提交详情的「这条提交不存在」一致:图标、一句话、一句为什么、一个回去的按钮。 * 样子跟提交详情的「这条提交不存在」一致:图标、一句话、一句为什么、一个回去的按钮。
*/ */
@@ -31,7 +31,7 @@ const keyword = ref('')
const focused = ref(false) const focused = ref(false)
const input = useTemplateRef<{ blur: () => void }>('input') const input = useTemplateRef<{ blur: () => void }>('input')
/** 题目页的三种入口(题库 / 比赛 / 题单)顶上都有自己的上下文条 */ /** 题目页的三种入口(题库 / 比赛 / 专题)顶上都有自己的上下文条 */
const PROBLEM_PAGES = new Set(['problem', 'contest problem', 'problemset problem']) const PROBLEM_PAGES = new Set(['problem', 'contest problem', 'problemset problem'])
const onProblemPage = computed(() => PROBLEM_PAGES.has(String(route.name))) const onProblemPage = computed(() => PROBLEM_PAGES.has(String(route.name)))
+1 -1
View File
@@ -62,7 +62,7 @@ const NAV: NavGroup[] = [
role: 'teacher', role: 'teacher',
}, },
{ {
label: '题单', label: '专题',
icon: 'ph:list-checks', icon: 'ph:list-checks',
path: '/admin/problemset', path: '/admin/problemset',
to: '/admin/problemset/list', to: '/admin/problemset/list',
+2 -2
View File
@@ -9,13 +9,13 @@ import type { QueuedAchievement } from 'utils/types'
* - 推(增强):WebSocket 只在用户当场停留在问题页时把延迟压到几百毫秒 * - 推(增强):WebSocket 只在用户当场停留在问题页时把延迟压到几百毫秒
* *
* 之所以不能只靠推:前端 WebSocket 不是常驻连接,只在问题页且有提交监听时 * 之所以不能只靠推:前端 WebSocket 不是常驻连接,只在问题页且有提交监听时
* 才建连,纯推会丢消息(尤其是题单奖章,那些页面根本没建连接)。 * 才建连,纯推会丢消息(尤其是专题奖章,那些页面根本没建连接)。
*/ */
export const useAchievementStore = defineStore('achievement', () => { export const useAchievementStore = defineStore('achievement', () => {
const queue = ref<QueuedAchievement[]>([]) const queue = ref<QueuedAchievement[]>([])
const current = ref<QueuedAchievement | null>(null) const current = ref<QueuedAchievement | null>(null)
// 成就和题单奖章的 id 来自两张不同的表,数值会重叠, // 成就和专题奖章的 id 来自两张不同的表,数值会重叠,
// 只按 id 去重会让奖章 5 把成就 5 挤掉 // 只按 id 去重会让奖章 5 把成就 5 挤掉
function keyOf(item: QueuedAchievement) { function keyOf(item: QueuedAchievement) {
return `${item.kind ?? 'achievement'}:${item.id}` return `${item.kind ?? 'achievement'}:${item.id}`
+1 -1
View File
@@ -16,7 +16,7 @@ type ClassOption = { label: string; value: string }
* 全站的班级下拉都按年级分组:「25 级」底下列 251、252……,新的年级在上,组里按班号排 * 全站的班级下拉都按年级分组:「25 级」底下列 251、252……,新的年级在上,组里按班号排
* (11 班排在 2 班后面)。年级是班号前两位,和排名的「本年级」同一个口径。 * (11 班排在 2 班后面)。年级是班号前两位,和排名的「本年级」同一个口径。
* `first` 给了就把那个年级提到最前(班级 PK 加班时先给同年级的)。 * `first` 给了就把那个年级提到最前(班级 PK 加班时先给同年级的)。
* `toOption` 换掉选项长相(题单的「n/m 人加入」、登录框的 ks 前缀);不是数字班号的 * `toOption` 换掉选项长相(专题的「n/m 人加入」、登录框的 ks 前缀);不是数字班号的
* 名字不分组,按原顺序跟在最后。 * 名字不分组,按原顺序跟在最后。
*/ */
export function groupClassOptions<T extends ClassOption = ClassOption>( export function groupClassOptions<T extends ClassOption = ClassOption>(
+1 -1
View File
@@ -171,7 +171,7 @@ export interface AdminProblemRow {
triedUsers: number triedUsers: number
} }
// 题单相关类型 // 专题相关类型
export type { export type {
ProblemSet, ProblemSet,
ProblemSetList, ProblemSetList,
+1 -1
View File
@@ -62,7 +62,7 @@ export const pendingAchievementSchema = z.object({
/** /**
* WebSocket 推来的解锁通知。比 `/achievements/pending` 多一个 `kind` —— * WebSocket 推来的解锁通知。比 `/achievements/pending` 多一个 `kind` ——
* 题单奖章和成就来自两张表、id 会重叠,前端靠它区分(见 events.ts 的 publishAchievementNotification)。 * 专题奖章和成就来自两张表、id 会重叠,前端靠它区分(见 events.ts 的 publishAchievementNotification)。
*/ */
export const achievementNotificationSchema = pendingAchievementSchema.extend({ export const achievementNotificationSchema = pendingAchievementSchema.extend({
kind: z.enum(['achievement', 'badge']), kind: z.enum(['achievement', 'badge']),
+5 -5
View File
@@ -462,7 +462,7 @@ export const updateAcmHelperRequestSchema = z.object({
checked: z.boolean(), checked: z.boolean(),
}) })
// ---------------------------------------------------------------- 题单管理 // ---------------------------------------------------------------- 专题管理
export const adminProblemSetSchema = z.object({ export const adminProblemSetSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -474,13 +474,13 @@ export const adminProblemSetSchema = z.object({
assignedAt: z.string().nullable(), assignedAt: z.string().nullable(),
/** 现在在不在布置期内(服务端的钟) */ /** 现在在不在布置期内(服务端的钟) */
assigning: z.boolean(), assigning: z.boolean(),
/** 公开:学生能在题单列表里看到、能进 */ /** 公开:学生能在专题列表里看到、能进 */
visible: z.boolean(), visible: z.boolean(),
createdBy: sampleUserSchema, createdBy: sampleUserSchema,
createTime: z.string(), createTime: z.string(),
lastUpdateTime: z.string(), lastUpdateTime: z.string(),
problemsCount: z.number().int(), problemsCount: z.number().int(),
/** 加入这份题单的人数,后台用来判断改动会影响多少人 */ /** 加入这个专题的人数,后台用来判断改动会影响多少人 */
participantCount: z.number().int(), participantCount: z.number().int(),
/** 做完(必做题全对)的人数 */ /** 做完(必做题全对)的人数 */
completedCount: z.number().int(), completedCount: z.number().int(),
@@ -519,7 +519,7 @@ export const addProblemsToSetResultSchema = z.object({
added: z.array(z.string()), added: z.array(z.string()),
/** 题号不存在、不可见或是比赛题 */ /** 题号不存在、不可见或是比赛题 */
missing: z.array(z.string()), missing: z.array(z.string()),
/** 本来就在题单里 */ /** 本来就在专题里 */
duplicate: z.array(z.string()), duplicate: z.array(z.string()),
}) })
@@ -527,7 +527,7 @@ export const updateProblemInSetRequestSchema = z.object({
isRequired: z.boolean(), isRequired: z.boolean(),
}) })
/** 拖动排序:题单里题目的链接 id,按新顺序给全 */ /** 拖动排序:专题里题目的链接 id,按新顺序给全 */
export const reorderProblemSetProblemsRequestSchema = z.object({ export const reorderProblemSetProblemsRequestSchema = z.object({
ids: z.array(z.number().int()).min(1), ids: z.array(z.number().int()).min(1),
}) })
+2 -2
View File
@@ -66,7 +66,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
@@ -75,7 +75,7 @@ export function isFlowchartPass(grade: string | null | undefined) {
return (FLOWCHART_PASS_GRADES as readonly string[]).includes(grade ?? '') return (FLOWCHART_PASS_GRADES as readonly string[]).includes(grade ?? '')
} }
/** 自己在一道题上评完的历次分数,早的在前;hidden = 被题单闸门藏起来的次数 */ /** 自己在一道题上评完的历次分数,早的在前;hidden = 被专题闸门藏起来的次数 */
export const flowchartScoresSchema = z.object({ export const flowchartScoresSchema = z.object({
scores: z.array( scores: z.array(
z.object({ z.object({
+1 -1
View File
@@ -472,7 +472,7 @@ export const problemListItemSchema = z.object({
hasAstRules: z.boolean(), hasAstRules: z.boolean(),
/** SQL 题(可选语言里有 SQL):写 SQL 语句查数据库,和写 C / Python 是两回事,列表里单独标出来 */ /** SQL 题(可选语言里有 SQL):写 SQL 语句查数据库,和写 C / Python 是两回事,列表里单独标出来 */
isSql: z.boolean(), isSql: z.boolean(),
/** 0 = 做对了。画流程图的题,流程图评到 A / S 也给 0(和题单、课堂条一个口径) */ /** 0 = 做对了。画流程图的题,流程图评到 A / S 也给 0(和专题、课堂条一个口径) */
myStatus: z.number().int().nullable(), myStatus: z.number().int().nullable(),
/** /**
* 做对的人 / 交过的人,和题目页「统计」页签同一个口径(代码提交、不算还在判的)。 * 做对的人 / 交过的人,和题目页「统计」页签同一个口径(代码提交、不算还在判的)。
+12 -12
View File
@@ -4,7 +4,7 @@ import { paginatedSchema, sampleUserSchema } from './common'
import { maskedProblemDifficultySchema } from './problem' import { maskedProblemDifficultySchema } from './problem'
/** /**
* 我在这份题单里的进度。 * 我在这个专题里的进度。
* *
* 两个计数口径不同,别混用: * 两个计数口径不同,别混用:
* - completedCount / totalCount 只数**必做题**,进度条和「做完了」看它; * - completedCount / totalCount 只数**必做题**,进度条和「做完了」看它;
@@ -60,9 +60,9 @@ export const problemSetSchema = z.object({
export const problemSetListSchema = paginatedSchema(problemSetSchema) export const problemSetListSchema = paginatedSchema(problemSetSchema)
/** /**
* 题单页的题目行只渲染题号、标题,所以只下发这几样(外加题目主键)。 * 专题页的题目行只渲染题号、标题,所以只下发这几样(外加题目主键)。
* 以前复用 problemListItemSchema,服务端每次要多 join 一次 user / 标签表, * 以前复用 problemListItemSchema,服务端每次要多 join 一次 user / 标签表,
* 而那些字段题单页一个都不渲染。 * 而那些字段专题页一个都不渲染。
*/ */
export const problemSetProblemItemSchema = z.object({ export const problemSetProblemItemSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -77,11 +77,11 @@ export const problemSetProblemSchema = z.object({
problem: problemSetProblemItemSchema, problem: problemSetProblemItemSchema,
order: z.number().int(), order: z.number().int(),
isRequired: z.boolean(), isRequired: z.boolean(),
/** 在这份题单里做对了没有(代码通过,或流程图拿到 A / S) */ /** 在这个专题里做对了没有(代码通过,或流程图拿到 A / S) */
isCompleted: z.boolean(), isCompleted: z.boolean(),
/** 在这份题单里做对的时刻 */ /** 在这个专题里做对的时刻 */
solvedTime: z.string().nullable(), solvedTime: z.string().nullable(),
/** 在这份题单里交错了几次(还没做对时显示「交过 n 次」) */ /** 在这个专题里交错了几次(还没做对时显示「交过 n 次」) */
wrongCount: z.number().int(), wrongCount: z.number().int(),
/** /**
* 布置期内、以前做对过、旧代码正被藏着 —— 行上标「以前做对过 · 旧代码先藏着」。 * 布置期内、以前做对过、旧代码正被藏着 —— 行上标「以前做对过 · 旧代码先藏着」。
@@ -94,11 +94,11 @@ export const joinProblemSetRequestSchema = z.object({
problemSetId: z.number().int().positive(), problemSetId: z.number().int().positive(),
}) })
/** 老师看某个班在这份题单里的情况:真名 × 每道题 */ /** 老师看某个班在这个专题里的情况:真名 × 每道题 */
export const problemSetClassCellSchema = z.object({ export const problemSetClassCellSchema = z.object({
/** 在题单里做对的时刻 */ /** 在专题里做对的时刻 */
solvedTime: z.string().nullable(), solvedTime: z.string().nullable(),
/** 在题单里交错的次数 */ /** 在专题里交错的次数 */
wrongCount: z.number().int(), wrongCount: z.number().int(),
/** 加入之前就做对过这道题(任何入口) */ /** 加入之前就做对过这道题(任何入口) */
solvedBefore: z.boolean(), solvedBefore: z.boolean(),
@@ -116,7 +116,7 @@ export const problemSetClassStudentSchema = z.object({
}) })
export const problemSetClassViewSchema = z.object({ export const problemSetClassViewSchema = z.object({
/** 加入过这份题单的学生分属哪些班:三分之一以上的人加入了的班在前(按最近一次加入),零星几个人的在后 */ /** 加入过这个专题的学生分属哪些班:三分之一以上的人加入了的班在前(按最近一次加入),零星几个人的在后 */
classes: z.array(z.object({ className: z.string(), joined: z.number().int(), size: z.number().int() })), classes: z.array(z.object({ className: z.string(), joined: z.number().int(), size: z.number().int() })),
/** 这次看的是哪个班;谁都没加入时为 null */ /** 这次看的是哪个班;谁都没加入时为 null */
className: z.string().nullable(), className: z.string().nullable(),
@@ -126,7 +126,7 @@ export const problemSetClassViewSchema = z.object({
_id: z.string(), _id: z.string(),
title: z.string(), title: z.string(),
isRequired: z.boolean(), isRequired: z.boolean(),
/** 这个班在题单里做对的人数 */ /** 这个班在专题里做对的人数 */
solved: z.number().int(), solved: z.number().int(),
}), }),
), ),
@@ -136,7 +136,7 @@ export const problemSetClassViewSchema = z.object({
students: z.array(problemSetClassStudentSchema), students: z.array(problemSetClassStudentSchema),
}) })
/** 某道题为什么藏着旧代码:落在哪些正在布置的题单里 */ /** 某道题为什么藏着旧代码:落在哪些正在布置的专题里 */
export const problemSetLockSchema = z.object({ export const problemSetLockSchema = z.object({
problemSetId: z.number().int(), problemSetId: z.number().int(),
title: z.string(), title: z.string(),
+9 -9
View File
@@ -185,11 +185,11 @@ export const createSubmissionRequestSchema = z.object({
.max(1024 * 1024), .max(1024 * 1024),
contestId: z.number().int().positive().optional(), contestId: z.number().int().positive().optional(),
/** /**
* 来源题单。学生从 `/problemset/:id/problem/:pid` 那个入口提交时前端带上, * 来源专题。学生从 `/problemset/:id/problem/:pid` 那个入口提交时前端带上,
* 后端落进 `submission.problemset_id`,提交列表据此标出「这条是刷题单刷出来的」。 * 后端落进 `submission.problemset_id`,提交列表据此标出「这条是刷专题刷出来的」。
* *
* 只是**来源标记**,不参与判题、也不参与题单进度记账 —— 进度由判完之后的 * 只是**来源标记**,不参与判题、也不参与专题进度记账 —— 进度由判完之后的
* `recordSolvedProblem` 记进所有已加入且含这道题的题单,和从哪个入口进来无关。 * `recordSolvedProblem` 记进所有已加入且含这道题的专题,和从哪个入口进来无关。
* 所以这里带错了顶多是标记不准,不会影响成绩。 * 所以这里带错了顶多是标记不准,不会影响成绩。
*/ */
problemSetId: z.number().int().positive().optional(), problemSetId: z.number().int().positive().optional(),
@@ -249,10 +249,10 @@ export const submissionDetailSchema = z.object({
problemTitle: z.string(), problemTitle: z.string(),
/** 提交者**现在**的班级(和提交列表同口径);已删号的为 null */ /** 提交者**现在**的班级(和提交列表同口径);已删号的为 null */
className: z.string().nullable(), className: z.string().nullable(),
/** 从哪个题单交的,同列表的 problemSet */ /** 从哪个专题交的,同列表的 problemSet */
problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(), problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(),
/** /**
* false = 自己的这份代码在题单布置期内先藏着(见后端 problemSetLockCutoffs):code 为空串、 * false = 自己的这份代码在专题布置期内先藏着(见后端 problemSetLockCutoffs):code 为空串、
* statisticInfo / info 为空对象,其余照给,页面照样摆出是谁、哪道题、什么结果。 * statisticInfo / info 为空对象,其余照给,页面照样摆出是谁、哪道题、什么结果。
* 别人的代码看不到时不走这里,直接 403 * 别人的代码看不到时不走这里,直接 403
*/ */
@@ -262,7 +262,7 @@ export const submissionDetailSchema = z.object({
/** /**
* 同一个人在同一道题、同一场比赛(或都不在比赛里)交过的每一次,按时间先后。只有带 * 同一个人在同一道题、同一场比赛(或都不在比赛里)交过的每一次,按时间先后。只有带
* `?attempts=1` 时才给(独立的提交详情页用),否则为 null —— 题目页每判完一次都要拉一遍 * `?attempts=1` 时才给(独立的提交详情页用),否则为 null —— 题目页每判完一次都要拉一遍
* 详情,用不着这一串。showLink 的口径同上,题单藏着的那几次点不进去 * 详情,用不着这一串。showLink 的口径同上,专题藏着的那几次点不进去
*/ */
attempts: z attempts: z
.array( .array(
@@ -310,7 +310,7 @@ export const submissionListItemSchema = z.object({
language: problemLanguageSchema, language: problemLanguageSchema,
statisticInfo: statisticInfoSchema, statisticInfo: statisticInfoSchema,
/** /**
* 来源题单,非题单入口提交的为 null。比赛提交恒为 null(比赛题不会进题单)。 * 来源专题,非专题入口提交的为 null。比赛提交恒为 null(比赛题不会进专题)。
* 历史提交里只有「当年首次 AC 那一条」有值 —— 迁移 0007 从 problemset_submission * 历史提交里只有「当年首次 AC 那一条」有值 —— 迁移 0007 从 problemset_submission
* 回填的就是这些,其余老提交无从判断入口,一律留空。 * 回填的就是这些,其余老提交无从判断入口,一律留空。
*/ */
@@ -488,7 +488,7 @@ export const submissionLessonsSchema = z.object({
* 只认那个语言,流程图评到 A / S 也算 —— 和任务页同一个口径)。推断的课为 null * 只认那个语言,流程图评到 A / S 也算 —— 和任务页同一个口径)。推断的课为 null
*/ */
doneCount: z.number().int().nullable(), doneCount: z.number().int().nullable(),
/** 多半是从题单里交的 —— 「题单 7 道」 */ /** 多半是从专题里交的 —— 「专题 7 道」 */
fromProblemSet: z.boolean(), fromProblemSet: z.boolean(),
/** 这节课的题(上面 problems)交了几次 —— 和点进去看到的对得上 */ /** 这节课的题(上面 problems)交了几次 —— 和点进去看到的对得上 */
total: z.number().int(), total: z.number().int(),