From f66bafafaf25fc55046eca421b1eb65e82eb7f48 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 8 Sep 2026 05:20:51 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E6=95=99=E5=B8=88=E7=BB=9F=E8=AE=A1):=20?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=BB=9F=E8=AE=A1=E9=9D=A2=E6=9D=BF=E8=BF=94?= =?UTF-8?q?=E4=BF=AE=20=E2=80=94=E2=80=94=20=E7=BB=9F=E8=AE=A1=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E3=80=81=E6=9C=AA=E5=AE=8C=E6=88=90=E5=88=86=E4=B8=A4?= =?UTF-8?q?=E6=A0=8F=E3=80=81=E5=A4=9A=E9=A2=98=E3=80=81=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查这个面板时发现的一串问题和缺口,一起修掉。改动集中在 `GET /submissions/statistics` 和 `StatisticsPanel.vue`,两边互相咬着, 拆不成独立的 commit。 **「已解决」数的不是题数**(真在显示错数字)。`count(*) filter (accepted)` 按用户名分组、没有 distinct problem_id,同一道题重复 AC 会重复计数。查单道题时 看不出来,老师查「这节课全班」时「已解决 5」可能是同一道题交了 5 次。新增 `solvedCount = count(distinct problem_id) filter (accepted)`,表格那一列换成它; `acceptedCount` 保留,正确率的分子仍然是提交条数。 **正确率把判题中的算进了分母**。PENDING / JUDGING 进分母不进分子,全班同时交卷的 那几秒正确率凭空掉一截 —— 而老师盯着看的就是这个数。分母改成判完的条数, `UNJUDGED_RESULTS` 提到 judge/status.ts 共用。总提交仍是全部条数(交了就算交过, 否则人数口径会跟着变,正在判的学生会掉进「没交」名单),另外下发 `judgingCount`, 非零时面板多显示一块「判题中」,三个数字才对得上。 **「未完成」实际是「一次没交」**。做了但一次没对的学生既不在「完成人数」也不在 未完成名单,等于从屏幕上消失 —— 而那恰恰是最该去看一眼的人。新增 `dataAttempted`,未完成栏拆成「还没交」/「交了没对」两组,tab 计数是两者之和。 请假隐藏对两组同时生效:他们同样占着班级人数这个分母,只藏一半会把完成度算错。 **点名字能看到错在哪**。`dataAttempted` 带上最近一条提交的题号、状态和 `statistic_info.err_info`(截断 400 字),点名字弹出来,还能一步跳到代码。 老师不用再切到提交列表、翻到这个人、点开代码。 **支持一次查几道题**。题号框接受 `1001,1005,1010`(中英文逗号、分号、空格都当 分隔符,投影前手敲不该因为打了全角逗号就查不出来),有一个题号不存在就整体 404。 **完成 = 这几道全解决**;只填一道时和原来完全等价,不填题号时退回「至少做出一道」。 差一道的人落在「交了没全对」里,名字后面缀 `2/3题`。 **零提交时整块面板消失**。判空条件是 `count.total > 0`,可一节课刚开始一条提交都 没有、后端已经把整份花名册当作「未完成」返回了 —— 最该看名单的时刻反而只显示 「暂无数据」。流程图那边更彻底:`/flowcharts/statistics` 的零提交分支把 `dataUnaccepted` 写死成空数组,名单压根没下发。 **面板不会自己刷新**。打开是空的、要点一次按钮,拿到的是那一刻的快照。改成打开即查 + 每 15 秒滚动重查,页面切到后台就停,关掉面板随组件卸载停掉。上一次没回来就跳过 这一次。班级从手打 `ks251` 换成下拉(选项来自网站配置的 class_list,和登录框同一份), 保留自由输入,查过的班级记进 localStorage 下次带上 —— 机房电脑一台对一个班。 **明细查询没有 LIMIT**。展开行用的 submissionItems 原来把窗口内**全部**提交捞进内存 再原样序列化,「全部时段 + 不填条件」就是十几万条。改成只取有 AC 的人、每人最近 50 条, 截断用窗口函数发生在数据库侧;表格「提交数」仍是真实总数。 **顺带**:`personRate` 前端从来没读过(完成度是前端按「减掉请假人数的分母」自己算的), 从契约里删掉;「语法未过」的题数单列出来(AST_CHECK_FAILED 全站仍然算通过,口径没动, 只是让老师看得见谁是绕过语法要求做出来的,那批人教学上没达标)。 实跑验证(dev 全栈 + 浏览器): - 已解决:student 两条 AC 都在题号 5 上 —— 改前显示 2,改后显示 1 - 正确率:临时把两条改成 PENDING/JUDGING,12 条提交 2 条通过 —— 16.67% → 20%, 面板多出「判题中 2」,表格显示「12(2 条判题中)」 - 未完成两栏:student2 没交、student 交了 12 次没对,请假隐藏 student 后 班级人数 2→1、tab 2→1、出现「恢复 1 位」 - 错因弹层:点 student 弹出「最近一次:1020 · 编译失败 / Test case not found / 看代码」 - 多题:`1004` 完成 1 人;`1004,1005` 完成 0 人、交了没全对 student 1/2题 8次; 全角逗号同上;`1004,9999` 报 `Problem 9999 does not exist` - 自动刷新:打开即发请求,之后 04:44:22 → 04:44:36 → 04:44:51 → 04:45:06 每 15 秒 一次且窗口跟着滚;关掉面板后 20 秒请求数不再增长 - 班级下拉:选「25计算机1班」→ ks251,重开面板自动带上并立刻查 tsc / vue-tsc / check:routes 全过。验证用的 dev 库改动已还原。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd --- apps/api/src/judge/status.ts | 7 + apps/api/src/routes/flowchart.ts | 7 +- apps/api/src/routes/submission.ts | 301 ++++++++++++--- .../components/FlowchartStatisticsPanel.vue | 18 +- .../src/shared/components/StatisticsPanel.vue | 358 ++++++++++++++---- apps/web/src/utils/constants.ts | 1 + packages/contract/src/submission.ts | 59 ++- 7 files changed, 620 insertions(+), 131 deletions(-) diff --git a/apps/api/src/judge/status.ts b/apps/api/src/judge/status.ts index 04b4a6b..cf22c9b 100644 --- a/apps/api/src/judge/status.ts +++ b/apps/api/src/judge/status.ts @@ -43,6 +43,13 @@ export function judgeStatusName(result: number) { return JUDGE_STATUS_NAME[result] ?? `未知状态(${result})` } +/** + * 还没判完的两个状态。这类提交**已经落库但结果未定**,凡是算「正确率」的地方都得把 + * 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。 + * 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。 + */ +export const UNJUDGED_RESULTS: number[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING] + /** * **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED,那也是答案对了) * 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题, diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index b23e75a..a440748 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -235,7 +235,12 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { personCount: roster.length, completedCount: 0, wordFrequencies: [], - dataUnaccepted: [], + // 一条提交都没有时,花名册上的人**全都**是「没做」—— 原来这里写死空数组, + // 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给 + dataUnaccepted: roster.map((row) => ({ + username: row.username, + realName: stripClassPrefix(row.username, row.className), + })), } if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty)) diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index da7749c..bc23249 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -22,7 +22,7 @@ import { import type { AuthUser } from "../auth/session" import { db, schema } from "../db" import { failure, success } from "../http" -import { JudgeStatus } from "../judge/status" +import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status" import { judgeQueue } from "../queue" import { canAccessContest, @@ -180,6 +180,11 @@ submissionRoutes.get("/submissions/today-count", async (c) => { const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] +/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */ +function judgedRate(accepted: number, judged: number) { + return judged > 0 ? rounded((accepted / judged) * 100) : 0 +} + /** * 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。 */ @@ -190,23 +195,173 @@ function statisticsRange(c: { req: { query(name: string): string | undefined } } return { start: start || null, end } } +/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */ +const STATISTICS_MAX_PROBLEMS = 20 + /** - * 按题号(展示用的 _id)定位公开题目。找不到时统计接口要报错而不是退化成「全部题目」, - * 否则教师打错一个字就会看到全站数据还以为是本题的。 + * 题号框允许一次填几道:`1001,1005,1010`。中英文逗号、空格、分号都当分隔符 —— + * 老师在投影前手敲,不该因为打了个全角逗号就查不出来。 */ -async function findPublicProblemByDisplayId(displayId: string) { - const [row] = await db - .select({ id: schema.problem.id }) +function parseDisplayIds(raw: string) { + const seen = new Set() + const ids: string[] = [] + for (const part of raw.split(/[,,;;\s]+/)) { + const id = part.trim() + if (!id) continue + const key = id.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + ids.push(id) + } + return ids +} + +/** + * 按题号(展示用的 _id)定位公开题目。**有一个找不到就整体报错**,不退化成「全部题目」—— + * 否则教师打错一个字就会看到全站数据还以为是这几道题的。 + */ +async function findPublicProblemsByDisplayIds(displayIds: string[]) { + const lowered = displayIds.map((id) => id.toLowerCase()) + const rows = await db + .select({ id: schema.problem.id, displayId: schema.problem.displayId }) .from(schema.problem) .where( and( - sql`lower(${schema.problem.displayId}) = lower(${displayId})`, + inArray(sql`lower(${schema.problem.displayId})`, lowered), isNull(schema.problem.contestId), eq(schema.problem.visible, true), ), ) - .limit(1) - return row ?? null + const found = new Set(rows.map((row) => row.displayId.toLowerCase())) + const missing = displayIds.find((id) => !found.has(id.toLowerCase())) + return { ids: rows.map((row) => row.id), missing: missing ?? null } +} + +/** + * 表格展开行要看的「这个人交了哪几次」。两道闸都是为了不让「全部时段 + 不填条件」 + * 把十几万条提交整个搬进响应体: + * + * - **只取有 AC 的人**。明细只挂在 `data` 里,而 `data` 本来就只留有 AC 的人, + * 原来给「一次没对的人」也捞一份明细,捞完直接扔掉。 + * - **每人只留最近 50 条**。展开行是一排 120px 的按钮,几十个就已经翻不动了。 + * 表格「提交数」那一列走的是 perUser 的 count,仍然是真实总数,不受这里截断影响。 + */ +const STATISTICS_ITEMS_PER_USER = 50 + +async function submissionItemsByUser(where: SQL | undefined, usernames: string[]) { + const byUser = new Map() + if (!usernames.length) return byUser + + // 走窗口函数而不是「查全量再在 JS 里截断」:截断要发生在数据库那边才省得下来。 + const rows = await db.execute<{ username: string; id: string; result: number }>(sql` + select username, id, result from ( + select + ${schema.submission.username} as username, + ${schema.submission.id} as id, + ${schema.submission.result} as result, + row_number() over ( + partition by ${schema.submission.username} + order by ${schema.submission.createTime} desc + ) as rn + from ${schema.submission} + where ${and(where, inArray(schema.submission.username, usernames))} + ) t + where rn <= ${STATISTICS_ITEMS_PER_USER} + -- rn 就是「这个人的第几新」,外层不排的话展开行里的按钮是乱序的 + order by username, rn + `) + + for (const row of rows) { + const bucket = byUser.get(row.username) + if (bucket) bucket.push({ id: row.id, result: row.result }) + else byUser.set(row.username, [{ id: row.id, result: row.result }]) + } + return byUser +} + +/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */ +const FAILURE_MESSAGE_LIMIT = 400 + +/** + * 「交了没对」那一栏点开要看的:这个人**最近一条**提交错在哪。 + * + * 有了它,老师看到「张三 12次」之后不用再切到提交列表、翻到这个人、点开代码 —— + * 点一下名字就知道是编译错了还是答案错了、报的什么。err_info 是判题机塞进 + * statistic_info 的那一段,提交详情页读的也是它。 + */ +async function lastFailureByUser(where: SQL | undefined, usernames: string[]) { + const byUser = new Map< + string, + { id: string; problem: string; result: number; error: string | null } + >() + if (!usernames.length) return byUser + + // 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x" + const rows = await db.execute<{ + username: string + id: string + problem: string + result: number + error: string | null + }>(sql` + select username, id, problem, result, error from ( + select + ${schema.submission.username} as username, + ${schema.submission.id} as id, + ${schema.problem.displayId} as problem, + ${schema.submission.result} as result, + left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error, + row_number() over ( + partition by ${schema.submission.username} + order by ${schema.submission.createTime} desc + ) as rn + from ${schema.submission} + join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId} + where ${and(where, inArray(schema.submission.username, usernames))} + ) t + where rn = 1 + `) + + for (const row of rows) { + byUser.set(row.username, { + id: row.id, + problem: row.problem, + result: row.result, + error: row.error, + }) + } + return byUser +} + +/** + * 「答案对了,但没按要求的语法写」的题数(AST_CHECK_FAILED)。 + * + * 只算**最后也没改对**的:同一道题上既有 AST_CHECK_FAILED 又有 ACCEPTED,说明学生后来 + * 改成要求的写法了,不该再拿这个提醒老师。所以要先按「人 × 题」聚一层,不能直接 + * `count(distinct problem_id) filter (result = 10)`。 + * + * 口径本身不动 —— AST_CHECK_FAILED 仍然算通过(答案确实对了,全站一致)。这里只是 + * 让教师看得见「这几个人是绕过要求做出来的」,教学上那不算达标。 + */ +async function astOnlyByUser(where: SQL | undefined, usernames: string[]) { + const byUser = new Map() + if (!usernames.length) return byUser + + const rows = await db.execute<{ username: string; n: number }>(sql` + select username, count(*)::int as n from ( + select + ${schema.submission.username} as username, + bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast, + bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac + from ${schema.submission} + where ${and(where, inArray(schema.submission.username, usernames))} + group by ${schema.submission.username}, ${schema.submission.problemId} + ) t + where has_ast and not has_ac + group by username + `) + for (const row of rows) byUser.set(row.username, row.n) + return byUser } /** @@ -236,11 +391,16 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { ] if (range.start) filters.push(sql`${schema.submission.createTime} >= ${range.start}`) - const displayId = c.req.query("problemId")?.trim() - if (displayId) { - const problem = await findPublicProblemByDisplayId(displayId) - if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") - filters.push(eq(schema.submission.problemId, problem.id)) + const displayIds = parseDisplayIds(c.req.query("problemId") ?? "") + if (displayIds.length > STATISTICS_MAX_PROBLEMS) { + return failure(c, 400, "invalid-request", `At most ${STATISTICS_MAX_PROBLEMS} problems`) + } + if (displayIds.length) { + const { ids, missing } = await findPublicProblemsByDisplayIds(displayIds) + if (missing) { + return failure(c, 404, "problem-not-found", `Problem ${missing} does not exist`) + } + filters.push(inArray(schema.submission.problemId, ids)) } const username = c.req.query("username")?.trim() @@ -248,10 +408,22 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { const where = and(...filters) const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})` + // 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉 + const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})` + /** + * **解决的题数**,不是通过的提交条数。同一道题重复 AC(改完再交一次仍然对) + * 在这里只算一道 —— 表格那一列叫「已解决」,数条数就名不副实了。 + * 指定了题号时它最多是 1,不指定时才看得出差别(老师查「这节课全班」就是这种)。 + */ + const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})` - const [[totals], perUser, rosterRows, items] = await Promise.all([ + const [[totals], perUser, rosterRows] = await Promise.all([ db - .select({ total: count(), accepted: acceptedFilter.mapWith(Number) }) + .select({ + total: count(), + accepted: acceptedFilter.mapWith(Number), + judging: judgingFilter.mapWith(Number), + }) .from(schema.submission) .where(where), db @@ -259,6 +431,8 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { username: schema.submission.username, submissionCount: count(), acceptedCount: acceptedFilter.mapWith(Number), + solvedCount: solvedFilter.mapWith(Number), + judgingCount: judgingFilter.mapWith(Number), }) .from(schema.submission) .where(where) @@ -266,26 +440,34 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { .orderBy(desc(count())), // 只有指定了用户名才有「班级人数」这个概念;不指定时分母无意义,旧后端也返回 0 username ? matchedStudents(username) : Promise.resolve([]), - db - .select({ - username: schema.submission.username, - id: schema.submission.id, - result: schema.submission.result, - }) - .from(schema.submission) - .where(where) - .orderBy(desc(schema.submission.createTime)), ]) const submissionCount = totals?.total ?? 0 const acceptedCount = totals?.accepted ?? 0 + const judgingCount = totals?.judging ?? 0 + // 正确率的分母是**判完的条数**,不是总条数 + const judgedCount = submissionCount - judgingCount - const itemsByUser = new Map() - for (const item of items) { - const bucket = itemsByUser.get(item.username) - if (bucket) bucket.push({ id: item.id, result: item.result }) - else itemsByUser.set(item.username, [{ id: item.id, result: item.result }]) - } + /** + * 「做完了」的判定。**指定了几道题,就要几道都解决**(这是教师选的口径: + * 「今天布置三道,谁全做完了」)—— 做出两道差一道的人落在「交了没全对」那一栏, + * 那里带着 `solvedCount`,老师看得出他差几道。 + * + * 只填一道题时 `solvedCount >= 1` 和原来的 `acceptedCount > 0` 完全等价; + * 不填题号时无所谓「全部」,退回「至少做出一道」。 + */ + const requiredSolved = displayIds.length + const isDone = (row: { solvedCount: number; acceptedCount: number }) => + requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0 + + // 表格列的是做完了的人。没做完的(一条没交 / 交了没全对)在「未完成」那一栏 + const acceptedUsers = perUser.filter(isDone) + // 这两个都要等 acceptedUsers 定下来才能查,所以进不了上面那个 Promise.all + const acceptedNames = acceptedUsers.map((row) => row.username) + const [itemsByUser, astOnlyByUserMap] = await Promise.all([ + submissionItemsByUser(where, acceptedNames), + astOnlyByUser(where, acceptedNames), + ]) const submittedUsernames = new Set(perUser.map((row) => row.username)) const classNames = new Map() @@ -297,17 +479,17 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { for (const row of rows) classNames.set(row.username, row.className) } - // 只列出有正确提交的人。做了但一次没对的学生落在「未完成」那一栏 - const data = perUser - .filter((row) => row.acceptedCount > 0) - .map((row) => ({ - username: row.username, - className: classNames.get(row.username) ?? null, - submissionCount: row.submissionCount, - acceptedCount: row.acceptedCount, - correctRate: rounded((row.acceptedCount / row.submissionCount) * 100), - submissionItems: itemsByUser.get(row.username) ?? [], - })) + const data = acceptedUsers.map((row) => ({ + username: row.username, + className: classNames.get(row.username) ?? null, + submissionCount: row.submissionCount, + acceptedCount: row.acceptedCount, + solvedCount: row.solvedCount, + astOnlyCount: astOnlyByUserMap.get(row.username) ?? 0, + judgingCount: row.judgingCount, + correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount), + submissionItems: itemsByUser.get(row.username) ?? [], + })) const dataUnaccepted = rosterRows .filter((row) => !submittedUsernames.has(row.username)) @@ -316,25 +498,42 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { realName: stripClassPrefix(row.username, row.className), })) - // 顺序照搬旧后端:先用原始 person_count 算完成度,再修正 person_count。 - // 修正是为了兜住「学生已删号但提交记录还在」——那时完成人数会大于花名册人数。 + // 交了但一次没对的。**按花名册取**,和 dataUnaccepted 同一个范围 —— + // 不指定用户名时没有花名册,这一栏也就跟着为空,不会冒出一堆别的班的人。 + const rosterNames = new Map(rosterRows.map((row) => [row.username, row.className])) + // 交了但没做完的:包括一道都没对的,也包括三道里做出两道的 + const attemptedRows = perUser.filter( + (row) => !isDone(row) && rosterNames.has(row.username), + ) + const failureByUser = await lastFailureByUser( + where, + attemptedRows.map((row) => row.username), + ) + const dataAttempted = attemptedRows.map((row) => ({ + username: row.username, + realName: stripClassPrefix(row.username, rosterNames.get(row.username) ?? null), + submissionCount: row.submissionCount, + solvedCount: row.solvedCount, + lastFailure: failureByUser.get(row.username) ?? null, + })) + + // 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。 + // 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是 + // 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。 let personCount = rosterRows.length - let personRate = 0 - if (personCount) { - personRate = Math.min(100, rounded((data.length / personCount) * 100)) - if (personCount < data.length) personCount = data.length - } + if (personCount && personCount < data.length) personCount = data.length return success( c, submissionStatisticsSchema.parse({ submissionCount, acceptedCount, - correctRate: submissionCount ? rounded((acceptedCount / submissionCount) * 100) : 0, + judgingCount, + correctRate: judgedRate(acceptedCount, judgedCount), personCount, - personRate, data, dataUnaccepted, + dataAttempted, }), ) }) diff --git a/apps/web/src/shared/components/FlowchartStatisticsPanel.vue b/apps/web/src/shared/components/FlowchartStatisticsPanel.vue index 6382cab..9a1c789 100644 --- a/apps/web/src/shared/components/FlowchartStatisticsPanel.vue +++ b/apps/web/src/shared/components/FlowchartStatisticsPanel.vue @@ -20,13 +20,9 @@ 统计 - + -