diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 18f408b..3939b05 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -50,6 +50,7 @@ import { config } from "../../config" import { readFile } from "node:fs/promises" import { resolve } from "node:path" import { getTopReactions } from "../../services/reaction" +import { problemUserCounts } from "../../services/problem-progress" import { asRecord, queryInteger, sampleUser } from "../helpers" export const adminProblemRoutes = new Hono() @@ -447,8 +448,13 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { ), ) } + // 「全部 / 学生看得到 / 藏着的」:三个按钮上的数按除可见性以外的条件数,列表再按它筛 + const beforeVisible = and(...filters) + const visibleQuery = c.req.query("visible") + if (visibleQuery === "1") filters.push(eq(schema.problem.visible, true)) + else if (visibleQuery === "0") filters.push(eq(schema.problem.visible, false)) const where = and(...filters) - const [totalRow, rows] = await Promise.all([ + const [totalRow, rows, visibleRows] = await Promise.all([ db .select({ value: count() }) .from(schema.problem) @@ -467,13 +473,24 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { .orderBy(desc(schema.problem.createTime)) .limit(limit) .offset(offset), + library + ? [] + : db + .select({ visible: schema.problem.visible, value: count() }) + .from(schema.problem) + .innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) + .where(beforeVisible) + .groupBy(schema.problem.visible), ]) // 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致 const problemIds = rows.map(({ problem }) => problem.id) - const [topReactions, tags] = await Promise.all([ + const [topReactions, tags, userCounts] = await Promise.all([ getTopReactions(problemIds), tagNamesFor(problemIds), + problemUserCounts(problemIds), ]) + const visibleCount = visibleRows.find((row) => row.visible)?.value ?? 0 + const hiddenCount = visibleRows.find((row) => !row.visible)?.value ?? 0 return success(c, { results: rows.map( ({ problem, user: creator, realName }) => @@ -491,9 +508,14 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { allowFlowchart: problem.allowFlowchart, showFlowchart: problem.showFlowchart, topReaction: topReactions.get(problem.id) ?? null, + solvedUsers: userCounts.get(problem.id)?.solved ?? 0, + triedUsers: userCounts.get(problem.id)?.tried ?? 0, }) satisfies AdminProblemListItem, ), total: totalRow[0]?.value ?? 0, + counts: library + ? undefined + : { all: visibleCount + hiddenCount, visible: visibleCount, hidden: hiddenCount }, } satisfies AdminProblemList) }) @@ -637,7 +659,15 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const filters = [eq(schema.problem.contestId, contestId)] const keyword = c.req.query("keyword")?.trim() - if (keyword) filters.push(ilike(schema.problem.title, `%${keyword}%`)) + // 和公开题列表一样,题号也能搜(搜索框写的是「题号或题目名」) + if (keyword) { + filters.push( + or( + ilike(schema.problem.title, `%${keyword}%`), + ilike(schema.problem.displayId, `%${keyword}%`), + )!, + ) + } const where = and(...filters) const [totalRow, rows] = await Promise.all([ db.select({ value: count() }).from(schema.problem).where(where), @@ -673,6 +703,8 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission allowFlowchart: problem.allowFlowchart, showFlowchart: problem.showFlowchart, topReaction: null, + solvedUsers: 0, + triedUsers: 0, }) satisfies AdminProblemListItem, ), total: totalRow[0]?.value ?? 0, diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index 068bee1..9389aad 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -1,6 +1,7 @@ import { parseFlowDoc, batchProblemTagRequestSchema, + batchProblemVisibleRequestSchema, generateFlowchartRequestSchema, renameTagRequestSchema, setTagCategoryRequestSchema, @@ -253,6 +254,28 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async ( return success(c, { visible: !problem.visible }) }) +/** + * 勾了几道,一次让学生看得到 / 藏起来(后台题目列表「已选 N 道」那一条)。 + * 和批量加标签同一个范围:只认公开题,不是全部题目管理员就只认自己出的 + */ +adminTagRoutes.post("/problems/batch-visible", requireProblemPermission, async (c) => { + const parsed = await parseBody(c, batchProblemVisibleRequestSchema) + if (!parsed.success) return parsed.response + const user = c.get("user")! + const filters = [ + inArray(schema.problem.id, parsed.data.problemIds), + isNull(schema.problem.contestId), + ] + if (!canManageAllProblems(user)) filters.push(eq(schema.problem.createdById, user.id)) + const updated = await db + .update(schema.problem) + .set({ visible: parsed.data.visible }) + .where(and(...filters)) + .returning({ id: schema.problem.id }) + if (updated.length === 0) return failure(c, 404, "no-problems", "没有可操作的题目") + return success(c, { problemCount: updated.length }) +}) + // ---------------------------------------------------------------- Python → 流程图 /** diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index d8cd2e5..2ec8d10 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -529,6 +529,12 @@ export const batchProblemTagRequestSchema = z.object({ action: z.enum(["add", "remove"]), }) +/** 勾了几道题,一次让学生看得到 / 藏起来。只认公开题、只认自己能改的 */ +export const batchProblemVisibleRequestSchema = z.object({ + problemIds: z.array(z.number().int().positive()).min(1), + visible: z.boolean(), +}) + export const batchProblemTagResponseSchema = z.object({ problemCount: z.number().int(), tagCount: z.number().int(), @@ -699,9 +705,23 @@ export const adminProblemListItemSchema = z.object({ // 最高票评价 {type, count}。**只有公开题列表下发**,比赛题列表恒传 null —— // 与旧后端 reaction/services.py:get_top_reactions 的口径一致。 topReaction: z.object({ type: reactionKeySchema, count: z.number().int() }).nullable(), + /** + * 做对 / 做过的人数(代码提交,和前台题目列表同一口径)。比赛题列表恒为 0 —— + * 那个口径只数题库里交的。做过的人数 > 0 就删不掉(有提交),列表里「删除」据此灰掉 + */ + solvedUsers: z.number().int(), + triedUsers: z.number().int(), }) -export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema) +export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema).extend({ + /** + * 「全部 / 学生看得到 / 藏着的」三个按钮上的数:除了可见性,筛选条件和列表一样。 + * 只有公开题列表给,比赛题列表没有这组按钮 + */ + counts: z + .object({ all: z.number().int(), visible: z.number().int(), hidden: z.number().int() }) + .optional(), +}) /** * 题面样例 / 标准答案 / 测试点分值。这三个旧后端都是逐字段校验的 @@ -1058,6 +1078,7 @@ export type CreateProblemSetBadgeRequest = z.infer export type RenameTagRequest = z.infer export type BatchProblemTagRequest = z.infer +export type BatchProblemVisibleRequest = z.infer export type GenerateFlowchartRequest = z.infer export type GenerateFlowchartResponse = z.infer export type UpdateProblemRequest = z.infer