Compare commits

..
2 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 c17f88cdbb feat(题单): 题单页重做——布置中的放最上面,详情顶上一条进度和奖章阶梯,没加入看得见题点不进;老师看全班、后台一屏编辑
Deploy / deploy (push) Canceled after 0s
- 列表:布置中的大卡片(做对几道、布置到哪天、再做对几道拿哪个奖章),其余小卡片,加入过的在前
- 详情:进度 + 奖章阶梯(下一个金圈「再做对 n 道」),题目两列写几点做对 / 交过几次;
  以前做对过的标「旧代码先藏着」;做完整条变绿,烟花只在做完那一刻放
- 老师多一个「全班情况」页签;题目页从链接直接进来没加入时,条上给「加入题单」
- 后台一屏编辑:布置一周 / 两周 / 四周 / 选日期,公开开关,粘贴多个题号一次加,拖动排序,奖章只剩两种条件
- 旧代码藏着的地方都说清楚是哪个题单、哪天之后能看
- 修掉列表卡片把选做题数说成「新加了 N 题」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:01:39 +08:00
xuyueandClaude Opus 5.5 8c53e28dcb feat(题单): 「截止时间」改成「布置期」,布置期内没加入的人也藏旧代码;老师能看全班情况,后台一次加好几道题
- 布置期(列名仍是 end_time,机房共库不改名)+ 新列 assigned_at:布置期内题单里这几道题,
  学生在布置开始之前交的代码先藏起来,不管加没加入,堵住「先抄再加入」;做对或到期放出来,不拦做题
- 「总分达到 N」奖章换成「做对 N 道」,草稿并进「不公开」(0026)
- /problem-sets 带布置期、必做题数、奖章获得时间;题目带做对时刻、错几次、旧代码藏着没有
- 新增 /problem-sets/:id/class-view(老师看一个班:真名 × 每题,加入前就做对过)、/problem-sets/locks
- 后台:批量加题、拖动排序、切必做;去掉状态、难度、分数、提示和单独的进度页

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:01:39 +08:00
43 changed files with 7524 additions and 2734 deletions

No files matched your search

