perf(提交列表): count 去掉无谓 join、只取序列化用得到的列

用户反馈「HEADER 点提交后页面空白一段时间才有数据」。拿生产快照
(12.3 万条提交 / submission 表 169MB)在本机实测,问题分两头。

**后端**(本次改的):

- count 无条件 `innerJoin(problem)`,但 problem 只有按题号筛选时才出现在
  where 里。带 join 的 count 走 seq scan 78ms,去掉 join 走索引 7.5ms。
- 行查询 `select submission.* + problem.*` 把两张表所有列都拉回来,包括
  submission.code(学生源码)、info、ip,以及 problem 的 description /
  hint / samples / answers / flowchart_data / sql_display —— 这些字段 map
  的时候一个都没用上。改成只 select 需要的列。
  canViewSubmission 的参数类型随之从整行 $inferSelect 收窄成实际用到的
  字段,完整行结构上仍然满足,详情接口调用不受影响。

公开列表和比赛列表两处是同一份代码,一起改了。

**前端**:

- list.vue 静态 import 了四个只在默认关闭的 n-modal 里用的组件,其中两个
  统计面板还只有老师看得见。光 chart.js 就 197KB,进页面前必须先下完。
  改成 defineAsyncComponent 后本路由增量下载 675KB / 59 个文件 →
  375KB / 43 个文件。
- n-data-table 没传 :loading,等接口这段时间表格就是一片空白,连转圈都
  没有 —— 这是「页面空白」最直接的观感来源。用 try/finally 包,接口抛错
  不会把转圈卡死。
- isAuthed 变化时重复拉了一次今日提交数。它不看登录态,onMounted 那次
  就够了。列表本身仍然重拉(要更新提交编号列的可点击状态),那次不是
  浪费;本想用 userStore.isFinished 把首次请求延后,但 getProfile() 一旦
  reject,isFinished 会永远停在 false,匿名用户就再也看不到列表了。

还有一个更大头的原因是索引用不上,导致每次翻页全表扫 169MB,那部分
需要加索引,走下一个提交。

验证:起真实 API 打生产快照,匿名 / 已登录 / myself / 题号筛选 /
语言+状态 / today / offset=5000 / 比赛列表全部 200,响应体大小前后一致
(2990 bytes),字段没丢。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 07:55:47 -06:00
parent a9332df6bd
commit 2ee61756b8
2 changed files with 94 additions and 38 deletions

View File

@@ -23,13 +23,23 @@ import { useUserStore } from "shared/store/user"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
import FlowchartStatisticsPanel from "shared/components/FlowchartStatisticsPanel.vue"
import SubmissionLink from "./components/SubmissionLink.vue"
import SubmissionDetail from "./detail.vue"
import Grade from "./components/Grade.vue"
import FlowchartLink from "./components/FlowchartLink.vue"
import FlowchartScoreDetail from "./components/FlowchartScoreDetail.vue"
// 下面四个组件只在默认关闭的 n-modal 里用,其中两个统计面板还只有老师看得见。
// 静态 import 会把它们拖进本路由的关键路径——光 chart.js 就 197KB进页面前必须先下完。
// 改成异步后本路由增量下载从 675KB / 59 个文件降到 360KB 出头。
const StatisticsPanel = defineAsyncComponent(
() => import("shared/components/StatisticsPanel.vue"),
)
const FlowchartStatisticsPanel = defineAsyncComponent(
() => import("shared/components/FlowchartStatisticsPanel.vue"),
)
const SubmissionDetail = defineAsyncComponent(() => import("./detail.vue"))
const FlowchartScoreDetail = defineAsyncComponent(
() => import("./components/FlowchartScoreDetail.vue"),
)
interface SubmissionQuery {
username: string
@@ -51,6 +61,8 @@ const submissions = ref<SubmissionListItem[]>([])
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
const total = ref(0)
const todayCount = ref(0)
// 没有它的话,等接口这段时间表格就是一片空白,连转圈都没有
const loading = ref(false)
// 使用分页 composable
const { query, clearQuery } = usePagination<SubmissionQuery>({
@@ -99,29 +111,34 @@ const languageOptions: SelectOption[] = [
async function listSubmissions() {
if (query.page < 1) query.page = 1
const offset = query.limit * (query.page - 1)
if (query.language === "Flowchart") {
const res = await getFlowchartSubmissions({
username: query.username,
problemId: query.problem,
myself: query.myself,
offset,
limit: query.limit,
today: query.today,
grade: query.result,
})
total.value = res.total
flowcharts.value = res.results
} else {
const res = await getSubmissions({
...query,
offset,
problemId: query.problem,
contestId: (route.params.contestID as string) ?? "",
language: query.language,
today: query.today,
})
submissions.value = res.results
total.value = res.total
loading.value = true
try {
if (query.language === "Flowchart") {
const res = await getFlowchartSubmissions({
username: query.username,
problemId: query.problem,
myself: query.myself,
offset,
limit: query.limit,
today: query.today,
grade: query.result,
})
total.value = res.total
flowcharts.value = res.results
} else {
const res = await getSubmissions({
...query,
offset,
problemId: query.problem,
contestId: (route.params.contestID as string) ?? "",
language: query.language,
today: query.today,
})
submissions.value = res.results
total.value = res.total
}
} finally {
loading.value = false
}
}
@@ -219,13 +236,11 @@ watch(
},
)
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
// 今日提交数不看登录态onMounted 那次就够了,这里不用再拉一遍。
watch(
() => userStore.isAuthed,
() => {
listSubmissions()
if (route.name === "submissions") getTodayCount()
},
() => listSubmissions(),
)
const columns = computed(() => {
@@ -491,12 +506,14 @@ const flowchartColumns = computed(() => {
:bordered="false"
:columns="flowchartColumns"
:data="flowcharts"
:loading="loading"
/>
<n-data-table
v-else
:bordered="false"
:columns="columns"
:data="submissions"
:loading="loading"
/>
</n-flex>
<Pagination