Files
OJ2/apps/web/src/oj/problem/list.vue
yuetsh ec35400249
Some checks failed
Deploy / deploy (push) Has been cancelled
perf(首页): 协作弹框改异步加载,首屏 JS 少三分之一
CollabHost 静态 import CollabModal,把整套 CodeMirror(view / state /
language / autocomplete / lang-*)拖进了入口 chunk,而那个组件是
v-if="isTeacher" 的——学生根本不渲染,字节却人人下、人人解析。改成
defineAsyncComponent 之后:

  入口 eager JS  1355 KB → 717 KB(gzip 447 → 234)
  首页 JS 总量   1923 KB → 1285 KB(gzip 642 → 428)

机房那批 Chrome 91 省下的不只是下载,还有六百多 KB 的解析。老师端代价
是第一次接单时多一次 chunk 请求。

顺带修掉题目列表的重复请求:onMounted 拉一次,profile 回来时那个
watch 又拉一次。但请求本来就带 cookie(withCredentials),后端
optionalAuth 认的也是 cookie,首屏那一份的状态列本来就是全的。watcher
改成拿 storage 里的登录态做基线,只在登录态真的变了才补拉。

这条在本机复现不了(/api/me 1ms 就回,路由 chunk 还没挂载完),给
preview 代理的 /api/me 加 300ms 延迟、用 production 构建验证:改前
/problems 发两遍,改后一遍且状态列有值,登录/退出两个切换仍各触发
一次重拉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tj3959yNB2srvc1oL3PaZH
2026-09-01 00:49:56 -06:00

