perf(教师统计): 展开行的明细改成按需拉,统计响应不再背着 4.9 万行没人看的数据
Some checks failed
Deploy / deploy (push) Has been cancelled

上一版把明细收成「只取有 AC 的人、每人最近 50 条」,拿生产快照(12.4 万条提交)
实测下来只从 105631 行降到 49108 行 —— 2.1 倍,不是一个数量级。原因是绝大多数人
本来就不到 50 条,每人截断那道闸在真实分布上基本没咬到,最坏情况响应体仍有 ~2.4MB。

真正的问题是形状不对:表格一次只展开一行(updateExpandedRowKeys 只留最后一个 key),
却给 1900 个人各准备了一份。所以明细整个从统计响应里拿掉,改成展开时按需拉:

- 新端点 `GET /submissions/statistics/items`,要用户名 + 同一套时间窗和题号。
  用户名这里是**精确匹配**,不是统计接口那种 ilike —— 那边填 ks251 要圈出整个班,
  这边是「点开的这一行是谁」。上限 200 条,多取一条来判断 truncated,被截断时
  展开行里说明「只显示最近 200 条」,免得老师以为这人就交了这么多。
- 时间窗和题号抽成共用的 statisticsScope,两个接口必须同一个范围,否则展开行
  看到的是另一个窗口的数据。
- 前端按人缓存,收起再展开不重拉;每次重新统计(含 15 秒自动刷新)清缓存,
  并把当前展开着的那一行重拉一遍 —— 展开行跟着一起活着,不然刷新之后上面的数
  变了、下面的明细还是老的。
- 去重放在 loadItems 里:点一行会同时走 rowProps 的 onClick 和表格的
  update:expanded-row-keys,两边都想拉,改之前真发出了两条一模一样的请求。

顺带把 submissionItems 从 submissionStatisticsUserSchema 里删掉。

生产快照上的验证(12.4 万条提交 / 1956 用户 / 961 题,恢复进一次性容器跑完即删):

- 最坏情况(全部时段 + 不填条件)少搬 49108 行,约 2.4MB
- 真实课堂量级(最忙的一小时:583 条提交 / 81 人)四条查询分别是
  主聚合 45ms、明细 6.5ms、语法未过 2.4ms、最近错因 <1ms
- 「已解决」那条口径修正的实际影响:13.3% 的「人×题」有重复 AC,同一题最多 AC 45 次;
  按人看最夸张的是 419 条 AC 其实只有 38 道题
- 语法要求的题 15 道、result=10 共 57 条,其中「最后也没改对」的 18 个人×题
  —— 角标会出现,但稀有

浏览器实跑:展开前不发明细请求;展开后 1 条、12 个按钮;收起 +0、再展开 +0(走缓存);
自动刷新时统计与明细 1:1 配对、间隔 15 秒,没有重复请求。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
This commit is contained in:
2026-09-08 05:43:32 -06:00
parent fdfb064d0c
commit 9c5a1d551d
4 changed files with 178 additions and 69 deletions

View File

