Some checks failed
Deploy / deploy (push) Has been cancelled
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
343 lines
9.1 KiB
Vue
343 lines
9.1 KiB
Vue
<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 不需要它跟着再来一遍 —— 请求带 cookie(axios 开了
|
||
// 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>
|