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