From 25c6e99c543a1eba084b6e35aa93b7b074a021a2 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 25 Aug 2026 14:08:09 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E5=8F=B0):=20=E8=A1=A5=E5=9B=9E?= =?UTF-8?q?=E3=80=8C=E6=9C=80=E9=AB=98=E7=A5=A8=E8=AF=84=E4=BB=B7=E3=80=8D?= =?UTF-8?q?=E5=88=97=EF=BC=8C=E9=87=8D=E5=86=99=E6=97=B6=E6=BC=8F=E6=8E=89?= =?UTF-8?q?=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端题目列表的「反馈」列一直是空的:前端渲染逻辑还在,后端两处 硬编码 topReaction: null —— 旧后端的 reaction/services.py:get_top_reactions 没有跟着迁过来,阶段 0 的清单也没抓到。 新增 services/reaction.ts,口径逐条对齐旧实现: - 按 (problem_id, type) 分组计数,取票数最高的那个; - 并列时按 reactionKeySchema 的定义顺序取靠前的,与前端 REACTIONS 的顺序 一致(旧后端是 TYPE_ORDER,已核对两边七个 key 的顺序与内容完全相同); - 只返回有评价的题目,没有的题调用方兜 null。 比库里可能残留、但前端已经下掉的类型多了一道跳过:不跳的话一个不再展示的 类型会以并列最优的身份把真正的最高票挤掉。 只有公开题列表下发,比赛题列表不下发 —— 与旧后端一致(旧的 top_reaction 只出现在 ProblemAPI,ContestProblemAPI 没有),前端那一列的注释也是这么写的。 实测:造 3 票 learned + 1 票 too_easy 取到 learned/3;interesting 与 too_hard 各 1 票时取到 too_hard(定义顺序在前);插一条 obsolete_kind 不影响结果;无评价的题返回 null。探针数据已清理。 Co-Authored-By: Claude Opus 5 --- apps/api/src/routes/admin/problem.ts | 5 ++- apps/api/src/services/reaction.ts | 55 ++++++++++++++++++++++++++++ apps/web/src/admin/problem/list.vue | 2 +- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/services/reaction.ts diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 61e0cde..e595d54 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -26,6 +26,7 @@ import { packTestCaseZip, processTestCaseZip, readInfo, readSqlScripts, TestCase import { config } from "../../config" import { readFile } from "node:fs/promises" import { resolve } from "node:path" +import { getTopReactions } from "../../services/reaction" import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers" export const adminProblemRoutes = new Hono() @@ -252,6 +253,8 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset), ]) + // 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致 + const topReactions = await getTopReactions(rows.map(({ problem }) => problem.id)) return success(c, adminProblemListSchema.parse({ results: await Promise.all(rows.map(async ({ problem, user: creator, realName }) => adminProblemListItemSchema.parse({ @@ -266,7 +269,7 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { hasAstRules: problem.astRules !== null, allowFlowchart: problem.allowFlowchart, showFlowchart: problem.showFlowchart, - topReaction: null, + topReaction: topReactions.get(problem.id) ?? null, }))), total: totalRow[0]?.value ?? 0, })) diff --git a/apps/api/src/services/reaction.ts b/apps/api/src/services/reaction.ts new file mode 100644 index 0000000..7b640eb --- /dev/null +++ b/apps/api/src/services/reaction.ts @@ -0,0 +1,55 @@ +import { reactionKeySchema } from "@oj2/contract" +import { count, inArray } from "drizzle-orm" + +import { db, schema } from "../db" + +/** + * 并列票数时按 reactionKeySchema 的定义顺序取靠前的那个,与前端 REACTIONS + * 的顺序一致 —— 对齐旧后端 `reaction/services.py` 的 TYPE_ORDER。 + * 改这里的顺序会让同票题目的展示结果跟着变。 + */ +const TYPE_ORDER = new Map( + reactionKeySchema.options.map((key, index) => [key, index]), +) + +export interface TopReaction { + type: string + count: number +} + +/** + * 批量取每道题得票最高的评价,返回 `Map`, + * **只包含有评价的题目**(没有的题不进 Map,调用方自己兜 null)。 + * + * 对齐旧后端 `reaction/services.py:get_top_reactions`。 + */ +export async function getTopReactions(problemIds: number[]) { + const top = new Map() + if (problemIds.length === 0) return top + + const rows = await db + .select({ + problemId: schema.reaction.problemId, + type: schema.reaction.type, + value: count(), + }) + .from(schema.reaction) + .where(inArray(schema.reaction.problemId, problemIds)) + .groupBy(schema.reaction.problemId, schema.reaction.type) + + for (const row of rows) { + const order = TYPE_ORDER.get(row.type) + // 库里可能残留前端已经下掉的旧类型。跳过而不是当成并列最优 —— + // 否则一个已经不展示的类型会把真正的最高票挤掉。 + if (order === undefined) continue + const current = top.get(row.problemId) + if ( + !current || + row.value > current.count || + (row.value === current.count && order < TYPE_ORDER.get(current.type)!) + ) { + top.set(row.problemId, { type: row.type, count: row.value }) + } + } + return top +} diff --git a/apps/web/src/admin/problem/list.vue b/apps/web/src/admin/problem/list.vue index bba2fd0..b162d0d 100644 --- a/apps/web/src/admin/problem/list.vue +++ b/apps/web/src/admin/problem/list.vue @@ -137,7 +137,7 @@ const baseColumns: DataTableColumn[] = [ }, { title: "反馈", - key: "top_reaction", + key: "topReaction", width: 60, render: (row) => { const top = row.topReaction