@@ -0,0 +1,10 @@
-- 题单的「截止时间」改成「布置到」:它从来不截止任何东西,只管布置期内藏学生以前的代码(防抄),
-- 见 services/problemset.ts 的 problemSetLockCutoffs。列名 end_time 不改(机房和服务器共库,
-- 改名会让没升级的那边直接报错);旧值是按旧语义填的(3 个题单,都已过期),清掉。
ALTER TABLE "problemset" ADD COLUMN "assigned_at" timestamp with time zone;--> statement-breakpoint
UPDATE "problemset" SET "end_time" = NULL;--> statement-breakpoint
-- 「状态」和「可见」两道闸合成一个「公开」:草稿等于不公开,归档等于公开(一直都还能进)
UPDATE "problemset" SET "visible" = false WHERE "status" = 'draft';--> statement-breakpoint
UPDATE "problemset" SET "status" = 'active' WHERE "status" <> 'active';--> statement-breakpoint
-- 分数拿掉了,「总分达到 N」的奖章换成「做对 N 道」(线上那几套题每题 10 分,门槛不变)
UPDATE "problemset_badge" SET "condition_type" = 'problem_count', "condition_value" = GREATEST(1, CEIL("condition_value" / 10.0)) WHERE "condition_type" = 'score';
File diff suppressed because it is too large. Load diff
+7
View File
@@ -183,6 +183,13 @@
"when": 1791521256834, "when": 1791521256834,
"tag": "0025_submission_problem_user_idx", "tag": "0025_submission_problem_user_idx",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1791553481647,
"tag": "0026_problemset_assignment",
"breakpoints": true
} }
] ]
} }
+11 -1
View File
@@ -417,7 +417,17 @@ export const problemset = pgTable(
difficulty: text().notNull().$type<ProblemSetDifficulty>(), difficulty: text().notNull().$type<ProblemSetDifficulty>(),
status: text().notNull().$type<ProblemSetStatus>(), status: text().notNull().$type<ProblemSetStatus>(),
createdById: integer("created_by_id").notNull(), createdById: integer("created_by_id").notNull(),
endTime: timestamp("end_time", { withTimezone: true, mode: "string" }), /**
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
* 两个都为空 = 没布置。
*
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字
*/
assignedUntil: timestamp("end_time", { withTimezone: true, mode: "string" }),
assignedAt: timestamp("assigned_at", { withTimezone: true, mode: "string" }),
}, },
(table) => [ (table) => [
index("problemset_created_by_id_01b5197f").using( index("problemset_created_by_id_01b5197f").using(
+139 -151
View File
@@ -1,18 +1,18 @@
import { import {
addProblemToSetRequestSchema, addProblemsToSetRequestSchema,
createProblemSetBadgeRequestSchema, createProblemSetBadgeRequestSchema,
createProblemSetRequestSchema, createProblemSetRequestSchema,
reorderProblemSetProblemsRequestSchema,
updateProblemInSetRequestSchema, updateProblemInSetRequestSchema,
updateProblemSetBadgeRequestSchema, updateProblemSetBadgeRequestSchema,
updateProblemSetRequestSchema, updateProblemSetRequestSchema,
updateProblemSetStatusRequestSchema, type AddProblemsToSetResult,
type AdminProblemSet, type AdminProblemSet,
type AdminProblemSetBadge, type AdminProblemSetBadge,
type AdminProblemSetList, type AdminProblemSetList,
type AdminProblemSetProblem, type AdminProblemSetProblem,
type AdminProblemSetProgress,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" import { and, asc, count, desc, eq, ilike, inArray, isNull, max, or, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware" import { requireTeacher, type AppEnv } from "../../auth/middleware"
@@ -20,7 +20,7 @@ import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db" import { db, schema } from "../../db"
import { failure, parseBody, success } from "../../http" import { failure, parseBody, success } from "../../http"
import { recalculateBadge, resyncProgress } from "../../services/problemset" import { recalculateBadge, resyncProgress } from "../../services/problemset"
import { asFilterValue, queryInteger, sampleUser } from "../helpers" import { queryInteger, sampleUser } from "../helpers"
export const adminProblemSetRoutes = new Hono<AppEnv>() export const adminProblemSetRoutes = new Hono<AppEnv>()
@@ -49,11 +49,11 @@ async function serialize(row: typeof schema.problemset.$inferSelect) {
return (await serializeMany([row]))[0]! return (await serializeMany([row]))[0]!
} }
/** 批量版:列表接口走这个,固定 3 条查询,与行数无关(按行 serialize 就是 N+1) */ /** 批量版:列表接口走这个,固定 4 条查询,与行数无关(按行 serialize 就是 N+1) */
async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) { async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
if (rows.length === 0) return [] if (rows.length === 0) return []
const ids = rows.map((row) => row.id) const ids = rows.map((row) => row.id)
const [problems, participants, creators] = await Promise.all([ const [problems, participants, badges, creators] = await Promise.all([
db db
.select({ .select({
problemsetId: schema.problemsetProblem.problemsetId, problemsetId: schema.problemsetProblem.problemsetId,
@@ -66,10 +66,19 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
.select({ .select({
problemsetId: schema.problemsetProgress.problemsetId, problemsetId: schema.problemsetProgress.problemsetId,
value: count(), value: count(),
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
}) })
.from(schema.problemsetProgress) .from(schema.problemsetProgress)
.where(inArray(schema.problemsetProgress.problemsetId, ids)) .where(inArray(schema.problemsetProgress.problemsetId, ids))
.groupBy(schema.problemsetProgress.problemsetId), .groupBy(schema.problemsetProgress.problemsetId),
db
.select({
problemsetId: schema.problemsetBadge.problemsetId,
value: count(),
})
.from(schema.problemsetBadge)
.where(inArray(schema.problemsetBadge.problemsetId, ids))
.groupBy(schema.problemsetBadge.problemsetId),
db db
.select({ .select({
id: schema.user.id, id: schema.user.id,
@@ -81,27 +90,57 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])), .where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])),
]) ])
const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value])) const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value]))
const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item.value])) const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item]))
const badgesBySet = new Map(badges.map((item) => [item.problemsetId, item.value]))
const creatorById = new Map(creators.map((item) => [item.id, item])) const creatorById = new Map(creators.map((item) => [item.id, item]))
const now = Date.now()
return rows.map((row) => { return rows.map((row) => {
const creator = creatorById.get(row.createdById) const creator = creatorById.get(row.createdById)
return { return {
id: row.id, id: row.id,
title: row.title, title: row.title,
description: row.description, description: row.description,
difficulty: row.difficulty, assignedUntil: row.assignedUntil,
status: row.status, assignedAt: row.assignedAt,
endTime: row.endTime, assigning:
row.assignedAt !== null &&
row.assignedUntil !== null &&
Date.parse(row.assignedUntil) > now,
visible: row.visible, visible: row.visible,
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName), createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
createTime: row.createTime, createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime, lastUpdateTime: row.lastUpdateTime,
problemsCount: problemsBySet.get(row.id) ?? 0, problemsCount: problemsBySet.get(row.id) ?? 0,
participantCount: participantsBySet.get(row.id) ?? 0, participantCount: participantsBySet.get(row.id)?.value ?? 0,
completedCount: participantsBySet.get(row.id)?.completed ?? 0,
badgeCount: badgesBySet.get(row.id) ?? 0,
} satisfies AdminProblemSet } satisfies AdminProblemSet
}) })
} }
/**
* 布置期怎么落库。老师给的是「布置到哪天」(东八区当天结束的时刻),开始时刻由服务端定:
* - 不布置 → 两个都清掉;
* - 现在正在布置 → 只改结束,开始不动(延长、缩短都不该把「以前的代码」的分界挪到现在);
* - 没在布置(从没布置过,或者上一轮已经过期)→ 开始记成现在,这就是「再布置一次」。
*/
function assignment(
requested: string | null,
current: { assignedAt: string | null; assignedUntil: string | null } | null,
) {
if (requested === null) return { assignedAt: null, assignedUntil: null }
const until = Date.parse(requested)
if (!Number.isFinite(until) || until <= Date.now()) return "past" as const
const running =
current?.assignedAt != null &&
current.assignedUntil != null &&
Date.parse(current.assignedUntil) > Date.now()
return {
assignedAt: running ? current.assignedAt : new Date().toISOString(),
assignedUntil: new Date(until).toISOString(),
}
}
// ---------------------------------------------------------------- 题单本体 // ---------------------------------------------------------------- 题单本体
adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => { adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
@@ -114,8 +153,6 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。 // 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
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()
const difficulty = c.req.query("difficulty")?.trim()
const status = c.req.query("status")?.trim()
if (keyword) { if (keyword) {
filters.push( filters.push(
or( or(
@@ -124,8 +161,6 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
)!, )!,
) )
} }
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = filters.length ? and(...filters) : undefined const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
@@ -147,12 +182,19 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => { adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
const parsed = await parseBody(c, createProblemSetRequestSchema) const parsed = await parseBody(c, createProblemSetRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const period = assignment(parsed.data.assignedUntil, null)
if (period === "past") return failure(c, 400, "assign-in-past", "布置到的日期已经过了")
const now = new Date().toISOString() const now = new Date().toISOString()
const [created] = await db const [created] = await db
.insert(schema.problemset) .insert(schema.problemset)
.values({ .values({
...parsed.data, title: parsed.data.title,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null, description: parsed.data.description,
visible: parsed.data.visible,
...period,
// 难度、状态两列还在表里、非空,但已经不用了(见契约 admin.ts 的说明)
difficulty: "Easy",
status: "active",
createdById: c.get("user")!.id, createdById: c.get("user")!.id,
createTime: now, createTime: now,
lastUpdateTime: now, lastUpdateTime: now,
@@ -172,11 +214,15 @@ adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
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)
if (period === "past") return failure(c, 400, "assign-in-past", "布置到的日期已经过了")
const [updated] = await db const [updated] = await db
.update(schema.problemset) .update(schema.problemset)
.set({ .set({
...parsed.data, title: parsed.data.title,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null, description: parsed.data.description,
visible: parsed.data.visible,
...period,
lastUpdateTime: new Date().toISOString(), lastUpdateTime: new Date().toISOString(),
}) })
.where(eq(schema.problemset.id, row.id)) .where(eq(schema.problemset.id, row.id))
@@ -196,22 +242,6 @@ adminProblemSetRoutes.put("/problem-sets/:id/visibility", requireTeacher, async
return success(c, await serialize(updated!)) return success(c, await serialize(updated!))
}) })
adminProblemSetRoutes.put("/problem-sets/:id/status", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = await parseBody(c, updateProblemSetStatusRequestSchema, "status 不合法")
if (!parsed.success) return parsed.response
const [updated] = await db
.update(schema.problemset)
.set({
status: parsed.data.status,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.problemset.id, row.id))
.returning()
return success(c, await serialize(updated!))
})
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", "题单不存在")
@@ -245,59 +275,97 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
difficulty: problem.difficulty, difficulty: problem.difficulty,
order: item.order, order: item.order,
isRequired: item.isRequired, isRequired: item.isRequired,
score: item.score,
hint: item.hint,
}) satisfies AdminProblemSetProblem, }) satisfies AdminProblemSetProblem,
), ),
) )
}) })
/**
* 加题:一次可以给好几个题号(后台搜索框里粘一串「3065 3066 3067」),按给的顺序接在最后。
* 找不到的、本来就在里面的不报错,分开报回去,前端告诉老师哪几个没加上。
*/
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, addProblemToSetRequestSchema) const parsed = await parseBody(c, addProblemsToSetRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const [problem] = await db const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))]
.select({ id: schema.problem.id }) const [problems, existing, last] = await Promise.all([
db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem) .from(schema.problem)
.where( .where(
and( and(
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`, inArray(sql<string>`lower(${schema.problem.displayId})`, wanted),
eq(schema.problem.visible, true), eq(schema.problem.visible, true),
isNull(schema.problem.contestId), isNull(schema.problem.contestId),
), ),
)
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见")
const [duplicate] = await db
.select({ id: schema.problemsetProblem.id })
.from(schema.problemsetProblem)
.where(
and(
eq(schema.problemsetProblem.problemsetId, row.id),
eq(schema.problemsetProblem.problemId, problem.id),
), ),
) db
.limit(1) .select({ problemId: schema.problemsetProblem.problemId })
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中") .from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, row.id)),
const [created] = await db db
.insert(schema.problemsetProblem) .select({ value: max(schema.problemsetProblem.order) })
.values({ .from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, row.id)),
])
const byDisplayId = new Map(problems.map((problem) => [problem.displayId.toLowerCase(), problem]))
const inSet = new Set(existing.map((item) => item.problemId))
const result: AddProblemsToSetResult = { added: [], missing: [], duplicate: [] }
const values: (typeof schema.problemsetProblem.$inferInsert)[] = []
let order = last[0]?.value ?? 0
for (const id of wanted) {
const problem = byDisplayId.get(id)
if (!problem) result.missing.push(id)
else if (inSet.has(problem.id)) result.duplicate.push(problem.displayId)
else {
inSet.add(problem.id)
order += 1
// 分数、提示两列还在表里但已经不用了:score 非空给 0
values.push({
problemsetId: row.id, problemsetId: row.id,
problemId: problem.id, problemId: problem.id,
order: parsed.data.order, order,
isRequired: parsed.data.isRequired, isRequired: true,
score: parsed.data.score, score: 0,
hint: parsed.data.hint,
}) })
.returning({ id: schema.problemsetProblem.id }) result.added.push(problem.displayId)
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变, }
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的, }
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。 if (values.length) {
await db.insert(schema.problemsetProblem).values(values)
// 题目集变了,已加入的人的分母、完成状态、奖章都得跟着变(旧栈靠 post_save 信号,
// 不在 views 里,别因为翻不到显式调用就以为它没做,见 services/problemset.ts)
await resyncProgress(row.id) await resyncProgress(row.id)
return success(c, { id: created!.id }, 201) }
return success(c, result, values.length ? 201 : 200)
})
// 必须注册在 /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", "题单不存在")
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))
const have = new Set(links.map((link) => link.id))
const given = new Set(parsed.data.ids)
if (given.size !== have.size || [...given].some((id) => !have.has(id))) {
return failure(c, 409, "problems-changed", "题目列表变了,刷新一下再排")
}
await db.transaction(async (tx) => {
for (const [index, id] of parsed.data.ids.entries()) {
await tx
.update(schema.problemsetProblem)
.set({ order: index + 1 })
.where(eq(schema.problemsetProblem.id, id))
}
})
return success(c, null)
}) })
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => { adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
@@ -316,7 +384,8 @@ adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher,
) )
.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", "题目不在该题单中")
if (parsed.data.score !== undefined) await resyncProgress(row.id) // 必做 / 选做变了,分母和「做完」跟着变
await resyncProgress(row.id)
return success(c, null) return success(c, null)
}) })
@@ -453,84 +522,3 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id)) await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
return success(c, null) return success(c, null)
}) })
// ---------------------------------------------------------------- 学生进度
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db
.select({
progress: schema.problemsetProgress,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.problemsetProgress)
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.problemsetProgress.problemsetId, row.id))
.orderBy(desc(schema.problemsetProgress.joinTime))
return success(
c,
rows.map(
({ progress, username, realName }) =>
({
id: progress.id,
userId: progress.userId,
username,
// 真名有意下发:这是老师看本班完成情况的页面,已由 requireTeacher + 归属校验把关
realName,
joinTime: progress.joinTime,
completeTime: progress.completeTime,
isCompleted: progress.isCompleted,
progressPercentage: progress.progressPercentage,
completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore,
}) satisfies AdminProblemSetProgress,
),
)
})
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
const deleted = await db.transaction(async (tx) => {
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
const badges = await tx
.select({ id: schema.problemsetBadge.id })
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id))
if (badges.length) {
await tx.delete(schema.userBadge).where(
and(
eq(schema.userBadge.userId, userId),
inArray(
schema.userBadge.badgeId,
badges.map((badge) => badge.id),
),
),
)
}
await tx
.delete(schema.problemsetSubmission)
.where(
and(
eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.userId, userId),
),
)
return tx
.delete(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, row.id),
eq(schema.problemsetProgress.userId, userId),
),
)
.returning({ id: schema.problemsetProgress.id })
})
if (deleted.length === 0) return failure(c, 404, "progress-not-found", "用户未加入该题单")
return success(c, null)
})
+18 -18
View File
@@ -23,7 +23,7 @@ import { db, schema } from "../db"
import { failure, readJson, success } from "../http" import { failure, readJson, success } from "../http"
import { flowchartQueue } from "../queue" import { flowchartQueue } from "../queue"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { problemSetJoinTimes } from "../services/problemset" import { problemSetLockCutoffs } from "../services/problemset"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency" import { buildWordFrequencies } from "../services/word-frequency"
import { dayStart } from "../time" import { dayStart } from "../time"
@@ -49,36 +49,36 @@ function flowchartThrottleKey(userId: number) {
} }
/** /**
* 题单防抄:学生加入含这道题的题单之前画的图,先对他本人藏起来 —— 和代码提交同一道闸 * 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetJoinTimes)。原来流程图 * (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
* 这边没有这一条,加入题单之前画到 A 的那张图点一下就能载回画布,交上去就是 A。 * 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
*/ */
function hiddenByProblemSet( function hiddenByProblemSet(
user: AuthUser, user: AuthUser,
row: { userId: number; problemId: number; createTime: string }, row: { userId: number; problemId: number; createTime: string },
joinTimes: Map<number, string>, cutoffs: Map<number, string>,
) { ) {
if (row.userId !== user.id || isTeacherOrAbove(user)) return false if (row.userId !== user.id || isTeacherOrAbove(user)) return false
const joinTime = joinTimes.get(row.problemId) const cutoff = cutoffs.get(row.problemId)
return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime) return cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)
} }
function canView( function canView(
user: AuthUser, user: AuthUser,
row: { userId: number; problemId: number; createTime: string }, row: { userId: number; problemId: number; createTime: string },
problem: { createdById: number }, problem: { createdById: number },
joinTimes: Map<number, string>, cutoffs: Map<number, string>,
) { ) {
if (hiddenByProblemSet(user, row, joinTimes)) return false if (hiddenByProblemSet(user, row, cutoffs)) return false
// 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission) // 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission)
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
} }
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */ /** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
function joinTimesFor(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)
return problemSetJoinTimes(user.id, [...new Set(own)]) return problemSetLockCutoffs(user.id, [...new Set(own)])
} }
function flowchartData( function flowchartData(
@@ -321,7 +321,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
.offset(offset), .offset(offset),
displayId ? unknownDisplayIds(displayId, null) : [], displayId ? unknownDisplayIds(displayId, null) : [],
]) ])
const joinTimes = await joinTimesFor( const cutoffs = await cutoffsFor(
user, user,
rows.map((row) => row.flowchart), rows.map((row) => row.flowchart),
) )
@@ -341,7 +341,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
aiModel: flowchart.aiModel, aiModel: flowchart.aiModel,
processingTime: flowchart.processingTime, processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime, evaluationTime: flowchart.evaluationTime,
showLink: canView(user, flowchart, problem, joinTimes), showLink: canView(user, flowchart, problem, cutoffs),
}) satisfies FlowchartListItem, }) satisfies FlowchartListItem,
), ),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
@@ -656,7 +656,7 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))) .where(eq(schema.flowchartSubmission.id, c.req.param("id")))
.limit(1) .limit(1)
const user = c.get("user")! const user = c.get("user")!
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart]))) if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
return failure(c, 404, "flowchart-not-found", "Submission does not exist") return failure(c, 404, "flowchart-not-found", "Submission does not exist")
return success(c, flowchartData(row.flowchart, row.username)) return success(c, flowchartData(row.flowchart, row.username))
}) })
@@ -669,7 +669,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))) .where(eq(schema.flowchartSubmission.id, c.req.param("id")))
.limit(1) .limit(1)
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart]))) if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
return failure(c, 404, "flowchart-not-found", "Submission does not exist") return failure(c, 404, "flowchart-not-found", "Submission does not exist")
// 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的 // 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的
// 评分再抽一次,别人的提交只有老师能动 // 评分再抽一次,别人的提交只有老师能动
@@ -737,7 +737,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。 * userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
*/ */
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) { async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
const [rows, joinTimes] = await Promise.all([ const [rows, cutoffs] = await Promise.all([
db db
.select({ .select({
id: schema.flowchartSubmission.id, id: schema.flowchartSubmission.id,
@@ -756,9 +756,9 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
), ),
) )
.orderBy(asc(schema.flowchartSubmission.createTime)), .orderBy(asc(schema.flowchartSubmission.createTime)),
joinTimesFor(user, [{ userId: user.id, problemId }]), cutoffsFor(user, [{ userId: user.id, problemId }]),
]) ])
const visible = rows.filter((row) => !hiddenByProblemSet(user, row, joinTimes)) const visible = rows.filter((row) => !hiddenByProblemSet(user, row, cutoffs))
return { visible, hidden: rows.length - visible.length } return { visible, hidden: rows.length - visible.length }
} }
+367 -258
View File
@@ -1,44 +1,83 @@
import { import {
joinProblemSetRequestSchema, joinProblemSetRequestSchema,
STUDENT_ROLES,
type ProblemSet, type ProblemSet,
type ProblemSetBadge, type ProblemSetBadge,
type ProblemSetClassView,
type ProblemSetList, type ProblemSetList,
type ProblemSetLock,
type ProblemSetProblem, type ProblemSetProblem,
type ProblemSetProgress,
type ProblemSetProgressList,
type UserBadge, type UserBadge,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, avg, count, desc, eq, gt, ilike, inArray, ne, or, sql } from "drizzle-orm" import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { computeProgress } from "../services/problemset" import { JudgeStatus, NON_FAILURE_RESULTS } from "../judge/status"
import { asFilterValue, asRecord, classPrefixCondition, queryInteger, sampleUser } from "./helpers" import {
assigningSql,
computeProgress,
problemSetLockCutoffs,
problemSetLocksFor,
} from "../services/problemset"
import { asRecord, isTeacherOrAbove, queryInteger, sampleUser } from "./helpers"
export const problemsetRoutes = new Hono<AppEnv>() export const problemsetRoutes = new Hono<AppEnv>()
type ProblemSetRow = typeof schema.problemset.$inferSelect type ProblemSetRow = typeof schema.problemset.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) { const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 必做题的 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))
}
function progressSummary(progress: ProgressRow | undefined) {
return progress return progress
? { ? {
isJoined: true, isJoined: true,
progressPercentage: progress.progressPercentage, joinTime: progress.joinTime,
completedCount: progress.completedProblemsCount, completedCount: progress.completedProblemsCount,
totalCount: progress.totalProblemsCount, totalCount: progress.totalProblemsCount,
solvedCount: Object.keys(asRecord(progress.progressDetail)).length,
isCompleted: progress.isCompleted, isCompleted: progress.isCompleted,
completeTime: progress.completeTime,
} }
: { : {
isJoined: false, isJoined: false,
progressPercentage: 0, joinTime: null,
completedCount: 0, completedCount: 0,
totalCount: 0, totalCount: 0,
solvedCount: 0,
isCompleted: false, isCompleted: false,
completeTime: null,
} }
} }
/**
* 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
* 看到的样子、看全班情况,不该被 404 挡在外面。
*/
async function loadVisible(id: number, user: AuthUser | null | undefined) {
const [row] = await db
.select({ set: schema.problemset, assigning: assigningSql })
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
isTeacherOrAbove(user) ? undefined : eq(schema.problemset.visible, true),
),
)
.limit(1)
return row ?? null
}
async function problemSetCreators(ids: number[]) { async function problemSetCreators(ids: number[]) {
const map = new Map<number, ReturnType<typeof sampleUser>>() const map = new Map<number, ReturnType<typeof sampleUser>>()
if (ids.length === 0) return map if (ids.length === 0) return map
@@ -55,7 +94,7 @@ async function problemSetCreators(ids: number[]) {
return map return map
} }
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) { function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime?: string | null) {
return { return {
id: badge.id, id: badge.id,
problemSetId: badge.problemsetId, problemSetId: badge.problemsetId,
@@ -64,28 +103,37 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: b
icon: badge.icon, icon: badge.icon,
conditionType: badge.conditionType, conditionType: badge.conditionType,
conditionValue: badge.conditionValue, conditionValue: badge.conditionValue,
isEarned: earned, isEarned: earnedTime === undefined ? undefined : earnedTime !== null,
earnedTime,
} satisfies ProblemSetBadge } satisfies ProblemSetBadge
} }
/** /**
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。 * 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。
* * 固定 6 条查询,与行数无关(按行查就是 N+1)。
* 以前是每行 5 条查询(题目数 / 我的进度 / 奖章 / 已获奖章 / 创建者),
* limit 最大 250 就是 1250 次往返。这里固定 5 条,与行数无关。
*/ */
async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, includeBadges = false) { async function serializeProblemSets(
rows: { set: ProblemSetRow; assigning: boolean }[],
userId?: number,
) {
if (rows.length === 0) return [] if (rows.length === 0) return []
const ids = rows.map((row) => row.id) const ids = rows.map((row) => row.set.id)
const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([ const [problemCounts, joinedCounts, progresses, badges, earnedRows, creators] = await Promise.all(
[
db db
.select({ .select({
problemsetId: schema.problemsetProblem.problemsetId, problemsetId: schema.problemsetProblem.problemsetId,
value: count(), value: count(),
required: sql<number>`count(*) filter (where ${schema.problemsetProblem.isRequired})::int`,
}) })
.from(schema.problemsetProblem) .from(schema.problemsetProblem)
.where(inArray(schema.problemsetProblem.problemsetId, ids)) .where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId), .groupBy(schema.problemsetProblem.problemsetId),
db
.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
.from(schema.problemsetProgress)
.where(inArray(schema.problemsetProgress.problemsetId, ids))
.groupBy(schema.problemsetProgress.problemsetId),
userId userId
? db ? db
.select() .select()
@@ -96,17 +144,15 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
eq(schema.problemsetProgress.userId, userId), eq(schema.problemsetProgress.userId, userId),
), ),
) )
: Promise.resolve([] as (typeof schema.problemsetProgress.$inferSelect)[]), : Promise.resolve([] as ProgressRow[]),
includeBadges db
? db
.select() .select()
.from(schema.problemsetBadge) .from(schema.problemsetBadge)
.where(inArray(schema.problemsetBadge.problemsetId, ids)) .where(inArray(schema.problemsetBadge.problemsetId, ids))
.orderBy(asc(schema.problemsetBadge.id)) .orderBy(asc(schema.problemsetBadge.id)),
: Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]), userId
includeBadges && userId
? db ? db
.select({ id: schema.userBadge.badgeId }) .select({ id: schema.userBadge.badgeId, earnedTime: schema.userBadge.earnedTime })
.from(schema.userBadge) .from(schema.userBadge)
.innerJoin( .innerJoin(
schema.problemsetBadge, schema.problemsetBadge,
@@ -118,16 +164,18 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
inArray(schema.problemsetBadge.problemsetId, ids), inArray(schema.problemsetBadge.problemsetId, ids),
), ),
) )
: Promise.resolve([] as { id: number }[]), : Promise.resolve([] as { id: number; earnedTime: string }[]),
problemSetCreators([...new Set(rows.map((row) => row.createdById))]), problemSetCreators([...new Set(rows.map((row) => row.set.createdById))]),
]) ],
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value])) )
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item]))
const joinedBySet = new Map(joinedCounts.map((item) => [item.problemsetId, item.value]))
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item])) const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>() const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
for (const badge of badges) for (const badge of badges)
badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge]) badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
const earned = new Set(earnedRows.map((item) => item.id)) const earned = new Map(earnedRows.map((item) => [item.id, item.earnedTime]))
return rows.map((row) => { return rows.map(({ set: row, assigning }) => {
const progress = progressBySet.get(row.id) const progress = progressBySet.get(row.id)
return { return {
id: row.id, id: row.id,
@@ -136,17 +184,16 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
createdBy: createdBy:
creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null), creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
createTime: row.createTime, createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime, assignedUntil: row.assignedUntil,
difficulty: row.difficulty, assigning,
status: row.status, problemsCount: countBySet.get(row.id)?.value ?? 0,
endTime: row.endTime, // 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径
visible: row.visible, requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
problemsCount: countBySet.get(row.id) ?? 0, joinedCount: joinedBySet.get(row.id) ?? 0,
completedCount: progress?.completedProblemsCount ?? 0,
userProgress: progressSummary(progress), userProgress: progressSummary(progress),
badges: includeBadges badges: (badgesBySet.get(row.id) ?? []).map((badge) =>
? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id))) badgeData(badge, userId ? (earned.get(badge.id) ?? null) : undefined),
: undefined, ),
} satisfies ProblemSet } satisfies ProblemSet
}) })
} }
@@ -154,10 +201,8 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => { problemsetRoutes.get("/problem-sets", optionalAuth, 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 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft")] const filters = [eq(schema.problemset.visible, true)]
const keyword = c.req.query("keyword")?.trim() const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim()
const status = c.req.query("status")?.trim()
if (keyword) if (keyword)
filters.push( filters.push(
or( or(
@@ -165,13 +210,11 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
ilike(schema.problemset.description, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`),
)!, )!,
) )
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = and(...filters) const where = and(...filters)
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problemset).where(where), db.select({ value: count() }).from(schema.problemset).where(where),
db db
.select() .select({ set: schema.problemset, assigning: assigningSql })
.from(schema.problemset) .from(schema.problemset)
.where(where) .where(where)
.orderBy(desc(schema.problemset.createTime)) .orderBy(desc(schema.problemset.createTime))
@@ -179,49 +222,45 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
.offset(offset), .offset(offset),
]) ])
return success(c, { return success(c, {
results: await serializeProblemSets(rows, c.get("user")?.id, true), results: await serializeProblemSets(rows, c.get("user")?.id),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
} satisfies ProblemSetList) } satisfies ProblemSetList)
}) })
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => { // 字面量路由要排在 /problem-sets/:id 前面:Hono 按注册顺序匹配,不是静态优先
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) // 题号(problemDisplayId)和主键(problemId)都认:提交列表那边手里只有题号
const [row] = await db problemsetRoutes.get("/problem-sets/locks", requireAuth, async (c) => {
.select() const user = c.get("user")!
.from(schema.problemset) if (isTeacherOrAbove(user)) return success(c, [] as ProblemSetLock[])
.where( let problemId = queryInteger(c.req.query("problemId"), 0, { min: 1 })
and( const displayId = c.req.query("problemDisplayId")?.trim()
eq(schema.problemset.id, id), if (!problemId && displayId) {
eq(schema.problemset.visible, true), const [problem] = await db
ne(schema.problemset.status, "draft"), .select({ id: schema.problem.id })
), .from(schema.problem)
) .where(and(eq(schema.problem.displayId, displayId), isNull(schema.problem.contestId)))
.limit(1) .limit(1)
problemId = problem?.id ?? 0
}
if (!problemId) return success(c, [] as ProblemSetLock[])
const rows = await problemSetLocksFor(user.id, problemId)
return success(c, rows satisfies ProblemSetLock[])
})
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) const [data] = await serializeProblemSets([row], c.get("user")?.id)
return success(c, data) return success(c, data)
}) })
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) const user = c.get("user")
const [problemSet] = await db const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), user)
.select({ id: schema.problemset.id }) if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
.from(schema.problemset) const id = found.set.id
.where( // order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
and( // 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。
//
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
const rows = await db const rows = await db
.select({ .select({
link: schema.problemsetProblem, link: schema.problemsetProblem,
@@ -234,76 +273,86 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
.where(eq(schema.problemsetProblem.problemsetId, id)) .where(eq(schema.problemsetProblem.problemsetId, id))
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)) .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
const progressRows = c.get("user") const problemIds = rows.map((row) => row.problemId)
? await db const [progressRows, wrongRows, cutoffs] = user
? await Promise.all([
db
.select({ detail: schema.problemsetProgress.progressDetail }) .select({ detail: schema.problemsetProgress.progressDetail })
.from(schema.problemsetProgress) .from(schema.problemsetProgress)
.where( .where(
and( and(
eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.problemsetId, id),
eq(schema.problemsetProgress.userId, c.get("user")!.id), eq(schema.problemsetProgress.userId, user.id),
), ),
) )
.limit(1) .limit(1),
db
.select({ problemId: schema.submission.problemId, value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemsetId, id),
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
),
)
.groupBy(schema.submission.problemId),
isTeacherOrAbove(user)
? new Map<number, string>()
: problemSetLockCutoffs(user.id, problemIds),
])
: [[], [], new Map<number, string>()]
// 「以前做对过 · 旧代码先藏着」:这道题正被闸门挡着,而且分界之前确实做对过
const hiddenSolved =
user && cutoffs.size
? await db
.select({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.problemId, [...cutoffs.keys()]),
inArray(schema.submission.result, SOLVED_RESULTS),
or(
...[...cutoffs].map(
([problemId, cutoff]) =>
sql`(${schema.submission.problemId} = ${problemId} and ${schema.submission.createTime} < ${cutoff})`,
),
),
),
)
.groupBy(schema.submission.problemId)
: [] : []
const completed = asRecord(progressRows[0]?.detail) const hidden = new Set(hiddenSolved.map((row) => row.problemId))
const solved = asRecord(progressRows[0]?.detail)
const wrong = new Map(wrongRows.map((row) => [row.problemId, row.value]))
return success( return success(
c, c,
rows.map( rows.map(({ link, problemId, displayId, title, difficulty }) => {
({ link, problemId, displayId, title, difficulty }) => const entry = solved[String(problemId)]
({ const solvedTime = entry === undefined ? null : asRecord(entry).submit_time
return {
id: link.id, id: link.id,
problemSetId: link.problemsetId, problemSetId: link.problemsetId,
problem: { id: problemId, _id: displayId, title, difficulty }, problem: { id: problemId, _id: displayId, title, difficulty },
order: link.order, order: link.order,
isRequired: link.isRequired, isRequired: link.isRequired,
score: link.score, isCompleted: entry !== undefined,
hint: link.hint, solvedTime: typeof solvedTime === "string" ? solvedTime : null,
isCompleted: String(problemId) in completed, wrongCount: wrong.get(problemId) ?? 0,
}) satisfies ProblemSetProblem, oldCodeHidden: hidden.has(problemId),
), } satisfies ProblemSetProblem
}),
) )
}) })
async function recomputeProgress(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
progress: typeof schema.problemsetProgress.$inferSelect,
detail: Record<string, unknown>,
) {
const links = await tx
.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
})
.from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
const update = computeProgress(detail, links, progress.completeTime)
await tx
.update(schema.problemsetProgress)
.set(update)
.where(eq(schema.problemsetProgress.id, progress.id))
return { ...progress, ...update }
}
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => { problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set") const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set")
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const user = c.get("user")! const user = c.get("user")!
const [problemSet] = await db const found = await loadVisible(parsed.data.problemSetId, user)
.select({ id: schema.problemset.id }) if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
.from(schema.problemset) const problemSet = found.set
.where(
and(
eq(schema.problemset.id, parsed.data.problemSetId),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
const [existing] = await db const [existing] = await db
.select({ id: schema.problemsetProgress.id }) .select({ id: schema.problemsetProgress.id })
.from(schema.problemsetProgress) .from(schema.problemsetProgress)
@@ -316,22 +365,21 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
.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 [created] = await tx const links = await tx
.insert(schema.problemsetProgress) .select({
.values({ problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
})
.from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemSet.id))
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份
await tx.insert(schema.problemsetProgress).values({
problemsetId: problemSet.id, problemsetId: problemSet.id,
userId: user.id, userId: user.id,
joinTime: new Date().toISOString(), joinTime: new Date().toISOString(),
completeTime: null, ...computeProgress({}, links, null),
isCompleted: false,
progressPercentage: 0,
completedProblemsCount: 0,
totalProblemsCount: 0,
totalScore: 0,
progressDetail: {},
}) })
.returning()
if (created) await recomputeProgress(tx, created, {})
}) })
return success(c, null, 201) return success(c, null, 201)
}) })
@@ -372,137 +420,198 @@ problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
) )
}) })
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => { /**
const id = queryInteger(c.req.param("id"), 0, { min: 1 }) * 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
const [problemSet] = await db *
.select({ id: schema.problemset.id }) * 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
.from(schema.problemset) * 也是任何老师看任何班,这里和它们一致。
.where( *
and( * 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
eq(schema.problemset.id, id), * 想看别的班再换(和比赛「全班情况」按参赛者推断班级是一个思路)。名单是那个班的全部学生,
eq(schema.problemset.visible, true), * 没加入的也列出来,好点名。
ne(schema.problemset.status, "draft"), */
), 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"))
.limit(1) if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") const id = found.set.id
const badges = await db const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
.select() const [links, joinedClasses] = await Promise.all([
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, id))
return success(
c,
badges.map((badge) => badgeData(badge)),
)
})
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db
.select({ id: schema.problemset.id })
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
// 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
// 也是任何老师看任何班,这里和它们一致。后台那条(admin/problemset.ts 的 loadOwned)
// 能改能删,仍然只给创建者
if (!problemSet) {
return failure(c, 404, "problem-set-not-found", "题单不存在")
}
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const className = c.req.query("className")?.trim()
const completion = c.req.query("completionStatus")?.trim()
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
// 只填数字(251)当班级号补上 ks;班级按 classPrefixCondition 匹配,
// 否则 251 会把 2511 班也算进来。别的输入照旧按用户名包含
if (className) {
const classPrefix = /^\d+$/.test(className) ? `ks${className}` : className
filters.push(classPrefixCondition(classPrefix) ?? ilike(schema.user.username, `%${className}%`))
}
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true))
else if (completion === "in_progress")
filters.push(
and(
eq(schema.problemsetProgress.isCompleted, false),
gt(schema.problemsetProgress.completedProblemsCount, 0),
)!,
)
else if (completion === "not_started")
filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
const where = and(...filters)
const [statsRows, rows, problemRows] = await Promise.all([
db db
.select({ .select({
total: count(), problemId: schema.problem.id,
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
avgProgress: avg(schema.problemsetProgress.progressPercentage),
})
.from(schema.problemsetProgress)
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
.where(where),
db
.select({
progress: schema.problemsetProgress,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problemsetProgress)
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(
desc(schema.problemsetProgress.isCompleted),
desc(schema.problemsetProgress.progressPercentage),
asc(schema.problemsetProgress.joinTime),
)
.limit(limit)
.offset(offset),
db
.select({
id: schema.problem.id,
_id: schema.problem.displayId, _id: schema.problem.displayId,
title: schema.problem.title, title: schema.problem.title,
isRequired: schema.problemsetProblem.isRequired,
}) })
.from(schema.problemsetProblem) .from(schema.problemsetProblem)
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
.where(eq(schema.problemsetProblem.problemsetId, id)) .where(eq(schema.problemsetProblem.problemsetId, id))
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)), .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
]) db
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem])) .select({
const results = rows.map( className: sql<string>`${schema.user.className}`,
({ progress, user: progressUser, realName }) => joined: count(),
({ last: sql<string>`max(${schema.problemsetProgress.joinTime})`,
id: progress.id, })
problemSetId: progress.problemsetId, .from(schema.problemsetProgress)
user: sampleUser(progressUser, realName), .innerJoin(schema.user, eq(schema.user.id, schema.problemsetProgress.userId))
joinTime: progress.joinTime, .where(
completeTime: progress.completeTime, and(
isCompleted: progress.isCompleted, eq(schema.problemsetProgress.problemsetId, id),
progressPercentage: progress.progressPercentage, studentRole,
completedProblemsCount: progress.completedProblemsCount, sql`coalesce(${schema.user.className}, '') <> ''`,
totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore,
completedProblems: Object.keys(asRecord(progress.progressDetail)).flatMap(
(key) => problemMap.get(key) ?? [],
), ),
}) satisfies ProblemSetProgress,
) )
const stats = statsRows[0] .groupBy(schema.user.className)
.orderBy(sql`max(${schema.problemsetProgress.joinTime}) desc`),
])
const sizes = joinedClasses.length
? await db
.select({ className: sql<string>`${schema.user.className}`, value: count() })
.from(schema.user)
.where(
and(
inArray(
schema.user.className,
joinedClasses.map((row) => row.className),
),
studentRole,
eq(schema.user.isDisabled, false),
),
)
.groupBy(schema.user.className)
: []
const sizeOf = new Map(sizes.map((row) => [row.className, row.value]))
// 只有零星几个人加入的班(别的班的学生自己点进来的)排到后面去:至少 5 个、三分之一的人加入了
// 才算「这个班在做」,这样的班按最近一次有人加入排,默认看第一个
const classes = joinedClasses
.map((row) => ({
className: row.className,
joined: row.joined,
size: sizeOf.get(row.className) ?? row.joined,
}))
.map((row, recency) => ({ row, recency, doing: row.joined >= 5 && row.joined * 3 >= row.size }))
.sort((a, b) =>
a.doing !== b.doing
? Number(b.doing) - Number(a.doing)
: a.doing
? a.recency - b.recency
: b.row.joined - a.row.joined,
)
.map((item) => item.row)
const wanted = c.req.query("className")?.trim()
const className = wanted || classes[0]?.className || null
const graded = gradedIds(links)
const empty: ProblemSetClassView = {
classes,
className,
problems: links.map((link) => ({
id: link.problemId,
_id: link._id,
title: link.title,
isRequired: link.isRequired,
solved: 0,
})),
totalCount: graded.size,
students: [],
}
if (!className || links.length === 0) return success(c, empty)
const roster = await db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
progress: schema.problemsetProgress,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.leftJoin(
schema.problemsetProgress,
and(
eq(schema.problemsetProgress.userId, schema.user.id),
eq(schema.problemsetProgress.problemsetId, id),
),
)
.where(
and(eq(schema.user.className, className), studentRole, eq(schema.user.isDisabled, false)),
)
.orderBy(asc(schema.user.username))
if (roster.length === 0) return success(c, empty)
const userIds = roster.map((row) => row.userId)
const problemIds = links.map((link) => link.problemId)
const [wrongRows, beforeRows] = await Promise.all([
db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
value: count(),
})
.from(schema.submission)
.where(
and(
eq(schema.submission.problemsetId, id),
inArray(schema.submission.userId, userIds),
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
),
)
.groupBy(schema.submission.userId, schema.submission.problemId),
// 加入之前就做对过(哪个入口都算):老师据此知道谁可能是凭记忆默写的
db
.select({ userId: schema.submission.userId, problemId: schema.submission.problemId })
.from(schema.submission)
.innerJoin(
schema.problemsetProgress,
and(
eq(schema.problemsetProgress.userId, schema.submission.userId),
eq(schema.problemsetProgress.problemsetId, id),
),
)
.where(
and(
inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, problemIds),
inArray(schema.submission.result, SOLVED_RESULTS),
sql`${schema.submission.createTime} < ${schema.problemsetProgress.joinTime}`,
),
)
.groupBy(schema.submission.userId, schema.submission.problemId),
])
const key = (userId: number, problemId: number) => `${userId}:${problemId}`
const wrong = new Map(wrongRows.map((row) => [key(row.userId, row.problemId), row.value]))
const before = new Set(beforeRows.map((row) => key(row.userId, row.problemId)))
const solvedCount = new Map<number, number>()
const students = roster.map((row) => {
const detail = asRecord(row.progress?.progressDetail)
let completed = 0
const cells = links.map((link) => {
const entry = detail[String(link.problemId)]
const time = entry === undefined ? null : asRecord(entry).submit_time
if (entry !== undefined) {
solvedCount.set(link.problemId, (solvedCount.get(link.problemId) ?? 0) + 1)
if (graded.has(link.problemId)) completed += 1
}
return {
solvedTime: typeof time === "string" ? time : null,
wrongCount: wrong.get(key(row.userId, link.problemId)) ?? 0,
solvedBefore: before.has(key(row.userId, link.problemId)),
}
})
return {
userId: row.userId,
username: row.username,
realName: row.realName,
joinTime: row.progress?.joinTime ?? null,
completedCount: completed,
cells,
}
})
return success(c, { return success(c, {
results, ...empty,
total: stats?.total ?? 0, problems: empty.problems.map((problem) => ({
statistics: { ...problem,
total: stats?.total ?? 0, solved: solvedCount.get(problem.id) ?? 0,
completed: stats?.completed ?? 0, })),
avgProgress: Number(stats?.avgProgress ?? 0), students,
}, } satisfies ProblemSetClassView)
problems: problemRows,
} satisfies ProblemSetProgressList)
}) })
+11 -11
View File
@@ -30,7 +30,7 @@ import {
} from "../services/contest" } from "../services/contest"
import { CodeFormatError, formatCode } from "../services/format-code" import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { problemSetJoinTimes } from "../services/problemset" import { problemSetLockCutoffs } from "../services/problemset"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { dayStart } from "../time" import { dayStart } from "../time"
import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers" import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
@@ -289,14 +289,14 @@ function canViewSubmission(
user: AuthUser | null, user: AuthUser | null,
row: { userId: number; problemId: number; createTime: string }, row: { userId: number; problemId: number; createTime: string },
problem: { createdById: number }, problem: { createdById: number },
problemSetJoinTime?: Map<number, string>, problemSetCutoffs?: Map<number, string>,
) { ) {
if (!user) return false if (!user) return false
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,老师不受限。 // 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。 // 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
if (row.userId === user.id && !isTeacherOrAbove(user)) { if (row.userId === user.id && !isTeacherOrAbove(user)) {
const joinTime = problemSetJoinTime?.get(row.problemId) const cutoff = problemSetCutoffs?.get(row.problemId)
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false if (cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)) return false
} }
// 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、 // 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、
// 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`, // 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`,
@@ -399,11 +399,11 @@ async function submissionDetail(id: string, user: AuthUser) {
if (!row) return null if (!row) return null
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103) // 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
const joinTimes = const cutoffs =
isTeacherOrAbove(user) || row.submission.userId !== user.id isTeacherOrAbove(user) || row.submission.userId !== user.id
? undefined ? undefined
: await problemSetJoinTimes(user.id, [row.submission.problemId]) : await problemSetLockCutoffs(user.id, [row.submission.problemId])
if (!canViewSubmission(user, row.submission, row.problem, joinTimes)) return null if (!canViewSubmission(user, row.submission, row.problem, cutoffs)) return null
// 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 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
@@ -569,9 +569,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
displayId ? unknownDisplayIds(displayId, null) : [], displayId ? unknownDisplayIds(displayId, null) : [],
]) ])
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询 // 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const [joinTimes, problemsetTitles] = await Promise.all([ const [cutoffs, problemsetTitles] = await Promise.all([
user && !isTeacherOrAbove(user) user && !isTeacherOrAbove(user)
? problemSetJoinTimes(user.id, [ ? problemSetLockCutoffs(user.id, [
...new Set( ...new Set(
rows rows
.filter((row) => row.submission.userId === user.id) .filter((row) => row.submission.userId === user.id)
@@ -589,7 +589,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
id: submission.id, id: submission.id,
problemDisplayId: problem.displayId, problemDisplayId: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, joinTimes) : false, showLink: user ? canViewSubmission(user, submission, problem, cutoffs) : false,
createTime: submission.createTime, createTime: submission.createTime,
userId: submission.userId, userId: submission.userId,
username: submission.username, username: submission.username,
+69 -34
View File
@@ -1,4 +1,4 @@
import { and, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm" import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishAchievementNotification } from "../events" import { publishAchievementNotification } from "../events"
@@ -10,7 +10,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect
type ProblemLink = { problemId: number; score: number; isRequired: boolean } type ProblemLink = { problemId: number; score: number; isRequired: boolean }
type BadgeCheck = Pick< type BadgeCheck = Pick<
ProgressRow, ProgressRow,
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail" "completedProblemsCount" | "totalProblemsCount" | "progressDetail"
> >
/** /**
@@ -132,7 +132,7 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
if (badge.conditionType === "problem_count") { if (badge.conditionType === "problem_count") {
return Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue return Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue
} }
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue // 「总分达到 N」随分数一起拿掉了,0026 把它们换成了「做对 N 道」
return false return false
} }
@@ -242,8 +242,8 @@ export async function recordSolvedProblem(
problemId: number, problemId: number,
/** /**
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id * 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
* 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」—— * 外键指向代码提交表,流程图提交的 id 进不去。不影响防抄闸门的解锁 ——
* 那道闸看的是 progress_detail(本文件下面的 problemSetJoinTimes) * 那道闸看的是 progress_detail(本文件下面的 problemSetLockCutoffs)
*/ */
submissionId: string | null, submissionId: string | null,
solvedAt: string, solvedAt: string,
@@ -390,50 +390,85 @@ export async function recordSolvedAndNotify(
} }
} }
/** 题单现在是不是在布置期内。按数据库的钟算(`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)
* 共用这一道闸。
* *
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含 * 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前**
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。 * 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。 * 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
* *
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡: * 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key) * 现在分界是布置开始,没加入的人也藏,这个口子就没了。代价是别的班以前做过这几道题的人,
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁) * 在这一两周里也看不到自己的旧代码,用户认了。
* - 题单被归档(status 不是 active)
* *
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」 * 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时 * - 题单不在布置期(没布置,或者布置期过了)
* 行为不确定,换成聚合顺手定死。 * - 题单没公开
* - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key)
*
* 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。
*/ */
export async function problemSetJoinTimes(userId: number, problemIds: number[]) { export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
const joinTimes = new Map<number, string>() const cutoffs = new Map<number, string>()
if (problemIds.length === 0) return joinTimes if (problemIds.length === 0) return cutoffs
const rows = await db const rows = await db
.select({ .select({
problemId: schema.problemsetProblem.problemId, problemId: schema.problemsetProblem.problemId,
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了 // 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。 // 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。 cutoff: sql<string>`max(${schema.problemset.assignedAt})`,
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
}) })
.from(schema.problemsetProgress) .from(schema.problemsetProblem)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId)) .innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId))
.innerJoin( .leftJoin(
schema.problemsetProblem, schema.problemsetProgress,
eq(schema.problemsetProblem.problemsetId, schema.problemset.id), and(
eq(schema.problemsetProgress.problemsetId, schema.problemset.id),
eq(schema.problemsetProgress.userId, userId),
),
) )
.where( .where(
and( and(
eq(schema.problemsetProgress.userId, userId),
inArray(schema.problemsetProblem.problemId, problemIds), inArray(schema.problemsetProblem.problemId, problemIds),
eq(schema.problemset.status, "active"), eq(schema.problemset.visible, true),
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)), assigningSql,
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`, sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`,
), ),
) )
.groupBy(schema.problemsetProblem.problemId) .groupBy(schema.problemsetProblem.problemId)
for (const row of rows) joinTimes.set(row.problemId, row.joinTime) for (const row of rows) cutoffs.set(row.problemId, row.cutoff)
return joinTimes return cutoffs
}
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */
export async function problemSetLocksFor(userId: number, problemId: number) {
return db
.select({
problemSetId: schema.problemset.id,
title: schema.problemset.title,
assignedUntil: sql<string>`${schema.problemset.assignedUntil}`,
})
.from(schema.problemsetProblem)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId))
.leftJoin(
schema.problemsetProgress,
and(
eq(schema.problemsetProgress.problemsetId, schema.problemset.id),
eq(schema.problemsetProgress.userId, userId),
),
)
.where(
and(
eq(schema.problemsetProblem.problemId, problemId),
eq(schema.problemset.visible, true),
assigningSql,
sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`,
),
)
.orderBy(asc(schema.problemset.assignedUntil))
} }
+32 -98
View File
@@ -2,7 +2,6 @@ import api from "utils/api"
import { toAdminProblemRow } from "admin/transforms" import { toAdminProblemRow } from "admin/transforms"
import type { import type {
AcTrend, AcTrend,
AdminProblemSetProgress,
BatchProblemTagResponse, BatchProblemTagResponse,
GenerateSqlTestCaseResponse, GenerateSqlTestCaseResponse,
RenameTagResponse, RenameTagResponse,
@@ -40,6 +39,7 @@ import type {
AdminProblemSetList, AdminProblemSetList,
AdminProblemSet, AdminProblemSet,
AdminProblemSetProblem, AdminProblemSetProblem,
AddProblemsToSetResult,
TutorialListItem, TutorialListItem,
TagCategory, TagCategory,
} from "utils/types" } from "utils/types"
@@ -495,15 +495,9 @@ export function updateACMHelperChecked(
} }
// 题单管理 API // 题单管理 API
export function getProblemSetList( export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api.get<AdminProblemSetList>("admin/problem-sets", { return api.get<AdminProblemSetList>("admin/problem-sets", {
params: { offset, limit, keyword, difficulty, status }, params: { offset, limit, keyword },
}) })
} }
@@ -511,33 +505,20 @@ export function getProblemSetDetail(id: number) {
return api.get<AdminProblemSet>(`admin/problem-sets/${id}`) return api.get<AdminProblemSet>(`admin/problem-sets/${id}`)
} }
interface ProblemSetBody { export interface ProblemSetBody {
title?: string title: string
description?: string description: string
difficulty?: AdminProblemSet["difficulty"] /** 布置到哪天(东八区当天结束的 ISO 时刻);null = 不布置 */
status?: AdminProblemSet["status"] assignedUntil: string | null
// 表单里是 Date,出站要 ISO 串 visible: boolean
endTime?: Date | null
visible?: boolean
}
function toProblemSetBody(data: ProblemSetBody) {
return {
title: data.title,
description: data.description ?? "",
difficulty: data.difficulty ?? "Easy",
status: data.status ?? "active",
endTime: data.endTime ? new Date(data.endTime).toISOString() : null,
visible: data.visible ?? true,
}
} }
export function createProblemSet(data: ProblemSetBody) { export function createProblemSet(data: ProblemSetBody) {
return api.post<AdminProblemSet>("admin/problem-sets", toProblemSetBody(data)) return api.post<AdminProblemSet>("admin/problem-sets", data)
} }
export function editProblemSet(data: ProblemSetBody & { id: number }) { export function editProblemSet(id: number, data: ProblemSetBody) {
return api.put<AdminProblemSet>(`admin/problem-sets/${data.id}`, toProblemSetBody(data)) return api.put<AdminProblemSet>(`admin/problem-sets/${id}`, data)
} }
export function deleteProblemSet(id: number) { export function deleteProblemSet(id: number) {
@@ -548,49 +529,28 @@ export function toggleProblemSetVisible(id: number) {
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`) return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
} }
export function updateProblemSetStatus(id: number, status: string) {
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/status`, { status })
}
// 题单题目管理 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`)
} }
export function addProblemToSet( /** 一次加好几道(题号),按给的顺序接在最后;找不到的、已经在里面的分开报回来 */
problemSetId: number, export function addProblemsToSet(problemSetId: number, problemIds: string[]) {
data: { return api.post<AddProblemsToSetResult>(`admin/problem-sets/${problemSetId}/problems`, {
problemId: string problemIds,
order?: number
isRequired?: boolean
score?: number
hint?: string
},
) {
return api.post(`admin/problem-sets/${problemSetId}/problems`, {
problemId: data.problemId,
order: data.order ?? 0,
isRequired: data.isRequired ?? true,
score: data.score ?? 0,
hint: data.hint ?? "",
}) })
} }
export function editProblemInSet( export function setProblemRequired(problemSetId: number, itemId: number, isRequired: boolean) {
problemSetId: number, return api.put(`admin/problem-sets/${problemSetId}/problems/${itemId}`, { isRequired })
problemSetProblemId: number,
data: {
order?: number
isRequired?: boolean
score?: number
hint?: string
},
) {
return api.put(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, data)
} }
export function removeProblemFromSet(problemSetId: number, problemSetProblemId: number) { export function reorderProblemSetProblems(problemSetId: number, ids: number[]) {
return api.delete(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`) return api.put(`admin/problem-sets/${problemSetId}/problems/order`, { ids })
}
export function removeProblemFromSet(problemSetId: number, itemId: number) {
return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`)
} }
// 题单奖章管理 API // 题单奖章管理 API
@@ -598,52 +558,26 @@ export function getProblemSetBadges(problemSetId: number) {
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`) return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
} }
interface BadgeBody { export interface BadgeBody {
name?: string name: string
description?: string description: string
icon?: string icon: string
conditionType?: AdminProblemSetBadge["conditionType"] conditionType: AdminProblemSetBadge["conditionType"]
conditionValue?: number conditionValue: number
}
function toBadgeBody(data: BadgeBody) {
return {
name: data.name,
description: data.description ?? "",
icon: data.icon ?? "",
conditionType: data.conditionType,
conditionValue: data.conditionValue ?? 0,
}
} }
export function createProblemSetBadge(problemSetId: number, data: BadgeBody) { export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
return api.post<AdminProblemSetBadge>( return api.post<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges`, data)
`admin/problem-sets/${problemSetId}/badges`,
toBadgeBody(data),
)
} }
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) { export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
return api.put<AdminProblemSetBadge>( return api.put<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges/${badgeId}`, data)
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
toBadgeBody(data),
)
} }
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) { export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
return api.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`) return api.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`)
} }
// 题单进度管理 API
// 注意:返回的是裸数组,不是分页信封 —— 和 oj 侧的 /user-progress 不同
export function getProblemSetProgress(problemSetId: number) {
return api.get<AdminProblemSetProgress[]>(`admin/problem-sets/${problemSetId}/progress`)
}
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
return api.delete(`admin/problem-sets/${problemSetId}/progress/${userId}`)
}
// 学生卡点分析 // 学生卡点分析
export function getStuckProblems() { export function getStuckProblems() {
return api.get<StuckProblem[]>("admin/problem-analytics/stuck") return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
@@ -1,91 +0,0 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
interface Props {
problemSetId: number
}
const props = defineProps<Props>()
const emit = defineEmits(["updated"])
const router = useRouter()
const message = useMessage()
const showStatusModal = ref(false)
const newStatus = ref<"active" | "archived" | "draft">("active")
const statusOptions = [
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
async function handleDeleteProblemSet() {
try {
await deleteProblemSet(props.problemSetId)
message.success("删除成功")
emit("updated")
} catch (err) {
message.error("删除失败:" + errorMessage(err, "未知错误"))
}
}
function goEdit() {
router.push({
name: "admin problemset edit",
params: { problemSetId: props.problemSetId },
})
}
function goDetail() {
router.push({
name: "admin problemset detail",
params: { problemSetId: props.problemSetId },
})
}
function openStatusModal() {
showStatusModal.value = true
}
async function handleUpdateStatus() {
try {
await updateProblemSetStatus(props.problemSetId, newStatus.value)
message.success("状态更新成功")
showStatusModal.value = false
emit("updated")
} catch (err) {
message.error("状态更新失败:" + errorMessage(err, "未知错误"))
}
}
</script>
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit"> 编辑 </n-button>
<n-button size="small" secondary type="info" @click="goDetail"> 详情 </n-button>
<n-button size="small" secondary type="warning" @click="openStatusModal"> 状态 </n-button>
<n-popconfirm @positive-click="handleDeleteProblemSet">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这个题单吗?删除后题单将不可见。
</n-popconfirm>
</n-flex>
<n-modal v-model:show="showStatusModal" preset="card" title="更新题单状态" style="width: 400px">
<n-space vertical>
<n-form>
<n-form-item label="状态" required>
<n-select v-model:value="newStatus" :options="statusOptions" placeholder="选择状态" />
</n-form-item>
</n-form>
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="showStatusModal = false">取消</n-button>
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>
@@ -1,94 +0,0 @@
<script setup lang="ts">
import { h } from "vue"
import type { AdminProblemSetBadge } from "utils/types"
import { NButton, NImage } from "naive-ui"
interface Props {
badges: AdminProblemSetBadge[]
}
interface Emits {
(e: "add-badge"): void
(e: "edit-badge", badge: AdminProblemSetBadge): void
(e: "delete-badge", badgeId: number): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>奖章列表</h3>
<n-button type="primary" @click="$emit('add-badge')"> 添加奖章 </n-button>
</n-flex>
<n-data-table
:columns="[
{
title: '图标',
key: 'icon',
render: (row) =>
h(NImage, {
src: row.icon,
width: 40,
height: 40,
objectFit: 'cover',
previewDisabled: true,
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
}),
},
{ title: '名称', key: 'name' },
{
title: '条件类型',
key: 'condition_type',
render: (row) => {
const typeMap: Record<string, string> = {
all_problems: '完成所有题目',
problem_count: '完成指定数量题目',
score: '达到指定分数',
}
return typeMap[row.conditionType] || row.conditionType
},
},
{
title: '条件值',
key: 'condition_value',
render: (row) => {
return row.conditionType === 'all_problems' ? '-' : row.conditionValue
},
},
{ title: '描述', key: 'description' },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { style: 'display: flex; gap: 8px;' }, [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => $emit('edit-badge', row),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => $emit('delete-badge', row.id),
},
{ default: () => '删除' },
),
]),
},
]"
:data="badges"
/>
</div>
</template>
@@ -1,155 +1,168 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from "naive-ui"
import type { BadgeBody } from "admin/api"
import type { AdminProblemSetBadge } from "utils/types" import type { AdminProblemSetBadge } from "utils/types"
// 添加和编辑共用一个弹窗:传了 badge 就是编辑,null 就是添加。 /**
// 原来是两份八成相同的组件,改一处表单项另一处总要漏 * 加奖章 / 改奖章共用一个弹窗:传了 badge 就是改,null 就是加。
interface Props { * 条件只有两种:全部做完、做对 N 道(「总分达到 N」随分数一起拿掉了)。
show: boolean * 「做对 N 道」数的是做对的全部题(含选做),和后端 eligibleForBadge 同一个口径。
badge: AdminProblemSetBadge | null */
} const props = defineProps<{ badge: AdminProblemSetBadge | null; problemsCount: number }>()
const show = defineModel<boolean>("show", { required: true })
const emit = defineEmits<{ save: [data: BadgeBody] }>()
const theme = useThemeVars()
type BadgeFormData = { const ICONS = [1, 2, 3, 4, 5, 6].map((i) => `/badge-${i}.png`)
name: string
description: string
icon: string
conditionType: "all_problems" | "problem_count" | "score"
conditionValue?: number
}
interface Emits { const form = reactive<BadgeBody>({
(e: "update:show", value: boolean): void name: "",
(e: "create", data: BadgeFormData): void description: "",
(e: "update", data: BadgeFormData): void icon: ICONS[0]!,
} conditionType: "problem_count",
conditionValue: 1,
})
const props = defineProps<Props>() // 每次打开都按当前模式重新填表:改就填这枚奖章的值,加就清空
const emit = defineEmits<Emits>() watch(show, (open) => {
if (!open) return
Object.assign(form, {
name: props.badge?.name ?? "",
description: props.badge?.description ?? "",
icon: props.badge?.icon || ICONS[0]!,
conditionType: props.badge?.conditionType ?? "problem_count",
conditionValue: props.badge?.conditionValue || 1,
})
})
const badgeName = ref("") const valid = computed(
const badgeDescription = ref("") () =>
const badgeIcon = ref("") form.name.trim().length > 0 &&
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems") (form.conditionType === "all_problems" || form.conditionValue >= 1),
const badgeConditionValue = ref(1) )
// 预设奖章图标选项 function save() {
const BADGE_LEN = 6 if (!valid.value) return
const badgeIconOptions = [] emit("save", {
for (let i = 1; i <= BADGE_LEN; i++) { ...form,
badgeIconOptions.push({ name: form.name.trim(),
label: `奖章${i}`, conditionValue: form.conditionType === "all_problems" ? 0 : form.conditionValue,
value: `/badge-${i}.png`,
icon: `/badge-${i}.png`,
}) })
} }
const conditionTypeOptions = [
{ label: "完成所有题目", value: "all_problems" },
{ label: "完成指定数量题目", value: "problem_count" },
{ label: "达到指定分数", value: "score" },
]
function handleConfirm() {
const data: BadgeFormData = {
name: badgeName.value,
description: badgeDescription.value,
icon: badgeIcon.value,
conditionType: badgeConditionType.value,
// 只有非"完成所有题目"时才带条件值
...(badgeConditionType.value === "all_problems"
? {}
: { conditionValue: badgeConditionValue.value }),
}
if (props.badge) emit("update", data)
else emit("create", data)
}
function handleCancel() {
emit("update:show", false)
}
// 每次打开都按当前模式重新填表:编辑就填这枚奖章的值,添加就清空。
// 编辑取消后再打开同一枚,看到的是库里的值,而不是上次没保存的改动
watch(
() => props.show,
(open) => {
if (!open) return
const badge = props.badge
badgeName.value = badge?.name ?? ""
badgeDescription.value = badge?.description ?? ""
badgeIcon.value = badge?.icon ?? ""
badgeConditionType.value = badge?.conditionType ?? "all_problems"
badgeConditionValue.value = badge?.conditionValue ?? 1
},
{ immediate: true },
)
</script> </script>
<template> <template>
<n-modal <n-modal
:show="show" v-model:show="show"
preset="card" preset="card"
:title="badge ? '编辑奖章' : '添加奖章'" :title="badge ? '改奖章' : '加奖章'"
style="width: 500px" style="width: 460px"
@update:show="emit('update:show', $event)" :mask-closable="false"
> >
<n-form> <div class="form">
<n-form-item label="奖章名称" required> <div class="field">
<n-input v-model:value="badgeName" placeholder="请输入奖章名称" /> <span class="label">图标</span>
</n-form-item> <div class="icons">
<n-form-item label="描述"> <button
<n-input v-model:value="badgeDescription" type="textarea" placeholder="奖章描述" required /> v-for="icon in ICONS"
</n-form-item> :key="icon"
<n-form-item label="图标" required> class="icon"
<n-flex align="center" gap="small"> :class="{ on: form.icon === icon }"
<div :aria-label="`选这个图标`"
v-for="option in badgeIconOptions" @click="form.icon = icon"
:key="option.value"
@click="badgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border: badgeIcon === option.value ? '2px solid #1890ff' : '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: badgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
> >
<n-image <img :src="icon" alt="" />
:src="option.icon" </button>
width="50"
height="50"
object-fit="cover"
preview-disabled
style="border-radius: 2px"
/>
</div> </div>
</n-flex> </div>
</n-form-item> <div class="field">
<n-flex align="center"> <span class="label">名字</span>
<n-form-item label="获得条件"> <n-input v-model:value="form.name" placeholder="比如 人上人" maxlength="100" />
<n-select </div>
style="width: 200px" <div class="field">
v-model:value="badgeConditionType" <span class="label">说明</span>
:options="conditionTypeOptions" <n-input v-model:value="form.description" placeholder="选填,学生拿到时看得到" />
/> </div>
</n-form-item> <div class="field">
<n-form-item label="条件值" v-if="badgeConditionType !== 'all_problems'"> <span class="label">条件</span>
<n-radio-group v-model:value="form.conditionType">
<n-radio-button value="problem_count">做对几道</n-radio-button>
<n-radio-button value="all_problems">全部做完</n-radio-button>
</n-radio-group>
<n-input-number <n-input-number
style="width: 120px" v-if="form.conditionType === 'problem_count'"
v-model:value="badgeConditionValue" v-model:value="form.conditionValue"
placeholder="条件值" :min="1"
/> :max="Math.max(1, problemsCount)"
</n-form-item> style="width: 110px"
</n-flex> >
</n-form> <template #suffix>道</template>
</n-input-number>
</div>
<span class="hint">
{{
form.conditionType === "all_problems"
? "必做题全部做对就发。"
: "做对的题(选做也算)够这个数就发。"
}}改了条件会按大家现在的进度重新发、收。
</span>
</div>
<template #footer> <template #footer>
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="handleCancel">取消</n-button> <n-button @click="show = false">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button> <n-button type="primary" :disabled="!valid" @click="save">保存</n-button>
</n-flex> </n-flex>
</template> </template>
</n-modal> </n-modal>
</template> </template>
<style scoped>
.form {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
align-items: center;
gap: 12px;
}
.label {
width: 36px;
flex-shrink: 0;
font-size: 13px;
color: v-bind("theme.textColor2");
}
.icons {
display: flex;
gap: 6px;
}
.icon {
width: 40px;
height: 40px;
padding: 3px;
border-radius: 6px;
border: 2px solid transparent;
background: transparent;
cursor: pointer;
}
.icon.on {
border-color: v-bind("theme.primaryColor");
}
.icon img {
width: 30px;
height: 30px;
}
.hint {
font-size: 12px;
line-height: 1.6;
color: v-bind("theme.textColor3");
}
</style>
@@ -1,71 +0,0 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import type { AdminProblemSetProblem } from "utils/types"
interface Props {
problems: AdminProblemSetProblem[]
}
interface Emits {
(e: "add-problem"): void
(e: "edit-problem", problem: AdminProblemSetProblem): void
(e: "remove-problem", problemSetProblemId: number): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>题目列表</h3>
<n-button type="primary" @click="$emit('add-problem')"> 添加题目 </n-button>
</n-flex>
<n-data-table
:columns="[
{ title: '题目ID', key: 'problemDisplayId', width: 80 },
{ title: '题目标题', key: 'title', minWidth: 200 },
{ title: '顺序', key: 'order', width: 80 },
{
title: '必做',
key: 'isRequired',
width: 80,
render: (row) => (row.isRequired ? '是' : '否'),
},
{ title: '分数', key: 'score', width: 80 },
{ title: '提示', key: 'hint', minWidth: 200 },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { style: 'display: flex; gap: 8px;' }, [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => $emit('edit-problem', row),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => $emit('remove-problem', row.id),
},
{ default: () => '移除' },
),
]),
},
]"
:data="problems"
/>
</div>
</template>
@@ -1,100 +0,0 @@
<script setup lang="ts">
import type { AdminProblemSetProblem } from "utils/types"
// 添加和编辑共用一个弹窗:传了 problem 就是编辑,null 就是添加。
// 两种模式只差第一栏 —— 添加时填题目 ID,编辑时题目已定、只显示标题
interface Props {
show: boolean
problem: AdminProblemSetProblem | null
}
type ProblemFormData = {
order: number
isRequired: boolean
score: number
hint: string
}
interface Emits {
(e: "update:show", value: boolean): void
(e: "create", data: ProblemFormData & { problemId: string }): void
(e: "update", data: ProblemFormData): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const problemId = ref("")
const problemOrder = ref(0)
const problemRequired = ref(true)
const problemScore = ref(0)
const problemHint = ref("")
function handleConfirm() {
const data: ProblemFormData = {
order: problemOrder.value,
isRequired: problemRequired.value,
score: problemScore.value,
hint: problemHint.value,
}
if (props.problem) emit("update", data)
else emit("create", { problemId: problemId.value, ...data })
}
function handleCancel() {
emit("update:show", false)
}
// 每次打开都按当前模式重新填表:编辑就填这道题的值,添加就清空。
// 编辑取消后再打开同一道,看到的是库里的值,而不是上次没保存的改动
watch(
() => props.show,
(open) => {
if (!open) return
const problem = props.problem
problemId.value = ""
problemOrder.value = problem?.order ?? 0
problemRequired.value = problem?.isRequired ?? true
problemScore.value = problem?.score ?? 0
problemHint.value = problem?.hint || ""
},
{ immediate: true },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="problem ? '编辑题目' : '添加题目'"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item v-if="problem" label="题目标题">
<n-input :value="problem.title" disabled />
</n-form-item>
<n-form-item v-else label="题目ID" required>
<n-input v-model:value="problemId" placeholder="请输入题目的显示ID(如:1001)" />
</n-form-item>
<n-form-item label="顺序">
<n-input-number v-model:value="problemOrder" placeholder="题目在题单中的顺序" />
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="problemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number v-model:value="problemScore" placeholder="题目分数" />
</n-form-item>
<n-form-item label="提示">
<n-input v-model:value="problemHint" type="textarea" placeholder="题目提示" />
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { parseTime } from "utils/functions"
import type { AdminProblemSet } from "utils/types"
interface Props {
problemSet: AdminProblemSet
}
defineProps<Props>()
</script>
<template>
<n-card title="题单信息" style="margin-bottom: 16px">
<n-descriptions :column="4" bordered>
<n-descriptions-item label="描述">
{{ problemSet.description }}
</n-descriptions-item>
<n-descriptions-item label="创建者">
{{ problemSet.createdBy.username }}
</n-descriptions-item>
<n-descriptions-item label="难度">
<n-tag
:type="
problemSet.difficulty === 'Easy'
? 'success'
: problemSet.difficulty === 'Medium'
? 'warning'
: 'error'
"
>
{{
problemSet.difficulty === "Easy"
? "简单"
: problemSet.difficulty === "Medium"
? "中等"
: "困难"
}}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag
:type="
problemSet.status === 'active'
? 'success'
: problemSet.status === 'archived'
? 'default'
: 'info'
"
>
{{
problemSet.status === "active"
? "活跃"
: problemSet.status === "archived"
? "已归档"
: "草稿"
}}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="可见">
{{ problemSet.visible ? "是" : "否" }}
</n-descriptions-item>
<n-descriptions-item label="题目数量">
{{ problemSet.problemsCount }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problemSet.createTime, "YYYY-MM-DD HH:mm:ss") }}
</n-descriptions-item>
</n-descriptions>
</n-card>
</template>
@@ -1,67 +0,0 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import { parseTime } from "utils/functions"
import type { AdminProblemSetProgress } from "utils/types"
interface Props {
progress: AdminProblemSetProgress[]
}
interface Emits {
(e: "remove-user", userId: number): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
// 定义表格列
const progressColumns = [
{ title: "用户", key: "username", width: 120 },
{
title: "加入时间",
key: "joinTime",
width: 180,
render: (row: AdminProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
},
{ title: "已完成", key: "completedProblemsCount", width: 100 },
{ title: "总题目", key: "totalProblemsCount", width: 100 },
{
title: "进度",
key: "progressPercentage",
width: 100,
render: (row: AdminProblemSetProgress) => `${row.progressPercentage.toFixed(0)}%`,
},
{
title: "是否完成",
key: "isCompleted",
width: 100,
render: (row: AdminProblemSetProgress) => (row.isCompleted ? "是" : "否"),
},
{
title: "操作",
key: "actions",
width: 120,
render: (row: AdminProblemSetProgress) =>
h(
NButton,
{
size: "small",
type: "error",
secondary: true,
onClick: () => emit("remove-user", row.userId),
},
{ default: () => "移除" },
),
},
]
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>用户进度</h3>
</n-flex>
<n-data-table :columns="progressColumns" :data="progress" />
</div>
</template>
-261
View File
@@ -1,261 +0,0 @@
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api"
import { NTabPane, NTabs, NButton } from "naive-ui"
import type {
AdminProblemSet,
AdminProblemSetProblem,
AddProblemToSetRequest,
UpdateProblemInSetRequest,
CreateProblemSetBadgeRequest,
AdminProblemSetBadge,
AdminProblemSetProgress,
} from "utils/types"
import {
getProblemSetDetail,
getProblemSetProblems,
getProblemSetBadges,
getProblemSetProgress,
addProblemToSet,
editProblemInSet,
removeProblemFromSet,
createProblemSetBadge,
editProblemSetBadge,
deleteProblemSetBadge,
removeUserFromProblemSet,
} from "../api"
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
import ProblemManagement from "./components/ProblemManagement.vue"
import BadgeManagement from "./components/BadgeManagement.vue"
import ProgressManagement from "./components/ProgressManagement.vue"
import ProblemModal from "./components/ProblemModal.vue"
import BadgeModal from "./components/BadgeModal.vue"
const route = useRoute()
const router = useRouter()
const message = useMessage()
/** 奖章弹窗的表单值:条件类型是「完成所有题目」时不带 conditionValue,后端补 0 */
type BadgeFormData = Omit<CreateProblemSetBadgeRequest, "conditionValue"> & {
conditionValue?: number
}
const problemSetId = computed(() => Number(route.params.problemSetId))
const problemSet = ref<AdminProblemSet | null>(null)
const problems = ref<AdminProblemSetProblem[]>([])
const badges = ref<AdminProblemSetBadge[]>([])
const progress = ref<AdminProblemSetProgress[]>([])
// 模态框状态。添加和编辑共用一个弹窗,editing* 为 null 就是添加
const showProblemModal = ref(false)
const showBadgeModal = ref(false)
// 编辑数据
const editingProblem = ref<AdminProblemSetProblem | null>(null)
const editingBadge = ref<AdminProblemSetBadge | null>(null)
async function loadProblemSetDetail() {
try {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res
} catch (err) {
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
}
}
async function loadProblems() {
try {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res
} catch (err) {
message.error("加载题目列表失败:" + errorMessage(err, "未知错误"))
}
}
async function loadBadges() {
try {
const res = await getProblemSetBadges(problemSetId.value)
badges.value = res
} catch (err) {
message.error("加载奖章列表失败:" + errorMessage(err, "未知错误"))
}
}
async function loadProgress() {
try {
const res = await getProblemSetProgress(problemSetId.value)
progress.value = res
} catch (err) {
message.error("加载进度列表失败:" + errorMessage(err, "未知错误"))
}
}
async function handleAddProblem(data: AddProblemToSetRequest) {
try {
await addProblemToSet(problemSetId.value, data)
message.success("题目添加成功")
showProblemModal.value = false
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err) {
message.error("添加题目失败:" + errorMessage(err, "未知错误"))
}
}
async function handleRemoveProblem(problemSetProblemId: number) {
try {
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
message.success("题目移除成功")
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err) {
message.error("移除题目失败:" + errorMessage(err, "未知错误"))
}
}
async function handleEditProblem(data: UpdateProblemInSetRequest) {
if (!editingProblem.value) return
try {
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
message.success("题目编辑成功")
showProblemModal.value = false
loadProblems()
} catch (err) {
message.error("编辑题目失败:" + errorMessage(err, "未知错误"))
}
}
async function handleAddBadge(data: BadgeFormData) {
try {
await createProblemSetBadge(problemSetId.value, data)
message.success("奖章创建成功")
showBadgeModal.value = false
loadBadges()
} catch (err) {
message.error("创建奖章失败:" + errorMessage(err, "未知错误"))
}
}
async function handleDeleteBadge(badgeId: number) {
try {
await deleteProblemSetBadge(problemSetId.value, badgeId)
message.success("奖章删除成功")
loadBadges()
} catch (err) {
message.error("删除奖章失败:" + errorMessage(err, "未知错误"))
}
}
async function handleEditBadge(data: BadgeFormData) {
if (!editingBadge.value) return
try {
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
message.success("奖章编辑成功")
showBadgeModal.value = false
loadBadges()
} catch (err) {
message.error("编辑奖章失败:" + errorMessage(err, "未知错误"))
}
}
async function handleRemoveUser(userId: number) {
try {
await removeUserFromProblemSet(problemSetId.value, userId)
message.success("用户移除成功")
loadProgress()
} catch (err) {
message.error("移除用户失败:" + errorMessage(err, "未知错误"))
}
}
function openAddProblemModal() {
editingProblem.value = null
showProblemModal.value = true
}
function openAddBadgeModal() {
editingBadge.value = null
showBadgeModal.value = true
}
function openEditProblemModal(problem: AdminProblemSetProblem) {
editingProblem.value = problem
showProblemModal.value = true
}
function openEditBadgeModal(badge: AdminProblemSetBadge) {
editingBadge.value = badge
showBadgeModal.value = true
}
onMounted(() => {
loadProblemSetDetail()
loadProblems()
loadBadges()
loadProgress()
})
</script>
<template>
<div v-if="problemSet">
<PageHeader :title="problemSet.title" :back="{ name: 'admin problemset list' }">
<template #actions>
<n-button
type="primary"
@click="
router.push({
name: 'admin problemset edit',
params: { problemSetId },
})
"
>
编辑题单
</n-button>
</template>
</PageHeader>
<ProblemSetInfo :problem-set="problemSet" />
<n-tabs type="line">
<n-tab-pane name="problems" tab="题目管理">
<ProblemManagement
:problems="problems"
@add-problem="openAddProblemModal"
@edit-problem="openEditProblemModal"
@remove-problem="handleRemoveProblem"
/>
</n-tab-pane>
<n-tab-pane name="badges" tab="奖章管理">
<BadgeManagement
:badges="badges"
@add-badge="openAddBadgeModal"
@edit-badge="openEditBadgeModal"
@delete-badge="handleDeleteBadge"
/>
</n-tab-pane>
<n-tab-pane name="progress" tab="进度管理">
<ProgressManagement :progress="progress" @remove-user="handleRemoveUser" />
</n-tab-pane>
</n-tabs>
<!-- 模态框组件 -->
<ProblemModal
v-model:show="showProblemModal"
:problem="editingProblem"
@create="handleAddProblem"
@update="handleEditProblem"
/>
<BadgeModal
v-model:show="showBadgeModal"
:badge="editingBadge"
@create="handleAddBadge"
@update="handleEditBadge"
/>
</div>
</template>
+792 -140
View File
@@ -1,168 +1,820 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from "naive-ui"
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import type { CreateProblemSetData, EditProblemSetData } from "utils/types" import { fromPickerValue, parseTime, toPickerValue, zonedParts } from "utils/functions"
import { fromPickerValue, toPickerValue } from "utils/functions" import type { AdminProblemRow, AdminProblemSet, AdminProblemSetBadge } from "utils/types"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api" import {
addProblemsToSet,
createProblemSet,
createProblemSetBadge,
deleteProblemSet,
deleteProblemSetBadge,
editProblemSet,
editProblemSetBadge,
getLibraryProblemList,
getProblemSetBadges,
getProblemSetDetail,
getProblemSetProblems,
removeProblemFromSet,
reorderProblemSetProblems,
setProblemRequired,
type BadgeBody,
} from "../api"
import BadgeModal from "./components/BadgeModal.vue"
/**
* 新建 / 编辑题单,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开;
* 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。
*
* 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。
* 新建时题目先攒在页面上,保存时建好题单再一次加进去;编辑时加、删、排、切选做当场生效。
*/
const props = defineProps<{ problemSetId?: string }>()
const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const dialog = useDialog()
const theme = useThemeVars()
const problemSetId = computed(() => Number(route.params.problemSetId)) const editing = computed(() => !!props.problemSetId)
const isEdit = computed(() => !!problemSetId.value) const id = computed(() => Number(props.problemSetId))
const ready = ref(false)
const saving = ref(false)
const current = ref<AdminProblemSet | null>(null)
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({ const form = reactive({ title: "", description: "", visible: true })
title: "",
description: "",
difficulty: "Easy",
status: "draft",
visible: false,
endTime: null,
})
// n-date-picker 按浏览器本地渲染,所以要平移一次再绑定(见 utils/functions.ts // ---------------------------------------------------------------- 布置期
// 的 toPickerValue)。`formData.endTime` 里始终存**真实时刻**,只有喂给选择器那一步换。
const endTimeTimestamp = computed({
get: () => (formData.value.endTime ? toPickerValue(formData.value.endTime.getTime()) : null),
set: (val: number | null) => {
formData.value.endTime = val ? new Date(fromPickerValue(val)) : null
},
})
const difficultyOptions = [ const DAY = 86_400_000
{ label: "简单", value: "Easy" }, const OFFSET = 8 * 3_600_000
{ label: "中等", value: "Medium" }, const CHOICES = [
{ label: "困难", value: "Hard" }, { days: 7, label: "一周" },
{ days: 14, label: "两周" },
{ days: 28, label: "四周" },
] ]
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]
const statusOptions = [ /** 东八区某一天结束的时刻(23:59:59.999) */
{ label: "活跃", value: "active" }, function endOfDay(ms: number) {
{ label: "已归档", value: "archived" }, const parts = zonedParts(new Date(ms))!
{ label: "草稿", value: "draft" }, return Date.UTC(parts.year, parts.month - 1, parts.day + 1) - OFFSET - 1
]
const loading = ref(false)
async function loadProblemSetDetail() {
if (!isEdit.value) return
try {
const res = await getProblemSetDetail(problemSetId.value)
const data = res
formData.value = {
id: data.id,
title: data.title,
description: data.description,
difficulty: data.difficulty,
status: data.status,
visible: data.visible,
endTime: data.endTime ? new Date(data.endTime) : null,
}
} catch (err) {
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
}
} }
async function handleSubmit() { /** "none" 不布置 / 天数 / "pick" 自己选日期 */
if (!formData.value.title?.trim()) { const choice = ref<"none" | "pick" | number>(14)
message.error("请输入题单标题") const picked = ref(toPickerValue(Date.now() + 14 * DAY))
const untilMs = computed(() => {
if (choice.value === "none") return null
if (choice.value === "pick") return endOfDay(fromPickerValue(picked.value))
return endOfDay(Date.now() + choice.value * DAY)
})
function dayText(ms: number) {
const iso = new Date(ms).toISOString()
return `${parseTime(iso, "M月D日")} 周${WEEKDAYS[new Date(ms + OFFSET).getUTCDay()]}`
}
const running = computed(() => !!current.value?.assigning)
const expired = computed(
() => !!current.value && !current.value.assigning && !!current.value.assignedUntil,
)
const assignHint = computed(() => {
if (untilMs.value === null) {
return running.value
? "保存后马上结束布置,以前的代码都放出来。"
: "不布置:学生照样能加入、做题,只是不藏以前的代码。"
}
if (untilMs.value <= Date.now()) return "这一天已经过了,选个以后的日子。"
const head = `布置到 ${dayText(untilMs.value)}。`
return running.value
? `${head}这一轮从 ${parseTime(current.value!.assignedAt!, "M月D日")} 开始,改日期只是延长或提前结束。`
: `${head}${expired.value ? "上一轮已经结束,保存就是再布置一次。" : ""}`
})
const disabledDate = (ts: number) => fromPickerValue(ts) + DAY <= Date.now()
// ---------------------------------------------------------------- 题目
type Item = { id: number; displayId: string; title: string; isRequired: boolean }
const items = ref<Item[]>([])
async function loadProblems() {
const rows = await getProblemSetProblems(id.value)
items.value = rows.map((row) => ({
id: row.id,
displayId: row.problemDisplayId,
title: row.title,
isRequired: row.isRequired,
}))
}
const keyword = ref("")
const results = ref<AdminProblemRow[]>([])
const searching = ref(false)
/** 输入框里粘了好几个题号(空格、逗号、顿号隔开) */
const pasted = computed(() => {
const parts = keyword.value
.split(/[\s,,、;;]+/)
.map((part) => part.trim())
.filter(Boolean)
return parts.length > 1 ? parts : []
})
watchDebounced(
keyword,
async (value) => {
if (!value.trim() || pasted.value.length) {
results.value = []
return return
} }
searching.value = true
if (!formData.value.description?.trim()) {
message.error("请输入题单描述")
return
}
loading.value = true
try { try {
if (isEdit.value) { results.value = (await getLibraryProblemList(0, 8, value.trim())).results
await editProblemSet(formData.value as EditProblemSetData)
message.success("题单更新成功")
} else {
await createProblemSet(formData.value as CreateProblemSetData)
message.success("题单创建成功")
}
router.push({ name: "admin problemset list" })
} catch (err) {
message.error((isEdit.value ? "更新" : "创建") + "题单失败:" + errorMessage(err, "未知错误"))
} finally { } finally {
loading.value = false searching.value = false
}
},
{ debounce: 300 },
)
const added = (displayId: string) =>
items.value.some((item) => item.displayId.toLowerCase() === displayId.toLowerCase())
async function addMany(displayIds: string[], titles = new Map<string, string>()) {
if (!editing.value) {
// 新建时先攒着;题目名要查一次,粘贴进来的题号只有号
for (const typed of displayIds) {
if (added(typed)) continue
let displayId = typed
let title = titles.get(typed)
if (!title) {
const found = (await getLibraryProblemList(0, 5, typed)).results.find(
(row) => row._id.toLowerCase() === typed.toLowerCase(),
)
if (!found) {
message.warning(`题号 ${typed} 找不到(或者没公开)`)
continue
}
title = found.title
displayId = found._id
}
items.value.push({ id: 0, displayId, title, isRequired: true })
}
keyword.value = ""
return
}
try {
const res = await addProblemsToSet(id.value, displayIds)
const notes = []
if (res.missing.length) notes.push(`找不到:${res.missing.join("、")}`)
if (res.duplicate.length) notes.push(`已经在里面:${res.duplicate.join("、")}`)
if (notes.length) message.warning(notes.join(";"))
else message.success(`加了 ${res.added.length} 道`)
keyword.value = ""
await Promise.all([loadProblems(), reloadSet()])
} catch (err) {
message.error(errorMessage(err))
} }
} }
onMounted(() => { function addRow(row: AdminProblemRow) {
if (isEdit.value) { addMany([row._id], new Map([[row._id, row.title]]))
loadProblemSetDetail() }
async function toggleRequired(item: Item) {
item.isRequired = !item.isRequired
if (!editing.value) return
try {
await setProblemRequired(id.value, item.id, item.isRequired)
} catch (err) {
item.isRequired = !item.isRequired
message.error(errorMessage(err))
} }
}) }
function remove(item: Item) {
if (!editing.value) {
items.value = items.value.filter((it) => it !== item)
return
}
dialog.warning({
title: "从题单里拿掉这道题?",
content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`,
positiveText: "拿掉",
negativeText: "不拿",
onPositiveClick: async () => {
try {
await removeProblemFromSet(id.value, item.id)
await Promise.all([loadProblems(), reloadSet()])
} catch (err) {
message.error(errorMessage(err))
}
},
})
}
// 拖动排序:原生 drag & drop,和比赛出卷那页同一套
const dragging = ref<number | null>(null)
function onDragStart(index: number) {
dragging.value = index
}
function onDragEnter(index: number) {
const from = dragging.value
if (from === null || from === index) return
const list = [...items.value]
const [moved] = list.splice(from, 1)
list.splice(index, 0, moved!)
items.value = list
dragging.value = index
}
async function onDragEnd() {
if (dragging.value === null) return
dragging.value = null
if (!editing.value) return
try {
await reorderProblemSetProblems(
id.value,
items.value.map((item) => item.id),
)
} catch (err) {
message.error(errorMessage(err))
await loadProblems()
}
}
const optionalCount = computed(() => items.value.filter((item) => !item.isRequired).length)
// ---------------------------------------------------------------- 奖章
const badges = ref<AdminProblemSetBadge[]>([])
const badgeModal = ref(false)
const editingBadge = ref<AdminProblemSetBadge | null>(null)
function badgeCondition(badge: AdminProblemSetBadge) {
return badge.conditionType === "all_problems" ? "全部做完" : `做对 ${badge.conditionValue} 道`
}
function openBadge(badge: AdminProblemSetBadge | null) {
editingBadge.value = badge
badgeModal.value = true
}
async function saveBadge(data: BadgeBody) {
try {
if (editingBadge.value) await editProblemSetBadge(id.value, editingBadge.value.id, data)
else await createProblemSetBadge(id.value, data)
badgeModal.value = false
badges.value = await getProblemSetBadges(id.value)
} catch (err) {
message.error(errorMessage(err))
}
}
function removeBadge(badge: AdminProblemSetBadge) {
dialog.warning({
title: `删掉奖章「${badge.name}」?`,
content: badge.earnedCount
? `已经拿到它的 ${badge.earnedCount} 个人也会一起被收回。`
: "还没有人拿到它。",
positiveText: "删掉",
negativeText: "不删",
onPositiveClick: async () => {
try {
await deleteProblemSetBadge(id.value, badge.id)
badges.value = await getProblemSetBadges(id.value)
} catch (err) {
message.error(errorMessage(err))
}
},
})
}
// ---------------------------------------------------------------- 载入 / 保存
async function reloadSet() {
current.value = await getProblemSetDetail(id.value)
}
async function load() {
keyword.value = ""
if (!editing.value) {
ready.value = true
return
}
await reloadSet()
const set = current.value!
Object.assign(form, { title: set.title, description: set.description, visible: set.visible })
if (set.assigning) {
choice.value = "pick"
picked.value = toPickerValue(Date.parse(set.assignedUntil!))
} else {
choice.value = "none"
}
await Promise.all([loadProblems(), getProblemSetBadges(id.value).then((b) => (badges.value = b))])
ready.value = true
}
async function submit() {
if (!form.title.trim()) {
message.error("题单还没起名字")
return
}
if (untilMs.value !== null && untilMs.value <= Date.now()) {
message.error("布置到的日期已经过了")
return
}
const body = {
title: form.title.trim(),
description: form.description.trim(),
visible: form.visible,
assignedUntil: untilMs.value === null ? null : new Date(untilMs.value).toISOString(),
}
saving.value = true
try {
if (editing.value) {
current.value = await editProblemSet(id.value, body)
message.success("改好了")
return
}
const created = await createProblemSet(body)
if (items.value.length) {
await addProblemsToSet(
created.id,
items.value.map((item) => item.displayId),
)
const optional = items.value.filter((item) => !item.isRequired).map((item) => item.displayId)
if (optional.length) {
const rows = await getProblemSetProblems(created.id)
for (const row of rows.filter((r) => optional.includes(r.problemDisplayId))) {
await setProblemRequired(created.id, row.id, false)
}
}
}
message.success(
items.value.length ? `题单建好了,${items.value.length} 道题都加进去了` : "题单建好了",
)
router.replace({ name: "admin problemset edit", params: { problemSetId: created.id } })
} catch (err) {
message.error(errorMessage(err))
} finally {
saving.value = false
}
}
function removeSet() {
const set = current.value!
dialog.error({
title: `删除题单「${set.title}」?`,
content: set.participantCount
? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。`
: "还没有人加入。删掉找不回来。",
positiveText: "删除",
negativeText: "不删",
onPositiveClick: async () => {
try {
await deleteProblemSet(set.id)
router.replace({ name: "admin problemset list" })
} catch (err) {
message.error(errorMessage(err))
}
},
})
}
watch(() => props.problemSetId, load)
onMounted(load)
</script> </script>
<template> <template>
<div> <PageHeader :title="editing ? '编辑题单' : '新建题单'" :back="{ name: 'admin problemset list' }">
<PageHeader <template v-if="editing" #actions>
:title="isEdit ? '编辑题单' : '新建题单'" <n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank">
:back=" 到前台看这个题单
isEdit
? { name: 'admin problemset detail', params: { problemSetId } }
: { name: 'admin problemset list' }
"
/>
<n-form :model="formData" label-placement="top">
<n-flex>
<n-form-item label="题单标题" required>
<n-input
v-model:value="formData.title"
placeholder="请输入题单标题"
maxlength="200"
show-count
/>
</n-form-item>
<n-form-item label="难度">
<n-select
style="width: 100px"
v-model:value="formData.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
/>
</n-form-item>
<n-form-item label="状态">
<n-select
style="width: 100px"
v-model:value="formData.status"
:options="statusOptions"
placeholder="选择状态"
/>
</n-form-item>
<n-form-item label="截止时间">
<n-date-picker
v-model:value="endTimeTimestamp"
type="datetime"
clearable
placeholder="不设置则无截止时间"
/>
</n-form-item>
<n-form-item v-if="isEdit" label="是否可见">
<n-switch v-model:value="formData.visible" />
</n-form-item>
</n-flex>
<n-form-item label="题单描述" required>
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="请输入题单描述"
:rows="4"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? "更新" : "创建" }}
</n-button> </n-button>
</n-form-item> </template>
</n-form> </PageHeader>
<div v-if="ready" class="cols">
<section class="card form">
<div class="field">
<span class="label">名字</span>
<n-input v-model:value="form.title" placeholder="比如 字符串方法" maxlength="200" />
</div> </div>
<div class="field">
<span class="label">说明</span>
<n-input v-model:value="form.description" placeholder="选填,比如:第 5 章 字符串方法" />
</div>
<div class="field top">
<span class="label">布置</span>
<div class="stack">
<n-flex :size="6" align="center">
<button class="chip" :class="{ on: choice === 'none' }" @click="choice = 'none'">
不布置
</button>
<button
v-for="c in CHOICES"
:key="c.days"
class="chip"
:class="{ on: choice === c.days }"
@click="choice = c.days"
>
{{ c.label }}
</button>
<button class="chip" :class="{ on: choice === 'pick' }" @click="choice = 'pick'">
选日期…
</button>
<n-date-picker
v-if="choice === 'pick'"
v-model:value="picked"
type="date"
:is-date-disabled="disabledDate"
style="width: 150px"
/>
</n-flex>
<span class="hint">{{ assignHint }}</span>
<span class="hint">
布置期内,题单里这几道题学生以前交的代码先藏起来(不管加没加入),在题单里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。
</span>
</div>
</div>
<div class="field">
<span class="label">公开</span>
<n-switch v-model:value="form.visible" />
<span class="hint">{{
form.visible ? "学生能在题单列表里看到" : "藏起来,学生看不到也进不去"
}}</span>
</div>
<div v-if="current" class="stats">
<span>{{ current.participantCount }} 人加入 · {{ current.completedCount }} 人做完</span>
<div class="spacer"></div>
<a :href="`/problemset/${current.id}?tab=class`" target="_blank">看全班情况 ›</a>
</div>
<div class="save">
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
<span class="hint"
>{{ items.length }} 道题<template v-if="optionalCount">
· {{ optionalCount }} 道选做</template
><template v-if="editing"> · {{ badges.length }} 个奖章</template></span
>
<div class="spacer"></div>
<n-button v-if="editing" text type="error" @click="removeSet">删除题单</n-button>
</div>
</section>
<div class="right">
<section class="card picker">
<div class="picker-head">
<b>题目</b>
<span class="hint">{{ items.length }} 道 · 拖动排顺序 · 点「必做」切成选做</span>
</div>
<n-input
v-model:value="keyword"
clearable
:loading="searching"
placeholder="搜题号或题目名;也能一次粘贴多个题号,如 3065 3066 3067"
@keyup.enter="pasted.length && addMany(pasted)"
/>
<div v-if="pasted.length" class="results">
<div class="result">
<span class="grow">粘贴了 {{ pasted.length }} 个题号:{{ pasted.join("、") }}</span>
<n-button size="small" type="primary" @click="addMany(pasted)">全部加进来</n-button>
</div>
</div>
<div v-else-if="results.length" class="results">
<div v-for="row in results" :key="row.id" class="result">
<span class="muted num">{{ row._id }}</span>
<span class="ell grow">{{ row.title }}</span>
<span v-if="added(row._id)" class="muted tiny">已加入</span>
<n-button v-else size="small" text type="primary" @click="addRow(row)">+ 加入</n-button>
</div>
</div>
<div v-if="items.length" class="list">
<div
v-for="(item, index) in items"
:key="item.displayId"
class="item"
:class="{ dragging: dragging === index }"
draggable="true"
@dragstart="onDragStart(index)"
@dragenter.prevent="onDragEnter(index)"
@dragover.prevent
@dragend="onDragEnd"
>
<span class="grip" aria-hidden="true">⋮⋮</span>
<b class="num idx">{{ index + 1 }}</b>
<span class="muted num tiny pid">{{ item.displayId }}</span>
<span class="ell grow">{{ item.title }}</span>
<button
class="chip small"
:class="{ dashed: item.isRequired }"
:title="item.isRequired ? '点一下改成选做' : '点一下改回必做'"
@click="toggleRequired(item)"
>
{{ item.isRequired ? "必做" : "选做" }}
</button>
<n-button size="small" text :aria-label="`拿掉 ${item.title}`" @click="remove(item)"
>✕</n-button
>
</div>
</div>
<div v-else class="empty hint">还没加题。在上面输入题号,比如 3065</div>
</section>
<section class="card picker">
<div class="picker-head">
<b>奖章</b>
<span class="hint">学生做对几道就发;改了条件会按现有进度重发</span>
<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-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" />
<b class="ell badge-name">{{ badge.name }}</b>
<span class="chip small static">{{ badgeCondition(badge) }}</span>
<span class="muted ell grow tiny">{{ badge.description }}</span>
<span class="muted num tiny">{{ badge.earnedCount }} 人拿到</span>
<n-button size="small" text type="primary" @click="openBadge(badge)">改</n-button>
<n-button
size="small"
text
:aria-label="`删掉 ${badge.name}`"
@click="removeBadge(badge)"
>✕</n-button
>
</div>
</div>
<div v-else class="empty hint">还没有奖章。比如「做对 3 道」发一个、「全部做完」发一个</div>
</section>
</div>
</div>
<BadgeModal
v-model:show="badgeModal"
:badge="editingBadge"
:problems-count="items.length"
@save="saveBadge"
/>
</template> </template>
<style scoped>
.cols {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
.card {
box-sizing: border-box;
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.form {
flex: 1 1 420px;
max-width: 480px;
}
.right {
flex: 999 1 480px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.picker {
gap: 10px;
}
.picker-head {
display: flex;
align-items: center;
gap: 10px;
}
.picker-head b {
font-size: 15px;
}
.field {
display: flex;
align-items: center;
gap: 12px;
}
.field.top {
align-items: flex-start;
}
.field.top .label {
line-height: 28px;
}
.label {
width: 40px;
flex-shrink: 0;
font-size: 13px;
color: v-bind("theme.textColor2");
}
.stack {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.grow {
flex-grow: 1;
min-width: 0;
}
.spacer {
flex-grow: 1;
}
.hint {
font-size: 12px;
line-height: 1.6;
color: v-bind("theme.textColor3");
}
.muted {
color: v-bind("theme.textColor3");
}
.tiny {
font-size: 12px;
flex-shrink: 0;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip {
height: 28px;
padding: 0 12px;
border-radius: 14px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
white-space: nowrap;
}
.chip.on {
border-color: v-bind("theme.primaryColor");
color: v-bind("theme.primaryColor");
font-weight: 600;
}
.chip.small {
height: 22px;
padding: 0 8px;
font-size: 12px;
flex-shrink: 0;
}
.chip.dashed {
border-style: dashed;
color: v-bind("theme.textColor3");
}
.chip.static {
cursor: default;
}
.stats {
display: flex;
align-items: center;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
font-size: 13px;
background: v-bind("theme.actionColor");
}
.stats a {
color: v-bind("theme.primaryColor");
text-decoration: none;
}
.save {
display: flex;
align-items: center;
gap: 10px;
padding-top: 12px;
border-top: 1px solid v-bind("theme.dividerColor");
}
.results {
margin-top: -4px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.result {
height: 36px;
box-sizing: border-box;
padding: 0 12px;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.result + .result {
border-top: 1px solid v-bind("theme.dividerColor");
}
.list {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
overflow: hidden;
}
.item {
height: 36px;
box-sizing: border-box;
padding: 0 12px;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
background: v-bind("theme.cardColor");
}
.item + .item {
border-top: 1px solid v-bind("theme.dividerColor");
}
.item.dragging {
opacity: 0.5;
}
.item.badge {
height: 42px;
}
.grip {
cursor: grab;
color: v-bind("theme.textColor3");
letter-spacing: -3px;
user-select: none;
}
.idx {
width: 20px;
text-align: right;
}
.pid {
width: 40px;
}
.badge-icon {
width: 26px;
height: 26px;
flex-shrink: 0;
}
.badge-name {
width: 80px;
flex-shrink: 0;
}
.empty {
padding: 16px 0;
text-align: center;
}
</style>
+53 -115
View File
@@ -5,87 +5,68 @@ import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { AdminProblemSet } from "utils/types" import type { AdminProblemSet } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api" import { getProblemSetList, toggleProblemSetVisible } from "../api"
import Actions from "./components/Actions.vue" import { NButton, NSwitch, NTag } from "naive-ui"
import { NTag, NSwitch } from "naive-ui"
/**
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
*/
const router = useRouter()
const total = ref(0) const total = ref(0)
const problemSets = ref<AdminProblemSet[]>([]) const problemSets = ref<AdminProblemSet[]>([])
interface ProblemSetQuery { const { query, clearQuery } = usePagination<{ keyword: string }>({ keyword: "" })
keyword: string
difficulty: string function edit(row: AdminProblemSet) {
status: string router.push({ name: "admin problemset edit", params: { problemSetId: row.id } })
} }
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>({
keyword: "",
difficulty: "",
status: "",
})
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "全部", value: "" },
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
const columns: DataTableColumn<AdminProblemSet>[] = [ const columns: DataTableColumn<AdminProblemSet>[] = [
{ title: "ID", key: "id", width: 80 }, { title: "ID", key: "id", width: 70 },
{ title: "标题", key: "title", minWidth: 200 }, {
{ title: "描述", key: "description", minWidth: 300, ellipsis: true }, title: "题单",
key: "title",
minWidth: 200,
render: (row) =>
h(NButton, { text: true, onClick: () => edit(row) }, { default: () => row.title }),
},
{ title: "题数", key: "problemsCount", width: 70 },
{
title: "加入 / 做完",
key: "participantCount",
width: 110,
render: (row) => `${row.participantCount} / ${row.completedCount}`,
},
{ title: "奖章", key: "badgeCount", width: 70 },
{
title: "布置",
key: "assignedUntil",
width: 130,
render: (row) =>
row.assigning
? h(
NTag,
{ type: "info", size: "small", bordered: false },
{ default: () => `到 ${parseTime(row.assignedUntil!, "M月D日")}` },
)
: "—",
},
{ {
title: "创建者", title: "创建者",
key: "created_by", key: "createdBy",
width: 120, width: 110,
render: (row) => row.createdBy.username, render: (row) => row.createdBy.username,
}, },
{
title: "难度",
key: "difficulty",
width: 100,
render: (row) => {
const difficultyMap = {
Easy: { type: "success" as const, text: "简单" },
Medium: { type: "warning" as const, text: "中等" },
Hard: { type: "error" as const, text: "困难" },
}
const config = difficultyMap[row.difficulty]
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
},
},
{
title: "状态",
key: "status",
width: 100,
render: (row) => {
const statusMap = {
active: { type: "success" as const, text: "活跃" },
archived: { type: "default" as const, text: "已归档" },
draft: { type: "info" as const, text: "草稿" },
}
const config = statusMap[row.status]
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
},
},
{ {
title: "创建时间", title: "创建时间",
key: "create_time", key: "createTime",
width: 180, width: 120,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"), render: (row) => parseTime(row.createTime, "YYYY-MM-DD"),
}, },
{ {
title: "可见", title: "公开",
key: "visible", key: "visible",
width: 100, width: 80,
render: (row) => render: (row) =>
h(NSwitch, { h(NSwitch, {
value: row.visible, value: row.visible,
@@ -94,84 +75,41 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
onUpdateValue: () => toggleVisible(row.id), onUpdateValue: () => toggleVisible(row.id),
}), }),
}, },
{
title: "选项",
key: "actions",
fixed: "right",
width: 300,
render: (row) =>
h(Actions, {
problemSetId: row.id,
onUpdated: listProblemSets,
}),
},
] ]
async function listProblemSets() { async function listProblemSets() {
if (query.page < 1) query.page = 1 if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
const res = await getProblemSetList( const res = await getProblemSetList(offset, query.limit, query.keyword)
offset,
query.limit,
query.keyword,
query.difficulty,
query.status,
)
total.value = res.total total.value = res.total
problemSets.value = res.results problemSets.value = res.results
} }
async function toggleVisible(problemSetId: number) { async function toggleVisible(problemSetId: number) {
await toggleProblemSetVisible(problemSetId) const updated = await toggleProblemSetVisible(problemSetId)
problemSets.value = problemSets.value.map((it) => { problemSets.value = problemSets.value.map((it) => (it.id === problemSetId ? updated : it))
if (it.id === problemSetId) {
it.visible = !it.visible
}
return it
})
} }
onMounted(listProblemSets) onMounted(listProblemSets)
watchDebounced(() => query.keyword, listProblemSets, { debounce: 500, maxWait: 1000 })
// 监听搜索关键词变化(防抖) watch(() => [query.page, query.limit], listProblemSets)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.difficulty, query.status], 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 type="primary" @click="router.push({ name: 'admin problemset create' })">
新建题单 新建题单
</n-button> </n-button>
</template> </template>
<template #filters> <template #filters>
<n-input <n-input
v-model:value="query.keyword" v-model:value="query.keyword"
placeholder="输入标题关键字" placeholder="搜题单名字"
clearable clearable
@clear="clearQuery" @clear="clearQuery"
style="width: 220px" style="width: 220px"
/> />
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="全部难度"
style="width: 120px"
clearable
/>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="全部状态"
style="width: 120px"
clearable
/>
</template> </template>
</PageHeader> </PageHeader>
<n-data-table striped :columns="columns" :data="problemSets" /> <n-data-table striped :columns="columns" :data="problemSets" />
+15 -25
View File
@@ -36,10 +36,10 @@ import {
type HeatmapItem, type HeatmapItem,
type SolvedList, type SolvedList,
type ProblemSet, type ProblemSet,
type ProblemSetBadge, type ProblemSetClassView,
type ProblemSetList, type ProblemSetList,
type ProblemSetLock,
type ProblemSetProblem, type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge, type UserBadge,
type FlowchartStatistics, type FlowchartStatistics,
type SubmissionStatistics, type SubmissionStatistics,
@@ -482,23 +482,15 @@ 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( export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
offset = 0, return api.get<ProblemSetList>("problem-sets", { params: { offset, limit, keyword } })
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
} }
export function getProblemSetDetail(id: number) { export function getProblemSetDetail(id: number) {
@@ -517,20 +509,18 @@ 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 getProblemSetBadges(problemSetId: number) { /** 老师看某个班在题单里的情况;不传班级就看服务端推断的那个班 */
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`) export function getProblemSetClassView(problemSetId: number, className = "") {
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
params: className ? { className } : {},
})
} }
export function getProblemSetUserProgress( /** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */
problemSetId: number, export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
params?: { return api.get<ProblemSetLock[]>("problem-sets/locks", {
limit?: number params: "id" in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
offset?: number })
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
return api.get<ProblemSetProgressList>(`problem-sets/${problemSetId}/user-progress`, { params })
} }
export function getExercises(tutorialId: number): Promise<Exercise[]> { export function getExercises(tutorialId: number): Promise<Exercise[]> {
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import type { RouteLocationRaw } from "vue-router" import type { RouteLocationRaw } from "vue-router"
import { getProblemSetDetail, getProblemSetProblems } from "oj/api" import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
import { useContestStore } from "oj/store/contest" import { useContestStore } from "oj/store/contest"
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart" import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
import { useLessonStore } from "oj/store/lesson" import { useLessonStore } from "oj/store/lesson"
@@ -161,6 +161,7 @@ const setBar = computed(() => {
const done = required.filter((item) => item.isCompleted).length const done = required.filter((item) => item.isCompleted).length
return { return {
title: problemSet.value.title, title: problemSet.value.title,
joined: problemSet.value.userProgress.isJoined,
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } }, home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
done, done,
total: required.length, total: required.length,
@@ -171,6 +172,22 @@ const setBar = computed(() => {
} }
}) })
// 没加入就点进了题(直接打开的链接):在条上给个「加入」,不然做对了也不记进度
const joiningSet = ref(false)
async function joinHere() {
const id = ctx.value.problemSetId
if (!id || joiningSet.value) return
joiningSet.value = true
try {
await joinProblemSet(Number(id))
problemSet.value = await getProblemSetDetail(Number(id))
} catch {
// 加入失败就还是那条提示,回题单页再点一次
} finally {
joiningSet.value = false
}
}
// 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉 // 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉
onSolvedHere(() => { onSolvedHere(() => {
const item = setProblems.value.find( const item = setProblems.value.find(
@@ -294,13 +311,19 @@ onSolvedHere(() => {
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`"> <router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
‹ {{ setBar.title }} ‹ {{ setBar.title }}
</router-link> </router-link>
<div class="set-progress" aria-hidden="true"> <div v-if="setBar.joined" 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>
<span class="meta"> <span v-if="setBar.joined" class="meta">
做完 {{ setBar.done }}/{{ setBar.total }} 做完 {{ setBar.done }}/{{ setBar.total }}
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template> <template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
</span> </span>
<template v-else>
<span class="meta">还没加入,在这里做对了不算进度</span>
<n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere"
>加入题单</n-button
>
</template>
<div class="spacer" /> <div class="spacer" />
<component <component
:is="setBar.previous ? 'router-link' : 'span'" :is="setBar.previous ? 'router-link' : 'span'"
@@ -236,7 +236,8 @@ function goCode() {
</template> </template>
</div> </div>
<p v-if="hiddenCount" class="note"> <p v-if="hiddenCount" class="note">
另外 {{ hiddenCount }} 次是加入题单之前画的,先藏起来了:在题单里画到 A 或 S 就解锁 另外 {{ hiddenCount }} 次是题单布置之前画的,先藏起来了:在题单里画到 A 或
S,或者布置期结束就解锁
</p> </p>
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 --> <!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
@@ -3,6 +3,7 @@ import { useThemeVars } from "naive-ui"
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api" import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import LockNote from "oj/problemset/components/LockNote.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
@@ -139,7 +140,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
@@ -332,7 +333,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"
@@ -349,8 +350,9 @@ watch(query, () => {
<path d="M8 11V8a4 4 0 018 0v3" /> <path d="M8 11V8a4 4 0 018 0v3" />
</svg> </svg>
<span> <span>
加入题单之前的 有 {{ lockedCount }} 次提交先藏起来了:<LockNote
{{ lockedCount }} 次提交先藏起来了,在题单里做出这道题就能看(题单过了截止时间也会解锁) :problem="{ displayId: problemDisplayId }"
/>
</span> </span>
</div> </div>
</div> </div>
+37
View File
@@ -0,0 +1,37 @@
import type { ProblemSet, ProblemSetBadge } from "utils/types"
/**
* 奖章的两种条件和后端 eligibleForBadge 同一个口径:
* - 全部做完:必做题全对(completedCount === totalCount)
* - 做对 N 道:做对的全部题数(含选做,solvedCount)≥ N
*/
export function badgeCondition(badge: ProblemSetBadge) {
return badge.conditionType === "all_problems" ? "全部做完" : `做对 ${badge.conditionValue} 道`
}
/** 拿到这枚奖章还差几道;已经拿到为 0 */
export function badgeShortfall(badge: ProblemSetBadge, set: ProblemSet) {
if (badge.isEarned) return 0
const progress = set.userProgress
const remaining =
badge.conditionType === "all_problems"
? set.requiredCount - progress.completedCount
: badge.conditionValue - progress.solvedCount
return Math.max(1, remaining)
}
/** 阶梯顺序:按要做对的题数从少到多,「全部做完」排最后 */
export function ladder(set: ProblemSet) {
const need = (badge: ProblemSetBadge) =>
badge.conditionType === "all_problems" ? Number.MAX_SAFE_INTEGER : badge.conditionValue
return [...set.badges].sort((a, b) => need(a) - need(b) || a.id - b.id)
}
/** 下一枚最近的奖章和还差几道;全拿到了(或者没有奖章)为 null */
export function nextBadge(set: ProblemSet) {
const pending = ladder(set).filter((badge) => !badge.isEarned)
if (pending.length === 0) return null
const scored = pending.map((badge) => ({ badge, left: badgeShortfall(badge, set) }))
scored.sort((a, b) => a.left - b.left)
return scored[0]!
}
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
import { badgeCondition, badgeShortfall, ladder, nextBadge } from "../badges"
/**
* 奖章阶梯(设计稿「题单重设计」详情 B):一排奖章连成一条线,拿到的亮、写哪天拿的;
* 下一个要拿的套金圈、写「再做对 n 道」;更远的灰着、写条件。
*/
const props = defineProps<{ set: ProblemSet }>()
const theme = useThemeVars()
const tone = useTone()
const steps = computed(() => {
const next = nextBadge(props.set)?.badge.id
return ladder(props.set).map((badge) => ({
badge,
next: badge.id === next,
sub: badge.isEarned
? badge.earnedTime
? parseTime(badge.earnedTime, "M月D日")
: "拿到了"
: badge.id === next && props.set.userProgress.isJoined
? `再做对 ${badgeShortfall(badge, props.set)} 道`
: badgeCondition(badge),
}))
})
/** 连线亮到最后一枚拿到的奖章那里 */
const filled = computed(() => {
const last = steps.value.map((step) => step.badge.isEarned).lastIndexOf(true)
return steps.value.length > 1 && last > 0 ? last / (steps.value.length - 1) : 0
})
const warning = computed(() => tone("warning"))
</script>
<template>
<div class="ladder" :style="{ width: `${steps.length * 92}px` }">
<div v-if="steps.length > 1" class="track">
<div class="track-fill" :style="{ width: `${filled * 100}%` }"></div>
</div>
<div
v-for="step in steps"
:key="step.badge.id"
class="step"
:title="`${step.badge.name} · ${badgeCondition(step.badge)}${step.badge.description ? `\n${step.badge.description}` : ''}`"
>
<span class="medal" :class="{ next: step.next }">
<img
:src="step.badge.icon"
:alt="step.badge.name"
:class="{ locked: !step.badge.isEarned }"
/>
</span>
<b class="name" :class="{ dim: !step.badge.isEarned }">{{ step.badge.name }}</b>
<span class="sub" :class="{ urge: step.next && !step.badge.isEarned }">{{ step.sub }}</span>
</div>
</div>
</template>
<style scoped>
.ladder {
position: relative;
display: flex;
flex-shrink: 0;
}
.track {
position: absolute;
left: 46px;
right: 46px;
top: 21px;
height: 3px;
background: v-bind("theme.dividerColor");
}
.track-fill {
height: 100%;
background: v-bind("theme.successColor");
}
.step {
position: relative;
width: 92px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.medal {
width: 44px;
height: 44px;
border-radius: 50%;
background: v-bind("theme.cardColor");
display: flex;
align-items: center;
justify-content: center;
}
.medal.next {
box-shadow: 0 0 0 2px #f0b44c;
}
.medal img {
width: 36px;
height: 36px;
}
.locked {
filter: grayscale(1);
opacity: 0.35;
}
.name {
max-width: 88px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name.dim {
color: v-bind("theme.textColor3");
font-weight: 400;
}
.sub {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.sub.urge {
color: v-bind("warning.color");
font-weight: 600;
}
</style>
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getProblemSetClassView } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSetClassView } from "utils/types"
/**
* 老师看一个班在题单里的情况(设计稿「题单重设计」老师 · 全班情况):真名 × 每道题,
* 格子里写几点做对 / 错了几次;紫色角 = 加入之前就做对过(可能是凭记忆默写的)。
* 没加入的人在上面点名。班级默认是服务端推断的(最近在做的那个班),可以换。
* 点格子到提交列表看这个学生这道题的代码。
*/
const props = defineProps<{ problemSetId: number }>()
const theme = useThemeVars()
const tone = useTone()
const data = ref<ProblemSetClassView | null>(null)
const className = ref("")
const loading = ref(false)
async function load() {
loading.value = true
try {
data.value = await getProblemSetClassView(props.problemSetId, className.value)
className.value = data.value.className ?? ""
} finally {
loading.value = false
}
}
onMounted(load)
watch(className, (value, old) => {
if (old && value !== data.value?.className) load()
})
function classLabel(name: string) {
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name
}
const classOptions = computed(() =>
(data.value?.classes ?? []).map((item) => ({
value: item.className,
label: `${classLabel(item.className)}(${item.joined}/${item.size} 人加入)`,
})),
)
const joined = computed(() => data.value?.students.filter((s) => s.joinTime) ?? [])
const absent = computed(() => data.value?.students.filter((s) => !s.joinTime) ?? [])
const average = computed(() =>
joined.value.length
? joined.value.reduce((sum, s) => sum + s.completedCount, 0) / joined.value.length
: 0,
)
const finished = computed(
() => joined.value.filter((s) => data.value && s.completedCount >= data.value.totalCount).length,
)
const beforeCount = computed(() =>
joined.value.reduce((sum, s) => sum + s.cells.filter((cell) => cell.solvedBefore).length, 0),
)
const columns = computed(
() => `22px 72px 44px repeat(${data.value?.problems.length ?? 1}, minmax(72px, 1fr))`,
)
function name(student: { realName: string | null; username: string }) {
return student.realName || student.username
}
function cellLink(username: string, problemId: string) {
return `/submission?username=${encodeURIComponent(username)}&problem=${encodeURIComponent(problemId)}`
}
function cellTime(time: string) {
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
return parseTime(time, today ? "HH:mm" : "M/D")
}
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
const error = computed(() => tone("error"))
</script>
<template>
<section class="card">
<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"
/>
<span
><b class="num big">{{ joined.length }}</b
><span class="muted"> / {{ data.students.length }} 人加入</span></span
>
<span
><span class="muted">平均做对</span> <b class="num big">{{ average.toFixed(1) }}</b
><span class="muted"> / {{ data.totalCount }} 道</span></span
>
<span
><span class="muted">做完</span> <b class="num big">{{ finished }}</b
><span class="muted"> 人</span></span
>
<span v-if="absent.length" class="absent ell" :title="absent.map(name).join('、')"
>没加入:{{ absent.map(name).join("、") }}</span
>
<div class="spacer"></div>
<span v-if="beforeCount" class="legend-before"
><span class="corner"></span>加入前就做对过({{ beforeCount }} 格)</span
>
</div>
<div class="scroll">
<div class="grid" :style="{ gridTemplateColumns: columns }">
<div class="head"></div>
<div class="head">姓名</div>
<div class="head right">做对</div>
<div v-for="(p, i) in data.problems" :key="p.id" class="head col" :title="p.title">
<b class="ell">{{ i + 1 }} {{ p.title }}</b>
<span class="num"
>{{ p.solved }} 人对<template v-if="!p.isRequired"> · 选做</template></span
>
</div>
<template v-for="(s, idx) in joined" :key="s.userId">
<div class="cell muted num tiny">{{ idx + 1 }}</div>
<div class="cell ell" :title="s.username">
<b>{{ name(s) }}</b>
</div>
<div class="cell right num">
<b>{{ s.completedCount }}</b>
</div>
<a
v-for="(c, i) in s.cells"
:key="i"
:href="cellLink(s.username, data.problems[i]!._id)"
target="_blank"
class="box"
:class="{ ok: c.solvedTime, bad: !c.solvedTime && c.wrongCount }"
:title="`${name(s)} · ${data.problems[i]!.title}${c.solvedBefore ? ' · 加入前就做对过' : ''}`"
>
<template v-if="c.solvedTime"
>✓ <span class="num">{{ cellTime(c.solvedTime) }}</span></template
>
<template v-else-if="c.wrongCount">错 {{ c.wrongCount }} 次</template>
<span v-if="c.solvedBefore" class="corner"></span>
</a>
</template>
</div>
</div>
<div class="foot muted">
<span>绿 = 在题单里做对(几点做对的)</span><span>黄 = 交过还没对</span
><span>紫色角 = 加入前就做对过</span><span>点格子看这个学生这道题的提交</span>
<div class="spacer"></div>
<span>按学号排</span>
</div>
</template>
<div v-else class="empty"><n-spin size="small" /></div>
</section>
</template>
<style scoped>
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
overflow: hidden;
}
.empty {
padding: 40px;
text-align: center;
}
.summary {
min-height: 52px;
box-sizing: border-box;
padding: 8px 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 20px;
font-size: 13px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.pick {
width: 240px;
}
.big {
font-size: 18px;
}
.absent {
max-width: 420px;
color: v-bind("error.color");
}
.legend-before {
display: inline-flex;
align-items: center;
gap: 6px;
color: #5b3fa8;
}
.legend-before .corner {
position: static;
}
.scroll {
overflow-x: auto;
}
.grid {
display: grid;
column-gap: 6px;
padding: 0 16px 8px;
}
.head {
position: sticky;
top: 0;
height: 44px;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.cardColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
margin-bottom: 4px;
}
.head.col {
align-items: center;
line-height: 16px;
min-width: 0;
overflow: hidden;
}
.head.col b {
max-width: 100%;
color: v-bind("theme.textColor2");
}
.right {
text-align: right;
align-items: flex-end;
padding-right: 8px;
}
.cell {
height: 32px;
display: flex;
align-items: center;
font-size: 14px;
min-width: 0;
}
.cell.right {
justify-content: flex-end;
}
.box {
position: relative;
height: 26px;
margin: 3px 0;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 12px;
text-decoration: none;
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor3");
}
.box.ok {
background: v-bind("success.background");
color: v-bind("success.color");
}
.box.bad {
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.box:hover {
outline: 1px solid v-bind("theme.primaryColor");
}
.corner {
position: absolute;
top: 2px;
right: 3px;
width: 0;
height: 0;
border-top: 8px solid #7a5fd0;
border-left: 8px solid transparent;
}
.foot {
min-height: 30px;
box-sizing: border-box;
padding: 6px 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 12px;
border-top: 1px solid v-bind("theme.dividerColor");
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.tiny {
font-size: 12px;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getProblemSetLocks } from "oj/api"
import { parseTime } from "utils/functions"
import type { ProblemSetLock } from "utils/types"
/**
* 「旧代码为什么藏着」那一句(设计稿「题单重设计」旧代码藏着时学生看到的):
* 题单《X》布置中,这道题以前的代码先藏着 · 在题单里做对,或 M月D日 之后就能看。
* 规则在后端 problemSetLockCutoffs;查不到是哪个题单(比如刚过期)就说通用的一句
*/
const props = defineProps<{ problem: { id: number } | { displayId: string } }>()
const theme = useThemeVars()
const locks = ref<ProblemSetLock[]>([])
watch(
() => ("id" in props.problem ? props.problem.id : props.problem.displayId),
async () => {
try {
locks.value = await getProblemSetLocks(props.problem)
} catch {
locks.value = []
}
},
{ immediate: true },
)
const first = computed(() => locks.value[0])
</script>
<template>
<span v-if="first">
题单<router-link :to="{ name: 'problemset', params: { problemSetId: first.problemSetId } }"
>《{{ first.title }}》</router-link
>布置中,这道题以前的代码先藏着 · 在题单里做对,或
{{ parseTime(first.assignedUntil, "M月D日") }} 之后就能看
</span>
<span v-else>这道题正在题单里布置,以前的代码先藏着 · 在题单里做对,或者布置期结束就能看</span>
</template>
<style scoped>
a {
color: v-bind("theme.primaryColor");
text-decoration: none;
}
</style>
@@ -1,123 +0,0 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
import { parseTime } from "utils/functions"
import UserBadge from "shared/components/UserBadge.vue"
import { useUserStore } from "shared/store/user"
interface Props {
problemSet: ProblemSet
isJoined: boolean
isJoining: boolean
userBadges: UserBadgeType[]
}
interface Emits {
(e: "join"): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const userStore = useUserStore()
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
// 进度一律读 userProgress:它的分母是**必做题数**,而 problemsCount 是总题数。
// 拿总题数当分母的话,做完全部必做题的人会看到「9 / 10、90%」,而同一张卡片上
// 又标着「已完成」—— 题单 6 那 10 个人正是这种。
function getProgressPercentage() {
return Math.round(props.problemSet?.userProgress?.progressPercentage ?? 0)
}
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
const optionalCount = computed(
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
)
const endTimeText = computed(() =>
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "",
)
function handleJoin() {
emit("join")
}
</script>
<template>
<n-card style="margin-bottom: 24px">
<n-flex justify="space-between" align="center">
<n-flex align="center">
<n-tag type="warning" v-if="problemSet.status === 'archived'"> 已归档 </n-tag>
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<!-- 截止时间不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
所以这里要连着解释一句,否则学生只看到一个日期,不知道它管什么 -->
<n-tooltip trigger="hover" v-if="endTimeText">
<template #trigger>
<n-tag type="info">截止 {{ endTimeText }}</n-tag>
</template>
这个时间之前,你在加入题单之前提交过的代码是看不到的;在题单里做出该题即可解锁。
</n-tooltip>
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
<n-tooltip trigger="hover" v-if="problemSet.description">
<template #trigger>
<Icon width="20" icon="fluent-emoji:information" />
</template>
{{ problemSet.description }}
</n-tooltip>
</n-flex>
<n-flex align="center" v-if="userStore.isAuthed">
<!-- 用户徽章显示区域 - 只在已加入且有徽章时显示 -->
<n-flex v-if="isJoined && userBadges.length > 0" align="center">
<n-text>已获徽章</n-text>
<UserBadge v-for="badge in userBadges" :key="badge.id" :badge="badge" />
</n-flex>
<!-- 完成进度 - 只在已加入时显示 -->
<n-flex align="center" v-if="isJoined">
<n-text strong>完成进度</n-text>
<n-text>
{{ problemSet.userProgress?.completedCount ?? 0 }} /
{{ problemSet.userProgress?.totalCount ?? 0 }}
</n-text>
<n-text depth="3" v-if="optionalCount > 0"> (另有 {{ optionalCount }} 道选做) </n-text>
</n-flex>
<n-progress
v-if="isJoined"
:percentage="getProgressPercentage()"
:height="8"
:border-radius="4"
style="width: 200px"
/>
<n-button
v-if="!isJoined"
type="primary"
size="large"
:loading="isJoining"
@click="handleJoin"
>
加入题单
</n-button>
<n-tag v-else type="success" size="large">
<template #icon>
<Icon icon="ph:check-circle-fill" />
</template>
已加入
</n-tag>
</n-flex>
</n-flex>
</n-card>
</template>
@@ -1,79 +0,0 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProblemSetProblem } from "utils/types"
import { DIFFICULTY } from "utils/constants"
import { getTagColor } from "utils/functions"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
problems: ProblemSetProblem[]
isJoined: boolean
}
interface Emits {
(e: "problem-click", problemId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { isDesktop } = useBreakpoints()
function handleProblemClick(problemId: string) {
emit("problem-click", problemId)
}
</script>
<template>
<div>
<n-grid :cols="isDesktop ? 4 : 1" :x-gap="16" :y-gap="16">
<n-grid-item v-for="(problemSetProblem, index) in problems" :key="problemSetProblem.id">
<n-card
hoverable
@click="handleProblemClick(problemSetProblem.problem._id)"
style="cursor: pointer"
>
<n-flex align="center">
<Icon
style="margin-right: 10px"
width="48"
icon="fluent-emoji:check-mark-button"
v-if="problemSetProblem.isCompleted"
/>
<n-flex vertical style="flex: 1">
<n-flex align="center">
<n-h4 style="margin: 0">#{{ index + 1 }}</n-h4>
<n-h4 style="margin: 0">
{{ problemSetProblem.problem.title }}
</n-h4>
</n-flex>
<n-flex align="center" size="small">
<n-tag
v-if="problemSetProblem.problem.difficulty"
:type="getTagColor(problemSetProblem.problem.difficulty)"
size="small"
>
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
</n-tag>
<n-text type="info">分数:{{ problemSetProblem.score }}</n-text>
<n-text v-if="!problemSetProblem.isRequired">(选做)</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
<div class="tip">
<n-text depth="3">题目完成后会自动返回题单页面</n-text>
</div>
</div>
</template>
<style scoped>
.tip {
padding-top: 24px;
text-align: center;
}
</style>
@@ -1,267 +0,0 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { h, computed, ref, onMounted, watch } from "vue"
import { watchDebounced } from "@vueuse/core"
import { parseTime } from "utils/functions"
import type { ProblemSetProgress } from "utils/types"
import { getProblemSetUserProgress } from "../../api"
import { NFlex, NTag } from "naive-ui"
import { usePagination } from "shared/composables/pagination"
import Pagination from "shared/components/Pagination.vue"
const route = useRoute()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const progress = ref<ProblemSetProgress[]>([])
const loading = ref(false)
const total = ref(0)
const statistics = ref<{
total: number
completed: number
avgProgress: number
} | null>(null)
const classFilter = ref<string>("")
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">("")
const allProblems = ref<Array<{ id: number; _id: string; title: string }>>([])
// 完成度筛选选项
const completionOptions = [
{ label: "全部", value: "" },
{ label: "未开始", value: "not_started" },
{ label: "进行中", value: "in_progress" },
{ label: "已完成", value: "completed" },
]
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 50 })
// 加载用户进度数据
async function loadUserProgress() {
loading.value = true
const offset = (query.page - 1) * query.limit
const params: {
limit?: number
offset?: number
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
} = {
limit: query.limit,
offset,
}
if (classFilter.value.trim()) {
params.className = classFilter.value.trim()
}
if (completionFilter.value) {
params.completionStatus = completionFilter.value
}
try {
const res = await getProblemSetUserProgress(problemSetId.value, params)
progress.value = res.results
total.value = res.total
// 使用后端返回的统计数据(基于所有数据)
if (res.statistics) {
statistics.value = res.statistics
}
// 保存所有题目信息
if (res.problems) {
allProblems.value = res.problems
}
} catch (err) {
// finally 里收掉 loading:以前 loading.value = false 写在 await 之后,
// 请求一失败(403、断网)转圈就永远停不下来
message.error("加载用户进度失败:" + errorMessage(err, "未知错误"))
} finally {
loading.value = false
}
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], loadUserProgress)
// 监听班级过滤变化(防抖)
watchDebounced(
classFilter,
() => {
query.page = 1 // 重置到第一页
loadUserProgress()
},
{ debounce: 500 },
)
// 监听完成度筛选变化
watch(completionFilter, () => {
query.page = 1 // 重置到第一页
loadUserProgress()
})
// 使用后端返回的统计数据
const stats = computed(() => {
if (statistics.value) {
return {
total: statistics.value.total,
completed: statistics.value.completed,
avgProgress: Math.round(statistics.value.avgProgress),
}
}
// 如果后端还没有返回统计数据,使用默认值
return {
total: total.value,
completed: 0,
avgProgress: 0,
}
})
onMounted(loadUserProgress)
// 定义表格列
const progressColumns = [
{
title: "排名",
key: "rank",
width: 80,
render: (_row: ProblemSetProgress, index: number) => {
// 计算全局排名:当前页偏移 + 当前行索引 + 1
const globalRank = (query.page - 1) * query.limit + index + 1
return globalRank
},
},
{
title: "用户",
key: "user.username",
width: 120,
render: (row: ProblemSetProgress) => row.user.username,
},
{
title: "加入时间",
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "已完成数量",
key: "completedProblemsCount",
width: 100,
},
{
title: "已/未完成题目",
key: "completed_problems",
width: 300,
render: (row: ProblemSetProgress) => {
if (row.progressPercentage === 100) {
return "全部题目已完成"
}
if (row.progressPercentage > 50 && row.progressPercentage < 100) {
const completedProblemIds = new Set(row.completedProblems.map((p: any) => p.id))
const incompleteProblems = allProblems.value.filter((p) => !completedProblemIds.has(p.id))
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () =>
incompleteProblems.map((problem) =>
h(
NTag,
{ type: "warning", size: "small", style: "margin: 2px" },
() => `${problem._id}: ${problem.title}`,
),
),
),
])
}
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () =>
row.completedProblems.map((problem: any) =>
h(
NTag,
{
type: "success",
size: "small",
style: "margin: 2px",
},
() => `${problem._id}: ${problem.title}`,
),
),
),
])
},
},
{
title: "进度",
key: "progress_percentage",
width: 120,
render: (row: ProblemSetProgress) => {
return `${row.progressPercentage.toFixed(0)}%`
},
},
{
title: "状态",
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => {
if (row.isCompleted) {
return h(NTag, { type: "success" }, () => "已完成")
} else {
return h(NTag, { type: "warning" }, () => "进行中")
}
},
},
]
</script>
<template>
<div>
<!-- 过滤条件 -->
<n-form label-placement="left" inline>
<n-form-item label="班级">
<n-input
v-model:value="classFilter"
placeholder="输入班级名称"
style="width: 200px"
clearable
/>
</n-form-item>
<n-form-item label="完成度:">
<n-select
v-model:value="completionFilter"
:options="completionOptions"
placeholder="完成度"
style="width: 160px"
clearable
/>
</n-form-item>
</n-form>
<!-- 统计信息卡片 -->
<n-grid :cols="3" :x-gap="16" style="margin-bottom: 16px">
<n-grid-item>
<n-card size="small">
<n-statistic label="总参与人数" :value="stats.total" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic label="已完成人数" :value="stats.completed" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic label="平均进度" :value="stats.avgProgress.toFixed(0) + '%'" />
</n-card>
</n-grid-item>
</n-grid>
<n-data-table
:loading="loading"
:columns="progressColumns"
:data="progress"
:pagination="false"
:bordered="false"
:single-line="false"
/>
<Pagination
:total="total"
:limit="query.limit"
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</div>
</template>
+611 -104
View File
@@ -1,130 +1,637 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
import { useTone } from "oj/submission/composables/tone"
import { errorMessage } from "utils/api" import { errorMessage } from "utils/api"
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet, getUserBadges } from "../api" import { parseTime } from "utils/functions"
import type { ProblemSet, ProblemSetProblem, UserBadge as UserBadgeType } from "utils/types" import type { ProblemSet, ProblemSetProblem } from "utils/types"
import { useFireworks } from "../problem/composables/useFireworks" import { useAuthModalStore } from "shared/store/authModal"
import ProblemSetHeader from "./components/ProblemSetHeader.vue"
import ProblemSetProblemsList from "./components/ProblemSetProblemsList.vue"
import UserProgressView from "./components/UserProgressView.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { useFireworks } from "../problem/composables/useFireworks"
import BadgeLadder from "./components/BadgeLadder.vue"
import ClassView from "./components/ClassView.vue"
/**
* 题单详情(设计稿「题单重设计」详情 B):顶上一条是进度和奖章阶梯,下面题目分两列。
*
* - 还没加入:题目看得见、点不进去,右边一个大按钮「加入题单,开始做」。加入是用户要留的
* 一步(「只是打开就算加入的话,会有疑惑的」),所以这里要让人一眼看懂为什么要点。
* - 布置期内以前做对过的题标「以前做对过 · 旧代码先藏着」,规则见后端 problemSetLockCutoffs。
* - 做完了:整条变绿,写几点做完、用了多久。烟花只在做完的那一刻放(做对最后一道、
* 1.5 秒后跳回来的那一下),以后再打开不放。
* - 老师多一个「全班情况」页签。
*/
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const theme = useThemeVars()
const { celebrate } = useFireworks() const tone = useTone()
const userStore = useUserStore() const userStore = useUserStore()
const authStore = useAuthModalStore()
const { celebrate } = useFireworks()
const problemSetId = computed(() => Number(route.params.problemSetId)) const id = computed(() => Number(route.params.problemSetId))
const set = ref<ProblemSet | null>(null)
const problemSet = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([]) const problems = ref<ProblemSetProblem[]>([])
const isJoined = ref(false) const missing = ref(false)
const isJoining = ref(false) const joining = ref(false)
const userBadges = ref<UserBadgeType[]>([]) const tab = useRouteQuery<string>("tab", "problems")
const activeTab = ref("problems")
async function loadProblemSetDetail() { async function load() {
const res = await getProblemSetDetail(problemSetId.value) missing.value = false
problemSet.value = res try {
isJoined.value = res.userProgress?.isJoined || false const [detail, list] = await Promise.all([
} getProblemSetDetail(id.value),
getProblemSetProblems(id.value),
async function loadProblems() { ])
const res = await getProblemSetProblems(problemSetId.value) set.value = detail
problems.value = res problems.value = list
} } catch {
missing.value = true
async function loadUserBadges() { return
if (!isJoined.value) return }
const done = set.value.userProgress.completeTime
const res = await getUserBadges() if (set.value.userProgress.isCompleted && done && Date.now() - Date.parse(done) < 60_000) {
userBadges.value = res.filter(
(badge: UserBadgeType) => badge.badge.problemSetId === problemSetId.value,
)
}
async function init() {
await Promise.all([loadProblemSetDetail(), loadProblems()])
if (isJoined.value) {
if (problemSet.value?.userProgress?.isCompleted) {
celebrate() celebrate()
} }
loadUserBadges()
}
} }
async function handleProblemClick(problemId: string) { watch(id, load)
if (!userStore.isAuthed) { onMounted(load)
message.warning("请先登录!")
return const progress = computed(() => set.value?.userProgress)
} const joined = computed(() => !!progress.value?.isJoined)
if (!isJoined.value) { /** 老师不用加入也能点进题目去看(不记进度),也不给他摆「加入」按钮 */
message.warning("请先点击【加入题单】按钮!") const canOpen = computed(() => joined.value || userStore.isTeacherOrAbove)
return const optional = computed(() => (set.value ? set.value.problemsCount - set.value.requiredCount : 0))
} const percent = computed(() =>
router.push({ set.value && progress.value?.isJoined && set.value.requiredCount
? Math.min(100, (progress.value.completedCount / set.value.requiredCount) * 100)
: 0,
)
const hiddenCount = computed(() => problems.value.filter((item) => item.oldCodeHidden).length)
/** 做完用了多久:从加入到做完 */
const spent = computed(() => {
const p = progress.value
if (!p?.joinTime || !p.completeTime) return ""
const minutes = Math.round((Date.parse(p.completeTime) - Date.parse(p.joinTime)) / 60_000)
if (minutes < 60) return `${Math.max(1, minutes)} 分钟`
if (minutes < 24 * 60) return `${Math.round(minutes / 60)} 小时`
return `${Math.round(minutes / 1440)} 天`
})
const columns = computed(() => {
const half = Math.ceil(problems.value.length / 2)
return [problems.value.slice(0, half), problems.value.slice(half)].filter((col) => col.length)
})
function indexOf(item: ProblemSetProblem) {
return problems.value.indexOf(item) + 1
}
function solvedAt(time: string) {
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
return parseTime(time, today ? "HH:mm" : "M月D日")
}
function problemLink(item: ProblemSetProblem) {
return {
name: "problemset problem", name: "problemset problem",
params: { params: { problemSetId: id.value, problemID: item.problem._id },
problemSetId: problemSetId.value,
problemID: problemId,
},
})
}
async function handleJoinProblemSet() {
if (isJoining.value) return
isJoining.value = true
try {
await joinProblemSet(problemSetId.value)
isJoined.value = true
message.success("成功加入题单!")
// 加入题单后加载用户徽章
await loadUserBadges()
} catch (err) {
message.error("加入题单失败:" + errorMessage(err, "未知错误"))
} finally {
isJoining.value = false
} }
} }
// 「用户进度」那一栏调的是 requireTeacher 的接口(Teacher Admin | Super Admin), async function join() {
// 所以这里的条件必须跟它一致。以前写的是「超管 或 自己完成了题单」,两边正好错开: if (!userStore.isAuthed) {
// 学生做完题单会看到这一栏、点进去 403;而真正该用它的 Teacher Admin 反倒看不到。 authStore.openLoginModal()
const showTabs = computed(() => userStore.isTeacherOrAbove) return
}
if (joining.value) return
joining.value = true
try {
await joinProblemSet(id.value)
message.success("加入了,现在开始在这里做对的题都会记进度")
await load()
} catch (err) {
message.error(errorMessage(err, "加入失败"))
} finally {
joining.value = false
}
}
onMounted(init) /** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
const canEdit = computed(
() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id),
)
const info = computed(() => tone("info"))
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
const purple = { color: "#5b3fa8", background: "rgba(122, 95, 208, 0.12)" }
</script> </script>
<template> <template>
<div v-if="problemSet"> <n-result
<ProblemSetHeader v-if="missing"
:problem-set="problemSet" status="404"
:is-joined="isJoined" title="题单不存在"
:is-joining="isJoining" description="可能还没公开,或者已经删掉了"
:user-badges="userBadges" >
@join="handleJoinProblemSet" <template #footer>
/> <n-button @click="router.push({ name: 'problemsets' })">回到题单列表</n-button>
<n-tabs v-if="showTabs" v-model:value="activeTab" animated> </template>
<n-tab-pane name="problems" tab="题目列表"> </n-result>
<ProblemSetProblemsList
:problems="problems" <div v-else-if="set" class="page">
:is-joined="isJoined" <section class="band" :class="{ done: progress?.isCompleted }">
@problem-click="handleProblemClick" <span v-if="progress?.isCompleted" class="done-mark" aria-hidden="true">✓</span>
/> <div class="head">
</n-tab-pane> <div class="row">
<n-tab-pane name="progress" tab="用户进度"> <router-link :to="{ name: 'problemsets' }" class="muted back">‹ 题单</router-link>
<UserProgressView /> <h2 class="ell">{{ set.title }}</h2>
</n-tab-pane> <span v-if="set.assigning" class="pill assign"
</n-tabs> >布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span
<ProblemSetProblemsList >
v-else </div>
:problems="problems" <template v-if="progress?.isCompleted">
:is-joined="isJoined" <div class="done-text">
@problem-click="handleProblemClick" <b>全部做完了</b> · {{ parseTime(progress.completeTime!, "M月D日 HH:mm") }}
/> <template v-if="spent">,加入后用了 {{ spent }}</template>
</div>
</template>
<template v-else-if="joined">
<div class="row baseline">
<span class="muted small">做对</span>
<span class="num count">{{ progress!.completedCount }}</span>
<span class="muted num">/ {{ set.requiredCount }} 道</span>
<span class="muted tiny gap">
<template v-if="optional > 0">另有 {{ optional }} 道选做 · </template>
{{ parseTime(progress!.joinTime!, "M月D日 HH:mm") }} 加入
</span>
</div>
<div class="bar"><div class="fill" :style="{ width: `${percent}%` }"></div></div>
</template>
<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>
</div>
<div v-if="set.description && set.description !== set.title" class="sec small desc">
{{ set.description }}
</div>
</div>
<template v-if="set.badges.length">
<span class="divider"></span>
<div class="ladder-box"><BadgeLadder :set="set" /></div>
</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>
<span class="muted tiny">加入以后,在题单里做对的题才算进度</span>
</div>
</section>
<div v-if="!joined && hiddenCount" class="notice" :style="purple">
<svg
class="lock"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<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日")
}})这几道的旧代码先藏起来,在题单里重新做对就能看。
</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>
</div>
<ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" />
<template v-else>
<div v-if="problems.length" class="cols">
<section v-for="(col, ci) in columns" :key="ci" class="card list">
<component
:is="canOpen ? 'router-link' : 'div'"
v-for="item in col"
:key="item.id"
:to="canOpen ? problemLink(item) : undefined"
class="prow"
:class="{ off: !canOpen }"
>
<span v-if="item.isCompleted" class="dot done-dot">✓</span>
<span v-else-if="item.wrongCount" class="dot tried-dot">!</span>
<span v-else class="dot empty-dot"></span>
<span class="muted num idx">{{ indexOf(item) }}</span>
<span class="ell ptitle">{{ item.problem.title }}</span>
<span v-if="!item.isRequired" class="opt">选做</span>
<span v-if="item.oldCodeHidden" class="old" :style="purple"
><svg
class="lock"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg
>以前做对过 · 旧代码先藏着</span
>
<div class="spacer"></div>
<span v-if="item.isCompleted && item.solvedTime" class="muted num tiny"
>{{ solvedAt(item.solvedTime) }} 做对</span
>
<span v-else-if="item.wrongCount && joined" class="tiny tried"
>交过 {{ item.wrongCount }} 次</span
>
<span v-else-if="canOpen" class="go">去做 ›</span>
</component>
</section>
</div>
<n-empty v-else description="这个题单还没有题目" />
<div v-if="joined && problems.length" class="muted tiny foot">做对一道会自动回到这里</div>
</template>
</div> </div>
</template> </template>
<style scoped></style> <style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.band {
box-sizing: border-box;
padding: 14px 20px 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px 24px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.band.done {
background: v-bind("success.background");
border-color: transparent;
}
.done-mark {
width: 48px;
height: 48px;
border-radius: 50%;
flex-shrink: 0;
background: v-bind("theme.successColor");
color: #ffffff;
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.done-text {
font-size: 14px;
color: v-bind("success.color");
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
width: 380px;
max-width: 100%;
min-width: 0;
}
.head h2 {
margin: 0;
font-size: 20px;
}
.desc {
line-height: 1.5;
}
.row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.row.baseline {
align-items: baseline;
gap: 6px;
}
.back {
flex-shrink: 0;
font-size: 13px;
text-decoration: none;
}
.count {
font-size: 24px;
font-weight: 700;
}
.gap {
margin-left: 6px;
}
.bar {
height: 8px;
border-radius: 4px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fill {
height: 100%;
background: v-bind("theme.successColor");
}
.divider {
width: 1px;
align-self: stretch;
background: v-bind("theme.dividerColor");
}
.ladder-box {
max-width: 100%;
overflow-x: auto;
}
.join {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.lock {
flex-shrink: 0;
vertical-align: -1px;
}
.old .lock {
margin-right: 3px;
}
.notice {
box-sizing: border-box;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
}
.tabs {
display: flex;
align-items: center;
}
.seg {
height: 30px;
padding: 0 14px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.seg:first-child {
border-radius: 4px 0 0 4px;
}
.seg + .seg {
border-left: none;
border-radius: 0 4px 4px 0;
}
.seg.on {
color: v-bind("success.color");
background: v-bind("success.background");
font-weight: 600;
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 12px;
align-items: start;
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
overflow: hidden;
}
.list {
display: flex;
flex-direction: column;
}
.prow {
height: 40px;
flex-shrink: 0;
box-sizing: border-box;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
color: v-bind("theme.textColor1");
text-decoration: none;
}
.prow:last-child {
border-bottom: none;
}
a.prow:hover {
background: v-bind("theme.hoverColor");
}
.prow.off {
color: v-bind("theme.textColor3");
}
.dot {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.done-dot {
background: v-bind("theme.successColor");
color: #ffffff;
}
.tried-dot {
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.empty-dot {
border: 1.5px dashed v-bind("theme.borderColor");
}
.idx {
width: 20px;
text-align: right;
font-size: 13px;
flex-shrink: 0;
}
.ptitle {
font-size: 14px;
}
.opt {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
border: 1px solid v-bind("theme.borderColor");
color: v-bind("theme.textColor3");
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.old {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.tried {
color: v-bind("warning.color");
}
.go {
font-size: 13px;
color: v-bind("theme.primaryColor");
flex-shrink: 0;
}
.foot {
text-align: center;
}
.pill {
height: 22px;
padding: 0 8px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.assign {
color: v-bind("info.color");
background: v-bind("info.background");
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: 13px;
}
.tiny {
font-size: 12px;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.cols {
grid-template-columns: minmax(0, 1fr);
}
.head {
width: 100%;
}
.divider {
display: none;
}
.join {
align-items: stretch;
width: 100%;
}
}
</style>
+353 -212
View File
@@ -1,263 +1,404 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouteQuery } from "@vueuse/router" import { useThemeVars } from "naive-ui"
import { getProblemSetList } from "../api" import { getProblemSetList } from "oj/api"
import { rgba, useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types" import type { ProblemSet } from "utils/types"
import Pagination from "shared/components/Pagination.vue" import { badgeCondition, ladder, nextBadge } from "./badges"
import { usePagination } from "shared/composables/pagination"
/**
* 题单列表(设计稿「题单重设计」列表 A):老师正在布置的题单单独一排大卡片 —— 做对几道、
* 布置到哪天、再做对几道拿哪个奖章;其余题单一排排小卡片,加入过的在前。
*
* 题单一共十几个,一次全取回来在前端分组,不分页
*/
const router = useRouter() const router = useRouter()
const theme = useThemeVars()
const tone = useTone()
const total = ref(0) const sets = ref<ProblemSet[]>([])
const problemSets = ref<ProblemSet[]>([]) const loaded = ref(false)
const keyword = ref("")
interface ProblemSetQuery { onMounted(async () => {
keyword: string try {
} sets.value = (await getProblemSetList(0, 250)).results
} finally {
// 使用分页 composable loaded.value = true
const { query, clearQuery } = usePagination<ProblemSetQuery>(
{
keyword: useRouteQuery("keyword", "").value,
},
{
defaultLimit: 30,
},
)
async function listProblemSets() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemSetList(offset, query.limit, query.keyword)
total.value = res.total
problemSets.value = res.results
}
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
} }
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function goToProblemSet(problemSetId: number) {
router.push(`/problemset/${problemSetId}`)
}
function getConditionText(conditionType: string, conditionValue: number): string {
const conditionMap: Record<string, string> = {
all_problems: "完成所有题目",
problem_count: `完成 ${conditionValue} 道题目`,
score: `达到 ${conditionValue} 分`,
}
return conditionMap[conditionType] || "未知条件"
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
}) })
// 监听其他查询条件变化 const shown = computed(() => {
watch(() => [query.page, query.limit], listProblemSets) const word = keyword.value.trim().toLowerCase()
if (!word) return sets.value
return sets.value.filter(
(set) => set.title.toLowerCase().includes(word) || set.description.toLowerCase().includes(word),
)
})
/** 布置中的:快到期的在前 */
const assigning = computed(() =>
shown.value
.filter((set) => set.assigning)
.sort((a, b) => Date.parse(a.assignedUntil!) - Date.parse(b.assignedUntil!)),
)
/** 其他:加入过的在前(最近加入的最前),再按新建排 */
const others = computed(() =>
shown.value
.filter((set) => !set.assigning)
.sort((a, b) => {
const ja = a.userProgress.joinTime
const jb = b.userProgress.joinTime
if (ja && jb) return Date.parse(jb) - Date.parse(ja)
if (ja || jb) return ja ? -1 : 1
return Date.parse(b.createTime) - Date.parse(a.createTime)
}),
)
const joinedOthers = computed(() => others.value.filter((set) => set.userProgress.isJoined).length)
function description(set: ProblemSet) {
// 简介大多就是把标题再抄一遍,一样的就不重复显示
return set.description && set.description !== set.title ? set.description : ""
}
function optional(set: ProblemSet) {
return set.problemsCount - set.requiredCount
}
function percent(set: ProblemSet) {
const progress = set.userProgress
return progress.isJoined && set.requiredCount
? Math.min(100, (progress.completedCount / set.requiredCount) * 100)
: 0
}
function open(set: ProblemSet) {
router.push({ name: "problemset", params: { problemSetId: set.id } })
}
const info = computed(() => tone("info"))
const success = computed(() => tone("success"))
// 做完的卡片只淡淡地染一点绿(设计稿),用 success.background 那一档太抢眼
const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
</script> </script>
<template> <template>
<n-flex vertical size="large"> <div class="page">
<!-- 难度和状态两个筛选器撤了:线上 16 个题单全是 Easy / active,选「中等」「困难」 <div class="top">
「已归档」永远是空列表。接口那两个 query 参数还在,哪天真的用起这两个字段, <h2>题单</h2>
把 select 加回来即可。 --> <div class="spacer"></div>
<n-space> <n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
<n-input
v-model:value="query.keyword"
placeholder="搜索题单..."
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-space>
<div v-if="problemSets.length > 0" class="set-grid">
<div
v-for="problemSet in problemSets"
:key="problemSet.id"
class="set-card"
:class="{ completed: problemSet.userProgress?.isCompleted }"
@click="goToProblemSet(problemSet.id)"
>
<n-flex justify="space-between" align="center" :wrap="false" :size="8">
<span class="set-title">{{ problemSet.title }}</span>
<n-tag v-if="problemSet.userProgress?.isCompleted" type="success" size="small" round>
已完成
</n-tag>
<n-tag v-else-if="problemSet.userProgress?.isJoined" type="info" size="small" round>
进行中
</n-tag>
</n-flex>
<!-- 简介大多就是把标题再抄一遍,一样的就不重复显示 -->
<n-text
v-if="problemSet.description && problemSet.description !== problemSet.title"
depth="3"
class="set-desc"
>
{{ problemSet.description }}
</n-text>
<div class="set-progress">
<n-flex justify="space-between" align="center" class="set-progress-text">
<n-text depth="3">
<!-- 进度是加入/完成时存下的快照,分母用快照自己的 totalCount:题单后来
加了题的话,拿现在的 problemsCount 去除,就会出现「已完成」却是 5 / 6 -->
<template v-if="problemSet.userProgress?.isJoined">
已完成 {{ problemSet.userProgress.completedCount }} /
{{ problemSet.userProgress.totalCount }} 题
<template v-if="problemSet.problemsCount > problemSet.userProgress.totalCount">
· 新加了
{{ problemSet.problemsCount - problemSet.userProgress.totalCount }} 题
</template>
</template>
<template v-else>共 {{ problemSet.problemsCount }} 题 · 还没开始</template>
</n-text>
<!-- 线上题单全是简单难度,只有不是简单时才值得标出来 -->
<n-tag
v-if="problemSet.difficulty !== 'Easy'"
:type="getDifficultyTag(problemSet.difficulty).type"
size="small"
:bordered="false"
>
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<n-tag v-if="problemSet.status === 'archived'" size="small" :bordered="false">
已归档
</n-tag>
</n-flex>
<n-progress
type="line"
:percentage="Math.round(problemSet.userProgress?.progressPercentage ?? 0)"
:show-indicator="false"
:height="6"
status="success"
/>
</div> </div>
<n-flex v-if="problemSet.badges?.length" align="center" :size="6" class="set-badges"> <template v-if="assigning.length">
<n-text depth="3" class="set-badges-label"> <div class="sect">
徽章 {{ problemSet.badges.filter((b) => b.isEarned).length }} / 布置中<span class="muted">老师现在布置的 · {{ assigning.length }} 个</span>
{{ problemSet.badges.length }} </div>
</n-text> <div class="big-grid">
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover"> <a
<template #trigger> v-for="set in assigning"
<n-image :key="set.id"
:href="`/problemset/${set.id}`"
class="card big"
:class="{ done: set.userProgress.isCompleted }"
@click.prevent="open(set)"
>
<div class="row">
<b class="ell title">{{ set.title }}</b>
<span v-if="set.userProgress.isCompleted" class="pill done-pill">✓ 做完了</span>
</div>
<div class="muted ell desc">{{ description(set) }}</div>
<div class="row baseline">
<template v-if="set.userProgress.isJoined">
<span class="num count">{{ set.userProgress.completedCount }}</span>
<span class="muted num">/ {{ set.requiredCount }}</span>
</template>
<span v-else class="muted small">还没加入 · {{ set.requiredCount }} 道</span>
<span v-if="optional(set) > 0" class="muted tiny">另有 {{ optional(set) }} 道选做</span>
<div class="spacer"></div>
<span class="pill assign">布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span>
</div>
<div class="bar"><div class="fill" :style="{ width: `${percent(set)}%` }"></div></div>
<div v-if="nextBadge(set)" class="row hint">
<img :src="nextBadge(set)!.badge.icon" alt="" class="icon locked" />
<span>
再做对 <b class="num">{{ nextBadge(set)!.left }}</b> 道拿「{{
nextBadge(set)!.badge.name
}}」
</span>
</div>
<div v-else-if="set.badges.length" class="row hint got">
<img
v-for="badge in ladder(set)"
:key="badge.id"
:src="badge.icon" :src="badge.icon"
:alt="badge.name" :alt="badge.name"
width="24" :title="badge.name"
height="24" class="icon"
object-fit="cover"
preview-disabled
:class="badge.isEarned ? 'earned-badge' : 'locked-badge'"
/> />
</template> <span>奖章全拿到了</span>
<n-flex vertical size="small">
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span>
<span>
获取条件:
{{ getConditionText(badge.conditionType, badge.conditionValue) }}
</span>
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text>
</n-flex>
</n-tooltip>
</n-flex>
</div> </div>
<div v-else class="muted hint">这个题单没有奖章</div>
</a>
</div>
</template>
<div v-if="others.length" class="sect">
{{ assigning.length ? "其他题单" : "题单" }}
<span class="muted"
>{{ others.length }} 个<template v-if="joinedOthers"> · 加入过的在前</template></span
>
</div>
<div class="small-grid">
<a
v-for="set in others"
:key="set.id"
:href="`/problemset/${set.id}`"
class="card small-card"
:class="{ done: set.userProgress.isCompleted }"
@click.prevent="open(set)"
>
<div class="row">
<b class="ell">{{ set.title }}</b>
<span v-if="set.userProgress.isCompleted" class="green tiny">✓</span>
</div>
<div class="row tiny">
<span v-if="set.userProgress.isJoined" class="num">
做对 <b>{{ set.userProgress.completedCount }}</b> / {{ set.requiredCount }}
</span>
<span v-else class="muted num"
>{{ set.problemsCount }} 道 · {{ set.joinedCount }} 人做过</span
>
<div class="spacer"></div>
<img
v-for="badge in ladder(set)"
:key="badge.id"
:src="badge.icon"
:alt="badge.name"
:title="`${badge.name} · ${badgeCondition(badge)}${badge.isEarned ? ' · 已拿到' : ''}`"
class="mini"
:class="{ locked: !badge.isEarned }"
/>
</div>
</a>
</div> </div>
<Pagination <n-empty
v-if="problemSets.length > 0" v-if="loaded && !shown.length"
:total="total" :description="keyword ? '没有找到这样的题单' : '还没有题单'"
v-model:limit="query.limit"
v-model:page="query.page"
/> />
</n-flex> </div>
<n-empty v-if="problemSets.length === 0"></n-empty>
</template> </template>
<style scoped> <style scoped>
.set-grid { .page {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.set-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px;
}
.top {
display: flex;
align-items: center;
gap: 12px;
}
.top h2 {
margin: 0;
font-size: 20px;
}
.search {
width: 220px;
}
.spacer {
flex-grow: 1;
}
.sect {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: v-bind("theme.textColor2");
}
.sect .muted {
font-weight: 400;
}
.muted {
color: v-bind("theme.textColor3");
}
.green {
color: v-bind("success.color");
}
.small {
font-size: 13px;
}
.tiny {
font-size: 12px;
}
.num {
font-variant-numeric: tabular-nums;
}
.ell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.row.baseline {
align-items: baseline;
}
.big-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.small-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px; gap: 10px;
padding: 16px 18px; }
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.2); .card {
cursor: pointer; box-sizing: border-box;
min-width: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
text-decoration: none;
transition: transition:
border-color 0.2s, border-color 0.2s,
box-shadow 0.2s; box-shadow 0.2s;
} }
.set-card:hover { .card:hover {
border-color: #18a058; border-color: v-bind("theme.primaryColor");
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
} }
.set-card.completed { .card.done {
background-color: rgba(24, 160, 88, 0.05); background: v-bind("doneBackground");
} }
.set-title { .big {
font-size: 16px; padding: 14px 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.set-desc {
font-size: 13px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.set-progress {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
margin-top: auto;
} }
.set-progress-text { .title {
font-size: 13px; flex-grow: 1;
font-size: 16px;
} }
.set-badges-label { .desc {
height: 17px;
font-size: 12px; font-size: 12px;
margin-right: 2px;
} }
.earned-badge { .count {
border: 2px solid #ffd700; font-size: 22px;
border-radius: 50%; font-weight: 700;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
} }
/* 没拿到的徽章压成灰色:原来拿没拿到只差一圈金边,一排看下去分不清 */ .pill {
.locked-badge { height: 22px;
padding: 0 8px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
.assign {
color: v-bind("info.color");
background: v-bind("info.background");
}
.done-pill {
color: v-bind("success.color");
background: v-bind("success.background");
font-weight: 600;
}
.bar {
height: 6px;
border-radius: 3px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fill {
height: 100%;
background: v-bind("theme.successColor");
}
.hint {
height: 20px;
font-size: 12px;
color: v-bind("theme.textColor2");
}
.hint.got {
gap: 4px;
color: v-bind("success.color");
}
.hint.got span {
margin-left: 4px;
}
.icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.mini {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.locked {
filter: grayscale(1); filter: grayscale(1);
opacity: 0.45; opacity: 0.4;
}
.small-card {
height: 74px;
padding: 10px 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.small-card b {
font-size: 14px;
} }
</style> </style>
+1 -1
View File
@@ -62,7 +62,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
* 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)
@@ -202,7 +202,9 @@ const canRetry = computed(
<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">
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import LockNote from "oj/problemset/components/LockNote.vue"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api" import { getSubmission } from "oj/api"
@@ -388,8 +389,8 @@ function openStandalone() {
</n-button> </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">加入题单之前交的代码先藏起来,在题单里做完这道题就能看到。</span> <span class="muted"><LockNote :problem="{ displayId: row.problemDisplayId }" /></span>
</template> </template>
<template v-else> <template v-else>
<span class="locked-title">登录之后才能看代码</span> <span class="locked-title">登录之后才能看代码</span>
@@ -3,7 +3,7 @@ import { useDark } from "@vueuse/core"
export type Tone = "success" | "error" | "warning" | "info" | "default" export type Tone = "success" | "error" | "warning" | "info" | "default"
function rgba(hex: string, alpha: number) { export function rgba(hex: string, alpha: number) {
const value = hex.replace("#", "") const value = hex.replace("#", "")
const full = const full =
value.length === 3 value.length === 3
+5 -1
View File
@@ -968,7 +968,11 @@ 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>
+5 -3
View File
@@ -337,12 +337,14 @@ export const admins: RouteRecordRaw = {
props: true, props: true,
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
// 原来的「详情」页(题目 / 奖章 / 进度三个页签)并进了一屏编辑,老链接转过去
{ {
path: "problemset/:problemSetId", path: "problemset/:problemSetId",
name: "admin problemset detail", name: "admin problemset detail",
component: () => import("admin/problemset/detail.vue"), redirect: (to) => ({
props: true, name: "admin problemset edit",
meta: { requiresTeacherAdmin: true }, params: { problemSetId: to.params.problemSetId },
}),
}, },
{ {
path: "ai/reports", path: "ai/reports",
@@ -1,87 +0,0 @@
<template>
<n-popover trigger="hover" placement="top">
<template #trigger>
<div class="badge-container">
<img
:src="badge.badge.icon"
:alt="badge.badge.name"
class="badge-icon"
@error="handleImageError"
/>
</div>
</template>
<n-card size="small" class="badge-popover">
<n-flex vertical>
<n-text strong>{{ badge.badge.name }}</n-text>
<n-tag type="info"> 获得条件:{{ getConditionText() }} </n-tag>
<n-text depth="3">
获得时间:{{ parseTime(badge.earnedTime, "YYYY-MM-DD HH:mm:ss") }}
</n-text>
</n-flex>
</n-card>
</n-popover>
</template>
<script setup lang="ts">
import type { UserBadge } from "utils/types"
import { parseTime } from "utils/functions"
interface Props {
badge: UserBadge
}
const props = defineProps<Props>()
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
// 如果图片加载失败,显示默认图标
img.src = "/badge-1.png" // 使用默认徽章图标
}
function getConditionText() {
const { conditionType, conditionValue } = props.badge.badge
switch (conditionType) {
case "all_problems":
return "完成所有题目"
case "problem_count":
return `完成 ${conditionValue} 道题目`
case "score":
return `获得 ${conditionValue} 分`
default:
return "未知条件"
}
}
</script>
<style scoped>
.badge-container {
position: relative;
display: inline-block;
cursor: pointer;
width: 44px;
height: 44px;
}
.badge-icon {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e0e0e0;
transition: all 0.3s ease;
}
.badge-icon:hover {
transform: scale(1.1);
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
.badge-popover {
max-width: 280px;
}
.badge-popover .n-space {
gap: 6px;
}
</style>
+4 -24
View File
@@ -195,31 +195,12 @@ export type {
ProblemSetList, ProblemSetList,
ProblemSetBadge, ProblemSetBadge,
ProblemSetProblem, ProblemSetProblem,
ProblemSetProgress, ProblemSetClassView,
ProblemSetProgressList, ProblemSetClassStudent,
ProblemSetLock,
UserBadge, UserBadge,
} from "@oj2/contract" } from "@oj2/contract"
export type { CompletedProblem } from "@oj2/contract"
export interface CreateProblemSetData {
title: string
description: string
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
endTime?: Date | null
}
export interface EditProblemSetData {
id: number
title?: string
description?: string
difficulty?: "Easy" | "Medium" | "Hard"
status?: "active" | "archived" | "draft"
endTime?: Date | null
visible?: boolean
}
export interface Code { export interface Code {
language: LANGUAGE language: LANGUAGE
value: string value: string
@@ -339,12 +320,11 @@ export type {
AdminUser, AdminUser,
AdminUserList, AdminUserList,
AdminContestList, AdminContestList,
AdminProblemSetProgress,
AdminProblemSetProblem, AdminProblemSetProblem,
AdminProblemSet, AdminProblemSet,
AdminProblemSetList, AdminProblemSetList,
AdminProblemSetBadge, AdminProblemSetBadge,
AddProblemToSetRequest, AddProblemsToSetResult,
UpdateProblemInSetRequest, UpdateProblemInSetRequest,
CreateProblemSetBadgeRequest, CreateProblemSetBadgeRequest,
AdminAiReport, AdminAiReport,
+39 -43
View File
@@ -6,6 +6,7 @@ import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account" import { rankProfileSchema } from "./account"
import { paginatedSchema, sampleUserSchema } from "./common" import { paginatedSchema, sampleUserSchema } from "./common"
import { reactionKeySchema } from "./content" import { reactionKeySchema } from "./content"
import { badgeConditionTypeSchema } from "./problemset"
import { problemLanguageSchema, runnableLanguageSchema } from "./language" import { problemLanguageSchema, runnableLanguageSchema } from "./language"
import { import {
astRuleSchema, astRuleSchema,
@@ -358,17 +359,22 @@ export const updateAcmHelperRequestSchema = z.object({
// ---------------------------------------------------------------- 题单管理 // ---------------------------------------------------------------- 题单管理
// 难度、状态两列还留在表里(0026 没删列,删列要手工放行破坏性迁移),
// 但界面和接口都不再用:线上 16 个题单全是 Easy / active,「状态 + 可见」两道闸合成了「公开」
export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"]) export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"])
export const problemSetStatusSchema = z.enum(["draft", "active", "archived"]) export const problemSetStatusSchema = z.enum(["draft", "active", "archived"])
export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count", "score"])
export const adminProblemSetSchema = z.object({ export const adminProblemSetSchema = z.object({
id: z.number().int(), id: z.number().int(),
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
difficulty: problemSetDifficultySchema, /** 布置到哪天(东八区当天结束);null = 没布置 */
status: problemSetStatusSchema, assignedUntil: z.string().nullable(),
endTime: z.string().nullable(), /** 这一轮布置开始的时刻 */
assignedAt: z.string().nullable(),
/** 现在在不在布置期内(服务端的钟) */
assigning: z.boolean(),
/** 公开:学生能在题单列表里看到、能进 */
visible: z.boolean(), visible: z.boolean(),
createdBy: sampleUserSchema, createdBy: sampleUserSchema,
createTime: z.string(), createTime: z.string(),
@@ -376,23 +382,22 @@ export const adminProblemSetSchema = z.object({
problemsCount: z.number().int(), problemsCount: z.number().int(),
/** 加入这份题单的人数,后台用来判断改动会影响多少人 */ /** 加入这份题单的人数,后台用来判断改动会影响多少人 */
participantCount: z.number().int(), participantCount: z.number().int(),
/** 做完(必做题全对)的人数 */
completedCount: z.number().int(),
badgeCount: z.number().int(),
}) })
export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema) export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema)
export const createProblemSetRequestSchema = z.object({ export const createProblemSetRequestSchema = z.object({
title: z.string().trim().min(1).max(200), title: z.string().trim().min(1).max(200),
description: z.string(), description: z.string().default(""),
difficulty: problemSetDifficultySchema.default("Easy"), /** 布置到哪天;null = 不布置。从「没在布置」变成「布置到将来」时,服务端把布置开始记成现在 */
status: problemSetStatusSchema.default("active"), assignedUntil: z.string().nullable().default(null),
endTime: z.string().nullable().default(null),
visible: z.boolean().default(true), visible: z.boolean().default(true),
}) })
export const updateProblemSetRequestSchema = createProblemSetRequestSchema export const updateProblemSetRequestSchema = createProblemSetRequestSchema
export const updateProblemSetStatusRequestSchema = z.object({
status: problemSetStatusSchema,
})
export const adminProblemSetProblemSchema = z.object({ export const adminProblemSetProblemSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -403,24 +408,28 @@ export const adminProblemSetProblemSchema = z.object({
difficulty: z.string(), difficulty: z.string(),
order: z.number().int(), order: z.number().int(),
isRequired: z.boolean(), isRequired: z.boolean(),
score: z.number().int(),
hint: z.string().nullable(),
}) })
export const addProblemToSetRequestSchema = z.object({ export const addProblemsToSetRequestSchema = z.object({
/** 展示用题号(_id),不是自增主键 —— 老师手里只有题号 */ /** 展示用题号(_id),不是自增主键 —— 老师手里只有题号。按给的顺序接在最后 */
problemId: z.string().trim().min(1), problemIds: z.array(z.string().trim().min(1)).min(1).max(100),
order: z.number().int().default(0), })
isRequired: z.boolean().default(true),
score: z.number().int().default(0), export const addProblemsToSetResultSchema = z.object({
hint: z.string().default(""), added: z.array(z.string()),
/** 题号不存在、不可见或是比赛题 */
missing: z.array(z.string()),
/** 本来就在题单里 */
duplicate: z.array(z.string()),
}) })
export const updateProblemInSetRequestSchema = z.object({ export const updateProblemInSetRequestSchema = z.object({
order: z.number().int().optional(), isRequired: z.boolean(),
isRequired: z.boolean().optional(), })
score: z.number().int().optional(),
hint: z.string().optional(), /** 拖动排序:题单里题目的链接 id,按新顺序给全 */
export const reorderProblemSetProblemsRequestSchema = z.object({
ids: z.array(z.number().int()).min(1),
}) })
export const adminProblemSetBadgeSchema = z.object({ export const adminProblemSetBadgeSchema = z.object({
@@ -438,27 +447,13 @@ export const adminProblemSetBadgeSchema = z.object({
export const createProblemSetBadgeRequestSchema = z.object({ export const createProblemSetBadgeRequestSchema = z.object({
name: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(100),
description: z.string(), description: z.string(),
icon: z.string(), icon: z.string().min(1),
conditionType: badgeConditionTypeSchema, conditionType: badgeConditionTypeSchema,
conditionValue: z.number().int().default(0), conditionValue: z.number().int().default(0),
}) })
export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema
export const adminProblemSetProgressSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
joinTime: z.string(),
completeTime: z.string().nullable(),
isCompleted: z.boolean(),
progressPercentage: z.number(),
completedProblemsCount: z.number().int(),
totalProblemsCount: z.number().int(),
totalScore: z.number().int(),
})
// ---------------------------------------------------------------- 标签与题目分析 // ---------------------------------------------------------------- 标签与题目分析
export const adminTagSchema = z.object({ export const adminTagSchema = z.object({
@@ -927,7 +922,6 @@ export type AstCheckRequest = z.infer<typeof astCheckRequestSchema>
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema> export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
export type GenerateTestInputsRequest = z.infer<typeof generateTestInputsRequestSchema> export type GenerateTestInputsRequest = z.infer<typeof generateTestInputsRequestSchema>
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema> export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
export type AdminProblemSetProgress = z.infer<typeof adminProblemSetProgressSchema>
export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema> export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema>
export type CreateAnnouncementRequest = z.infer<typeof createAnnouncementRequestSchema> export type CreateAnnouncementRequest = z.infer<typeof createAnnouncementRequestSchema>
@@ -959,14 +953,16 @@ export type UpdateContestRequest = z.infer<typeof updateContestRequestSchema>
export type UpdateAcmHelperRequest = z.infer<typeof updateAcmHelperRequestSchema> export type UpdateAcmHelperRequest = z.infer<typeof updateAcmHelperRequestSchema>
export type ProblemSetDifficulty = z.infer<typeof problemSetDifficultySchema> export type ProblemSetDifficulty = z.infer<typeof problemSetDifficultySchema>
export type ProblemSetStatus = z.infer<typeof problemSetStatusSchema> export type ProblemSetStatus = z.infer<typeof problemSetStatusSchema>
export type BadgeConditionType = z.infer<typeof badgeConditionTypeSchema>
export type AdminProblemSet = z.infer<typeof adminProblemSetSchema> export type AdminProblemSet = z.infer<typeof adminProblemSetSchema>
export type AdminProblemSetList = z.infer<typeof adminProblemSetListSchema> export type AdminProblemSetList = z.infer<typeof adminProblemSetListSchema>
export type CreateProblemSetRequest = z.infer<typeof createProblemSetRequestSchema> export type CreateProblemSetRequest = z.infer<typeof createProblemSetRequestSchema>
export type UpdateProblemSetRequest = z.infer<typeof updateProblemSetRequestSchema> export type UpdateProblemSetRequest = z.infer<typeof updateProblemSetRequestSchema>
export type UpdateProblemSetStatusRequest = z.infer<typeof updateProblemSetStatusRequestSchema>
export type AdminProblemSetProblem = z.infer<typeof adminProblemSetProblemSchema> export type AdminProblemSetProblem = z.infer<typeof adminProblemSetProblemSchema>
export type AddProblemToSetRequest = z.infer<typeof addProblemToSetRequestSchema> export type AddProblemsToSetRequest = z.infer<typeof addProblemsToSetRequestSchema>
export type AddProblemsToSetResult = z.infer<typeof addProblemsToSetResultSchema>
export type ReorderProblemSetProblemsRequest = z.infer<
typeof reorderProblemSetProblemsRequestSchema
>
export type UpdateProblemInSetRequest = z.infer<typeof updateProblemInSetRequestSchema> export type UpdateProblemInSetRequest = z.infer<typeof updateProblemInSetRequestSchema>
export type AdminProblemSetBadge = z.infer<typeof adminProblemSetBadgeSchema> export type AdminProblemSetBadge = z.infer<typeof adminProblemSetBadgeSchema>
export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeRequestSchema> export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeRequestSchema>
+89 -43
View File
@@ -3,23 +3,38 @@ import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common" import { paginatedSchema, sampleUserSchema } from "./common"
import { maskedProblemDifficultySchema } from "./problem" import { maskedProblemDifficultySchema } from "./problem"
/**
* 我在这份题单里的进度。
*
* 两个计数口径不同,别混用:
* - completedCount / totalCount 只数**必做题**,进度条和「做完了」看它;
* - solvedCount 数做对的**全部**题(含选做),「做对 N 道」奖章看它 ——
* 和后端 eligibleForBadge 的 problem_count 同一个口径。
*/
export const problemSetUserProgressSummarySchema = z.object({ export const problemSetUserProgressSummarySchema = z.object({
isJoined: z.boolean(), isJoined: z.boolean(),
progressPercentage: z.number(), joinTime: z.string().nullable(),
completedCount: z.number().int(), completedCount: z.number().int(),
totalCount: z.number().int(), totalCount: z.number().int(),
solvedCount: z.number().int(),
isCompleted: z.boolean(), isCompleted: z.boolean(),
completeTime: z.string().nullable(),
}) })
/** 奖章只有两种条件:全部做完、做对 N 道(「总分达到 N」随分数一起拿掉了,0026 换算过) */
export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count"])
export const problemSetBadgeSchema = z.object({ export const problemSetBadgeSchema = z.object({
id: z.number().int(), id: z.number().int(),
problemSetId: z.number().int(), problemSetId: z.number().int(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
icon: z.string(), icon: z.string(),
conditionType: z.enum(["all_problems", "problem_count", "score"]), conditionType: badgeConditionTypeSchema,
conditionValue: z.number().int(), conditionValue: z.number().int(),
isEarned: z.boolean().optional(), isEarned: z.boolean().optional(),
/** 我拿到它的时刻;没拿到、没登录为 null */
earnedTime: z.string().nullable().optional(),
}) })
export const problemSetSchema = z.object({ export const problemSetSchema = z.object({
@@ -28,26 +43,26 @@ export const problemSetSchema = z.object({
description: z.string(), description: z.string(),
createdBy: sampleUserSchema, createdBy: sampleUserSchema,
createTime: z.string(), createTime: z.string(),
lastUpdateTime: z.string().optional(), /** 布置到哪天(东八区当天结束的时刻);null = 没布置 */
difficulty: z.enum(["Easy", "Medium", "Hard"]), assignedUntil: z.string().nullable(),
status: z.enum(["active", "archived", "draft"]), /** 现在是不是在布置期内。服务端按自己的钟算好,前端不用拿本机时间去比 */
endTime: z.string().nullable(), assigning: z.boolean(),
visible: z.boolean(), /** 全部题数(含选做) */
problemsCount: z.number().int(), problemsCount: z.number().int(),
completedCount: z.number().int().optional(), /** 必做题数(一道必做都没标时等于全部题数)——「做完」的分母,没加入时也要用 */
requiredCount: z.number().int(),
/** 加入过的人数 */
joinedCount: z.number().int(),
userProgress: problemSetUserProgressSummarySchema, userProgress: problemSetUserProgressSummarySchema,
badges: z.array(problemSetBadgeSchema).optional(), badges: z.array(problemSetBadgeSchema),
}) })
export const problemSetListSchema = paginatedSchema(problemSetSchema) export const problemSetListSchema = paginatedSchema(problemSetSchema)
/** /**
* 题单页的题目卡片只渲染题号、标题、难度,所以只下发这三样(外加题目主键)。 * 题单页的题目行只渲染题号、标题,所以只下发这几样(外加题目主键)。
* * 以前复用 problemListItemSchema,服务端每次要多 join 一次 user / 标签表,
* 以前这里复用 problemListItemSchema,代价是服务端每次都要多 join 一次 user + * 而那些字段题单页一个都不渲染。
* user_profile 凑 createdBy、再多查一次标签表凑 tags,而 tags / submissionNumber /
* acceptedNumber / createdBy / flowchart / hasAstRules / myStatus 在题单页一个都不渲染
* —— myStatus 甚至是写死的 null。
*/ */
export const problemSetProblemItemSchema = z.object({ export const problemSetProblemItemSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -62,42 +77,72 @@ export const problemSetProblemSchema = z.object({
problem: problemSetProblemItemSchema, problem: problemSetProblemItemSchema,
order: z.number().int(), order: z.number().int(),
isRequired: z.boolean(), isRequired: z.boolean(),
score: z.number().int(), /** 在这份题单里做对了没有(代码通过,或流程图拿到 A / S) */
hint: z.string().nullable(),
isCompleted: z.boolean(), isCompleted: z.boolean(),
/** 在这份题单里做对的时刻 */
solvedTime: z.string().nullable(),
/** 在这份题单里交错了几次(还没做对时显示「交过 n 次」) */
wrongCount: z.number().int(),
/**
* 布置期内、以前做对过、旧代码正被藏着 —— 行上标「以前做对过 · 旧代码先藏着」。
* 规则见后端 services/problemset.ts 的 problemSetLockCutoffs
*/
oldCodeHidden: z.boolean(),
}) })
export const joinProblemSetRequestSchema = z.object({ export const joinProblemSetRequestSchema = z.object({
problemSetId: z.number().int().positive(), problemSetId: z.number().int().positive(),
}) })
export const completedProblemSchema = z.object({ /** 老师看某个班在这份题单里的情况:真名 × 每道题 */
export const problemSetClassCellSchema = z.object({
/** 在题单里做对的时刻 */
solvedTime: z.string().nullable(),
/** 在题单里交错的次数 */
wrongCount: z.number().int(),
/** 加入之前就做对过这道题(任何入口) */
solvedBefore: z.boolean(),
})
export const problemSetClassStudentSchema = z.object({
userId: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
/** 没加入为 null */
joinTime: z.string().nullable(),
/** 做对的必做题数 */
completedCount: z.number().int(),
cells: z.array(problemSetClassCellSchema),
})
export const problemSetClassViewSchema = z.object({
/** 加入过这份题单的学生分属哪些班:三分之一以上的人加入了的班在前(按最近一次加入),零星几个人的在后 */
classes: z.array(
z.object({ className: z.string(), joined: z.number().int(), size: z.number().int() }),
),
/** 这次看的是哪个班;谁都没加入时为 null */
className: z.string().nullable(),
problems: z.array(
z.object({
id: z.number().int(), id: z.number().int(),
_id: z.string(), _id: z.string(),
title: z.string(), title: z.string(),
}) isRequired: z.boolean(),
/** 这个班在题单里做对的人数 */
export const problemSetProgressSchema = z.object({ solved: z.number().int(),
id: z.number().int(),
problemSetId: z.number().int(),
user: sampleUserSchema,
joinTime: z.string(),
completeTime: z.string().nullable(),
isCompleted: z.boolean(),
progressPercentage: z.number(),
completedProblemsCount: z.number().int(),
totalProblemsCount: z.number().int(),
totalScore: z.number().int(),
completedProblems: z.array(completedProblemSchema),
})
export const problemSetProgressListSchema = paginatedSchema(problemSetProgressSchema).extend({
statistics: z.object({
total: z.number().int(),
completed: z.number().int(),
avgProgress: z.number(),
}), }),
problems: z.array(completedProblemSchema), ),
/** 必做题数(全是选做时等于全部题数),「做完」的分母 */
totalCount: z.number().int(),
/** 这个班的全部学生(按学号),没加入的 joinTime 为 null */
students: z.array(problemSetClassStudentSchema),
})
/** 某道题为什么藏着旧代码:落在哪些正在布置的题单里 */
export const problemSetLockSchema = z.object({
problemSetId: z.number().int(),
title: z.string(),
assignedUntil: z.string(),
}) })
export const userBadgeSchema = z.object({ export const userBadgeSchema = z.object({
@@ -112,10 +157,11 @@ export type ProblemSet = z.infer<typeof problemSetSchema>
export type ProblemSetList = z.infer<typeof problemSetListSchema> export type ProblemSetList = z.infer<typeof problemSetListSchema>
export type ProblemSetBadge = z.infer<typeof problemSetBadgeSchema> export type ProblemSetBadge = z.infer<typeof problemSetBadgeSchema>
export type ProblemSetProblem = z.infer<typeof problemSetProblemSchema> export type ProblemSetProblem = z.infer<typeof problemSetProblemSchema>
export type ProblemSetProgress = z.infer<typeof problemSetProgressSchema> export type ProblemSetClassView = z.infer<typeof problemSetClassViewSchema>
export type ProblemSetProgressList = z.infer<typeof problemSetProgressListSchema> export type ProblemSetClassStudent = z.infer<typeof problemSetClassStudentSchema>
export type ProblemSetLock = z.infer<typeof problemSetLockSchema>
export type UserBadge = z.infer<typeof userBadgeSchema> export type UserBadge = z.infer<typeof userBadgeSchema>
export type CompletedProblem = z.infer<typeof completedProblemSchema> export type BadgeConditionType = z.infer<typeof badgeConditionTypeSchema>
export type ProblemSetUserProgressSummary = z.infer<typeof problemSetUserProgressSummarySchema> export type ProblemSetUserProgressSummary = z.infer<typeof problemSetUserProgressSummarySchema>
export type JoinProblemSetRequest = z.infer<typeof joinProblemSetRequestSchema> export type JoinProblemSetRequest = z.infer<typeof joinProblemSetRequestSchema>