@@ -37,6 +37,7 @@ import {
submissionDetailSchema,
type FlowchartStatistics,
type SubmissionStatistics,
type SubmissionStatisticsItems,
} from "@oj2/contract"
import api from "utils/api"
import { filterResult } from "oj/transforms"
@@ -159,6 +160,20 @@ export function adminRejudge(id: string) {
)
}
/**
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
* 和上面那个按班级模糊匹配的不是一回事。
*/
export function getSubmissionStatisticsItems(
duration: { start?: string; end: string },
username: string,
problemID?: string,
) {
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
params: { ...duration, problemId: problemID, username },
})
}
export function getSubmissionStatistics(
duration: { start?: string; end: string },
problemID?: string,

View File

@@ -192,7 +192,7 @@
<script setup lang="ts">
import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns"
import { getSubmissionStatistics } from "oj/api"
import { getSubmissionStatistics, getSubmissionStatisticsItems } from "oj/api"
import { DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { useConfigStore } from "../store/config"
@@ -202,6 +202,7 @@ import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
import { JUDGE_STATUS } from "utils/constants"
import type {
AttemptedStudent,
SubmissionStatisticsItems,
SubmissionStatisticsUser,
UnacceptedStudent,
} from "@oj2/contract"
@@ -232,24 +233,35 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
{
type: "expand",
renderExpand: (row) => {
return h(NFlex, { size: "small", wrap: true }, () =>
row.submissionItems.map((item) =>
h(
NButton,
{
size: "small",
tertiary: true,
type: JUDGE_STATUS[item.result]?.type ?? "default",
style: "width: 120px",
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(item.id)
const loaded = items[row.username]
if (!loaded) return h(NText, { depth: 3 }, () => "加载中…")
return h(NFlex, { vertical: true, size: "small" }, () => [
h(NFlex, { size: "small", wrap: true }, () =>
loaded.items.map((item) =>
h(
NButton,
{
size: "small",
tertiary: true,
type: JUDGE_STATUS[item.result]?.type ?? "default",
style: "width: 120px",
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(item.id)
},
},
},
() => item.id.toString().slice(0, 12),
() => item.id.toString().slice(0, 12),
),
),
),
)
loaded.truncated
? h(
NText,
{ depth: 3 },
() => `只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
)
: null,
])
},
},
{ title: "用户", key: "username" },
@@ -325,6 +337,41 @@ const listUnaccepted = ref<UnacceptedStudent[]>([])
const listAttempted = ref<AttemptedStudent[]>([])
const expandedRowKeys = ref<DataTableRowKey[]>([])
/**
* 展开行的明细,按人缓存。**每次重新统计都清空** —— 时间窗滚过之后旧明细就不对了;
* 清完如果还有展开着的行,顺手把那一行重拉一遍,让它跟着自动刷新一起活着。
*/
const items = reactive<Record<string, SubmissionStatisticsItems>>({})
// 点一行会同时走 rowProps 的 onClick 和表格的 update:expanded-row-keys
// 两边都想拉一次;去重放在这里,调用方不用各自判
const itemsLoading = new Set<string>()
async function loadItems(username: string) {
if (items[username] || itemsLoading.has(username)) return
itemsLoading.add(username)
const current = Date.now()
const duration =
query.duration === "all"
? { end: formatISO(current) }
: {
start: formatISO(sub(current, subOptions.value)),
end: formatISO(current),
}
try {
items[username] = await getSubmissionStatisticsItems(
duration,
username,
query.problem,
)
} catch {
// 拉不到就当空的:展开行显示不出东西,但不该把整个面板带崩
items[username] = { items: [], truncated: false }
} finally {
itemsLoading.delete(username)
}
}
/**
* 「查出东西了吗」。**不能只看提交数** —— 一节课刚开始时一条提交都没有,但后端
* 已经把整份花名册当作「未完成」返回了,而那正是老师最想看名单的时刻。
@@ -624,6 +671,10 @@ async function fetchStatistics() {
personCount.value = res.personCount
// 查过的班级记下来,下次打开直接带上
if (query.username) storage.set(STORAGE_KEY.STATISTICS_CLASS, query.username)
const expanded = expandedRowKeys.value[0]
for (const key of Object.keys(items)) delete items[key]
if (typeof expanded === "string") loadItems(expanded)
}
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
@@ -632,6 +683,8 @@ function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
function updateExpandedRowKeys(keys: DataTableRowKey[]) {
expandedRowKeys.value = keys.slice(-1)
const opened = expandedRowKeys.value[0]
if (typeof opened === "string") loadItems(opened)
}
function rowProps(row: SubmissionStatisticsUser) {
@@ -641,6 +694,7 @@ function rowProps(row: SubmissionStatisticsUser) {
const key = rowKey(row)
const isExpanded = expandedRowKeys.value.includes(key)
expandedRowKeys.value = isExpanded ? [] : [key]
if (!isExpanded) loadItems(row.username)
},
}
}