343 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { NFlex, NTag } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import { getProblemList } from "oj/api"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { getTagColor } from "utils/functions"
import type { ProblemFiltered, Tag as ContractTag } from "utils/types"
import { getProblemTagList } from "shared/api"
import Hitokoto from "shared/components/Hitokoto.vue"
import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user"
import { renderTableTitle } from "utils/renders"
import ProblemStatus from "./components/ProblemStatus.vue"
import AuthorSelect from "shared/components/AuthorSelect.vue"
import ProblemListTitle from "./components/ProblemListTitle.vue"
// 列表页的标签是个视图模型:契约的标签 + 本地的选中态
type Tag = ContractTag & { checked: boolean }
interface ProblemQuery {
keyword: string
difficulty: string
tag: string
author: string
sort: string
}
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Low" },
{ label: "中等", value: "Mid" },
{ label: "困难", value: "High" },
]
const sortOptions = [
{ label: "最新创建", value: "" },
{ label: "最早创建", value: "create_time" },
{ label: "最多提交", value: "-submission_number" },
{ label: "最少提交", value: "submission_number" },
{ label: "最多通过", value: "-accepted_number" },
{ label: "最少通过", value: "accepted_number" },
{ label: "画流程图", value: "flowchart" },
{ label: "语法检查", value: "ast" },
]
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const problems = ref<ProblemFiltered[]>([])
const total = ref(0)
const tags = ref<Tag[]>([])
const [showTag, toggleShowTag] = useToggle(isDesktop.value)
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemQuery>({
keyword: useRouteQuery("keyword", "").value,
difficulty: useRouteQuery("difficulty", "").value,
tag: useRouteQuery("tag", "").value,
author: useRouteQuery("author", "").value,
sort: useRouteQuery("sort", "").value,
})
async function listProblems() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemList(offset, query.limit, {
keyword: query.keyword,
tag: query.tag,
difficulty: query.difficulty,
author: query.author,
sort: query.sort,
})
total.value = res.total
problems.value = res.results
}
async function listTags() {
const res = await getProblemTagList()
tags.value = res.map((r: Omit<Tag, "checked">) => ({
...r,
checked: query.tag === r.name,
}))
}
function chooseTag(tag: Tag) {
query.tag = tag.checked ? "" : tag.name
tags.value = tags.value.map((t) => {
if (t.id === tag.id) {
t.checked = !t.checked
} else {
t.checked = false
}
return t
})
}
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblems, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [
query.tag,
query.difficulty,
query.limit,
query.page,
query.author,
query.sort,
],
listProblems,
)
// 监听标签变化,更新标签选中状态
watch(
() => query.tag,
() => {
tags.value = tags.value.map((r: Omit<Tag, "checked">) => ({
...r,
checked: query.tag === r.name,
}))
},
)
// 这里只补「用登录框登进来」那一下:那时页面不刷新,列表还是匿名时拉的。
//
// 进站时的那次 listProblems 不需要它跟着再来一遍 —— 请求带 cookieaxios 开了
// withCredentials后端 optionalAuth 认的也是 cookie所以首屏那一份里状态列
// 本来就是全的。基线取 storage 里的登录态,它和 cookie 同生共死,正好用来区分
// 「profile 回来了,还是原来那个人」和「刚登进来 / 刚退出去」。
let authedAtLoad: boolean = storage.get(STORAGE_KEY.AUTHED) ?? false
watch(
() => [userStore.isFinished, userStore.isAuthed],
([isFinished, isAuthed]) => {
if (!isFinished || isAuthed === authedAtLoad) return
authedAtLoad = isAuthed
listProblems()
},
)
onMounted(() => {
listProblems()
listTags()
})
const baseColumns: DataTableColumn<ProblemFiltered>[] = [
{
title: renderTableTitle("状态", "streamline-emojis:high-voltage"),
key: "status",
width: 80,
align: "center",
render: (row) => h(ProblemStatus, { status: row.status }),
},
{
title: renderTableTitle(
"编号",
"streamline-ultimate-color:board-game-dice-1",
),
key: "_id",
width: 100,
},
{
title: renderTableTitle(
"题目",
"streamline-ultimate-color:fruit-watermelon",
),
key: "title",
minWidth: 200,
render: (row) => h(ProblemListTitle, { problem: row }),
},
{
title: renderTableTitle("难度", "streamline-emojis:lady-beetle"),
key: "difficulty",
width: 100,
render: (row) =>
row.difficulty
? h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty)
: null,
},
{
title: renderTableTitle("标签", "streamline-ultimate-color:attachment"),
key: "tags",
width: 260,
render: (row) =>
h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
},
{
title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"),
key: "author",
width: 130,
},
{
title: renderTableTitle("提交数", "streamline-ultimate-color:paper-write"),
key: "submission",
align: "center",
width: 100,
},
{
title: renderTableTitle("通过率", "streamline-emojis:victory-hand-2"),
key: "rate",
width: 100,
align: "center",
},
]
const columns = computed(() =>
userStore.isAuthed
? baseColumns
: baseColumns.filter((c: any) => c.key !== "status"),
)
function rowProps(row: ProblemFiltered) {
return {
style: "cursor: pointer",
onClick() {
router.push("/problem/" + row._id)
},
}
}
</script>
<template>
<n-flex vertical size="large">
<div class="problem-list-toolbar">
<n-space>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item label="难度">
<n-select
style="width: 80px"
v-model:value="query.difficulty"
:options="difficultyOptions"
/>
</n-form-item>
<n-form-item label="出题者">
<AuthorSelect v-model:value="query.author" />
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item label="排序">
<n-select
style="width: 120px"
v-model:value="query.sort"
:options="sortOptions"
:dropdown-style="{ maxHeight: 'unset' }"
/>
</n-form-item>
<n-form-item>
<n-input
clearable
style="width: 160px"
v-model:value="query.keyword"
placeholder="题号或标题"
/>
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item>
<n-button @click="clearQuery" quaternary>重置</n-button>
</n-form-item>
<n-form-item>
<n-button
@click="toggleShowTag()"
quaternary
icon-placement="right"
>
<template #icon>
<Icon v-if="showTag" icon="ph:caret-down"></Icon>
<Icon v-else icon="ph:caret-up"></Icon>
</template>
标签
</n-button>
</n-form-item>
</n-form>
</n-space>
<Hitokoto v-if="isDesktop" class="problem-list-hitokoto" />
</div>
<n-collapse-transition :show="showTag">
<n-flex>
<n-tag
v-for="tag in tags"
:closable="tag.checked"
@close="chooseTag(tag)"
@click="chooseTag(tag)"
:key="tag.id"
:type="tag.checked ? 'success' : 'default'"
>
{{ tag.name }}
</n-tag>
</n-flex>
</n-collapse-transition>
<n-data-table
:bordered="false"
:data="problems"
:columns="columns"
:row-props="rowProps"
/>
</n-flex>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<style scoped>
.problem-list-toolbar {
display: grid;
grid-template-columns: minmax(0, auto) minmax(250px, 1fr);
align-items: start;
gap: 12px 16px;
}
.problem-list-toolbar :deep(.n-space) {
min-width: 0;
}
.problem-list-hitokoto {
justify-self: end;
width: 100%;
max-width: 720px;
min-width: 0;
}
@media (max-width: 768px) {
.problem-list-toolbar {
grid-template-columns: minmax(0, 1fr);
}
.problem-list-toolbar :deep(.n-space) {
width: 100%;
}
}
</style>