Files
OJ2/apps/web/src/oj/problem/list.vue
T
xuyueandClaude Opus 5.5 e1b6a9e9fa feat(前台): 题单卡片显示完成进度,手机上题目列表不再横向溢出,个人主页数据排得更紧凑
- 题单卡片:完成进度条和「已完成 x / y 题」,「已完成」改成绿色,未获得的徽章置灰;
  进度按加入时的快照算,题单后来加了题就注明「新加了 N 题」,不再出现「已完成」却是 5 / 6
- 题目列表:手机上只留状态、编号、题目、难度四列;标签默认收起,选中的标签显示在按钮旁
- 个人主页:六项数据保留,改成三列两行的小卡片,头像缩小

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:27:00 +08:00

343 lines
9.8 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 { ProblemRow, 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 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, isMobile } = useBreakpoints()
const problems = ref<ProblemRow[]>([])
const total = ref(0)
const tags = ref<Tag[]>([])
// 使用分页 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,
})
// 标签默认收起:四十多个标签在桌面上要占三行,把题目表格顶到半屏以下。
// 从带 ?tag= 的链接进来时展开,让人看得见是哪个标签在起作用
const [showTag, toggleShowTag] = useToggle(!!query.tag)
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<ProblemRow>[] = [
{
title: "状态",
key: "status",
width: 80,
align: "center",
render: (row) => h(ProblemStatus, { status: row.status }),
},
{
title: "编号",
key: "_id",
width: 100,
},
{
title: "题目",
key: "title",
minWidth: 200,
render: (row) => h(ProblemListTitle, { problem: row }),
},
{
title: "难度",
key: "difficulty",
width: 100,
render: (row) =>
row.difficulty ? h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty) : null,
},
{
title: "标签",
key: "tags",
width: 260,
render: (row) => h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
},
{
title: "出题者",
key: "author",
width: 130,
},
{
title: "提交数",
key: "submissionCount",
align: "center",
width: 100,
},
{
title: "通过率",
key: "acRate",
width: 100,
align: "center",
},
]
// 手机上只留判断「做不做这题」要的几列,其余的挤进 390 宽的屏幕只会横向溢出
const MOBILE_WIDTHS: Record<string, { width?: number; minWidth?: number }> = {
status: { width: 60 },
_id: { width: 72 },
title: { minWidth: 120 },
difficulty: { width: 72 },
}
const columns = computed(() =>
baseColumns
.filter((c: any) => {
if (c.key === "status" && !userStore.isAuthed) return false
return !isMobile.value || c.key in MOBILE_WIDTHS
})
.map((c: any) => (isMobile.value ? { ...c, ...MOBILE_WIDTHS[c.key] } : c)),
)
function rowProps(row: ProblemRow) {
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-up"></Icon>
<Icon v-else icon="ph:caret-down"></Icon>
</template>
标签
</n-button>
</n-form-item>
<n-form-item v-if="query.tag && !showTag">
<n-tag type="success" closable @close="query.tag = ''">{{ query.tag }}</n-tag>
</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>