Files
OJ2/apps/web/src/oj/problem/list.vue
yuetsh 1729b9db4d
Some checks failed
Deploy / deploy (push) Has been cancelled
fix(题目列表): 排序下拉框不再多出一根滚动条
原来那行 :dropdown-style 是无效的 —— NSelect 没有这个 prop(只有
menu-props),写了会当普通属性掉到根节点上。真正卡高度的是 naive-ui
InternalSelectMenu 主题里的 height: calc(var(--n-option-height) * 7.6),
作用在菜单内部的 .n-scrollbar 上。8 条排序项 280px 超出 258.4px 半条,
逼出一根只能滚 22px 的滚动条。

改走 theme-overrides,按选项条数算高度,以后加减排序项自动跟着走。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy3L9zJHiSNFyNC3ZbMwdj
2026-09-03 20:13:01 -06:00

356 lines
9.7 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" },
]
// naive-ui 下拉菜单默认只给 7.6 个选项的高度InternalSelectMenu 主题里的
// height: calc(var(--n-option-height) * 7.6)8 条排序正好多出半条,逼出一根
// 只能滚两像素的滚动条。按实际条数放开,+8px 是菜单自己的上下内边距。
// 注意NSelect 没有 dropdown-style 这个 prop写了会当普通属性掉到根节点上
// 不起作用;控制高度只能走 peers.InternalSelectMenu。
const sortMenuTheme = {
peers: {
InternalSelectMenu: {
height: `calc(var(--n-option-height) * ${sortOptions.length} + 8px)`,
},
},
}
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"
:theme-overrides="sortMenuTheme"
/>
</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>