feat(前台): 题单卡片显示完成进度,手机上题目列表不再横向溢出,个人主页数据排得更紧凑

- 题单卡片:完成进度条和「已完成 x / y 题」,「已完成」改成绿色,未获得的徽章置灰;
  进度按加入时的快照算,题单后来加了题就注明「新加了 N 题」,不再出现「已完成」却是 5 / 6
- 题目列表:手机上只留状态、编号、题目、难度四列;标签默认收起,选中的标签显示在按钮旁
- 个人主页:六项数据保留,改成三列两行的小卡片,头像缩小

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 20:27:00 +08:00
1 parent 6bf8c6e1d9
commit e1b6a9e9fa
3 files changed
+254 -142

No files matched your search

+24 -5
View File
@@ -63,12 +63,11 @@ const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { isDesktop } = useBreakpoints() const { isDesktop, isMobile } = useBreakpoints()
const problems = ref<ProblemRow[]>([]) const problems = ref<ProblemRow[]>([])
const total = ref(0) const total = ref(0)
const tags = ref<Tag[]>([]) const tags = ref<Tag[]>([])
const [showTag, toggleShowTag] = useToggle(isDesktop.value)
// 使用分页 composable // 使用分页 composable
const { query, clearQuery } = usePagination<ProblemQuery>({ const { query, clearQuery } = usePagination<ProblemQuery>({
@@ -79,6 +78,10 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
sort: useRouteQuery("sort", "").value, sort: useRouteQuery("sort", "").value,
}) })
// 标签默认收起:四十多个标签在桌面上要占三行,把题目表格顶到半屏以下。
// 从带 ?tag= 的链接进来时展开,让人看得见是哪个标签在起作用
const [showTag, toggleShowTag] = useToggle(!!query.tag)
async function listProblems() { async function listProblems() {
if (query.page < 1) query.page = 1 if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
@@ -208,8 +211,21 @@ const baseColumns: DataTableColumn<ProblemRow>[] = [
}, },
] ]
// 手机上只留判断「做不做这题」要的几列,其余的挤进 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(() => const columns = computed(() =>
userStore.isAuthed ? baseColumns : baseColumns.filter((c: any) => c.key !== "status"), 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) { function rowProps(row: ProblemRow) {
@@ -263,12 +279,15 @@ function rowProps(row: ProblemRow) {
<n-form-item> <n-form-item>
<n-button @click="toggleShowTag()" quaternary icon-placement="right"> <n-button @click="toggleShowTag()" quaternary icon-placement="right">
<template #icon> <template #icon>
<Icon v-if="showTag" icon="ph:caret-down"></Icon> <Icon v-if="showTag" icon="ph:caret-up"></Icon>
<Icon v-else icon="ph:caret-up"></Icon> <Icon v-else icon="ph:caret-down"></Icon>
</template> </template>
标签 标签
</n-button> </n-button>
</n-form-item> </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-form>
</n-space> </n-space>
<Hitokoto v-if="isDesktop" class="problem-list-hitokoto" /> <Hitokoto v-if="isDesktop" class="problem-list-hitokoto" />
+156 -80
View File
@@ -1,15 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import { getProblemSetList } from "../api" import { getProblemSetList } from "../api"
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types" import type { ProblemSet } from "utils/types"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { useBreakpoints } from "shared/composables/breakpoints"
const router = useRouter() const router = useRouter()
const { isDesktop } = useBreakpoints()
const total = ref(0) const total = ref(0)
const problemSets = ref<ProblemSet[]>([]) const problemSets = ref<ProblemSet[]>([])
@@ -61,12 +57,6 @@ function getConditionText(conditionType: string, conditionValue: number): string
return conditionMap[conditionType] || "未知条件" return conditionMap[conditionType] || "未知条件"
} }
function getProgressColor(percentage: number) {
if (percentage >= 80) return "#18a058" // 绿色
if (percentage >= 50) return "#f0a020" // 橙色
return "#d03050" // 红色
}
onMounted(listProblemSets) onMounted(listProblemSets)
// 监听搜索关键词变化(防抖) // 监听搜索关键词变化(防抖)
@@ -94,79 +84,99 @@ watch(() => [query.page, query.limit], listProblemSets)
/> />
</n-space> </n-space>
<n-grid v-if="problemSets.length > 0" :cols="isDesktop ? 3 : 1" :x-gap="16" :y-gap="16"> <div v-if="problemSets.length > 0" class="set-grid">
<n-grid-item v-for="problemSet in problemSets" :key="problemSet.id"> <div
<n-card hoverable @click="goToProblemSet(problemSet.id)" style="cursor: pointer"> v-for="problemSet in problemSets"
<template #header> :key="problemSet.id"
<n-flex justify="space-between" align="center"> class="set-card"
<n-text strong>{{ problemSet.title }}</n-text> :class="{ completed: problemSet.userProgress?.isCompleted }"
<n-tag :type="getDifficultyTag(problemSet.difficulty).type"> @click="goToProblemSet(problemSet.id)"
{{ getDifficultyTag(problemSet.difficulty).text }} >
</n-tag> <n-flex justify="space-between" align="center" :wrap="false" :size="8">
</n-flex> <span class="set-title">{{ problemSet.title }}</span>
</template> <n-tag v-if="problemSet.userProgress?.isCompleted" type="success" size="small" round>
<n-flex vertical size="large"> 已完成
<n-flex justify="space-between" align="center"> </n-tag>
<n-flex> <n-tag v-else-if="problemSet.userProgress?.isJoined" type="info" size="small" round>
<Icon width="20" icon="streamline-emojis:blossom" /> 进行中
<n-text>{{ problemSet.problemsCount }} 道题目</n-text> </n-tag>
</n-flex> </n-flex>
<n-flex align="center" style="height: 28px"> <!-- 简介大多就是把标题再抄一遍,一样的就不重复显示 -->
<!-- 用户进度显示 --> <n-text
<n-progress v-if="problemSet.description && problemSet.description !== problemSet.title"
v-if="problemSet.userProgress?.isJoined && !problemSet.userProgress?.isCompleted" depth="3"
type="line" class="set-desc"
:percentage="Math.round(problemSet.userProgress.progressPercentage)" >
:height="4" {{ problemSet.description }}
:border-radius="2" </n-text>
style="width: 100px"
:color="getProgressColor(problemSet.userProgress.progressPercentage)"
/>
<n-tag type="warning" v-if="problemSet.status === 'archived'"> 已归档 </n-tag>
<n-tag
v-if="problemSet.userProgress?.isJoined && !problemSet.userProgress?.isCompleted"
type="warning"
>
已加入
</n-tag>
<n-tag v-if="problemSet.userProgress?.isCompleted" type="error"> 已完成 </n-tag>
</n-flex>
</n-flex>
<!-- 奖章显示 --> <div class="set-progress">
<n-flex align="center" justify="space-between"> <n-flex justify="space-between" align="center" class="set-progress-text">
<n-text depth="3"> <n-text depth="3">
创建于 <!-- 进度是加入/完成时存下的快照,分母用快照自己的 totalCount:题单后来
{{ parseTime(problemSet.createTime, "YYYY-MM-DD") }} 加了题的话,拿现在的 problemsCount 去除,就会出现「已完成」却是 5 / 6 -->
</n-text> <template v-if="problemSet.userProgress?.isJoined">
<n-flex> 已完成 {{ problemSet.userProgress.completedCount }} /
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover"> {{ problemSet.userProgress.totalCount }} 题
<template #trigger> <template v-if="problemSet.problemsCount > problemSet.userProgress.totalCount">
<n-image · 新加了
:src="badge.icon" {{ problemSet.problemsCount - problemSet.userProgress.totalCount }} 题
:alt="badge.name" </template>
width="24" </template>
height="24" <template v-else>共 {{ problemSet.problemsCount }} 题 · 还没开始</template>
object-fit="cover" </n-text>
:class="{ 'earned-badge': badge.isEarned }" <!-- 线上题单全是简单难度,只有不是简单时才值得标出来 -->
/> <n-tag
</template> v-if="problemSet.difficulty !== 'Easy'"
<n-flex vertical size="small"> :type="getDifficultyTag(problemSet.difficulty).type"
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span> size="small"
<span> :bordered="false"
获取条件: >
{{ getConditionText(badge.conditionType, badge.conditionValue) }} {{ getDifficultyTag(problemSet.difficulty).text }}
</span> </n-tag>
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text> <n-tag v-if="problemSet.status === 'archived'" size="small" :bordered="false">
</n-flex> 已归档
</n-tooltip> </n-tag>
</n-flex>
</n-flex>
</n-flex> </n-flex>
</n-card> <n-progress
</n-grid-item> type="line"
</n-grid> :percentage="Math.round(problemSet.userProgress?.progressPercentage ?? 0)"
:show-indicator="false"
:height="6"
status="success"
/>
</div>
<n-flex v-if="problemSet.badges?.length" align="center" :size="6" class="set-badges">
<n-text depth="3" class="set-badges-label">
徽章 {{ problemSet.badges.filter((b) => b.isEarned).length }} /
{{ problemSet.badges.length }}
</n-text>
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover">
<template #trigger>
<n-image
:src="badge.icon"
:alt="badge.name"
width="24"
height="24"
object-fit="cover"
preview-disabled
:class="badge.isEarned ? 'earned-badge' : 'locked-badge'"
/>
</template>
<n-flex vertical size="small">
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span>
<span>
获取条件:
{{ getConditionText(badge.conditionType, badge.conditionValue) }}
</span>
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text>
</n-flex>
</n-tooltip>
</n-flex>
</div>
</div>
<Pagination <Pagination
v-if="problemSets.length > 0" v-if="problemSets.length > 0"
@@ -179,9 +189,75 @@ watch(() => [query.page, query.limit], listProblemSets)
</template> </template>
<style scoped> <style scoped>
.set-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.set-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.2);
cursor: pointer;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.set-card:hover {
border-color: #18a058;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.set-card.completed {
background-color: rgba(24, 160, 88, 0.05);
}
.set-title {
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.set-desc {
font-size: 13px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.set-progress {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: auto;
}
.set-progress-text {
font-size: 13px;
}
.set-badges-label {
font-size: 12px;
margin-right: 2px;
}
.earned-badge { .earned-badge {
border: 2px solid #ffd700; border: 2px solid #ffd700;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4); box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
} }
/* 没拿到的徽章压成灰色:原来拿没拿到只差一圈金边,一排看下去分不清 */
.locked-badge {
filter: grayscale(1);
opacity: 0.45;
}
</style> </style>
+74 -57
View File
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api" import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions" import { durationToDays, parseTime } from "utils/functions"
import type { AchievementSummary, Profile } from "utils/types" import type { AchievementSummary, Profile } from "utils/types"
import { getAchievementSummary } from "oj/achievement/api" import { getAchievementSummary } from "oj/achievement/api"
@@ -23,8 +21,6 @@ const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle() const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false) const [show, toggleShow] = useToggle(false)
const { isDesktop } = useBreakpoints()
const isDefaultAvatar = computed(() => profile.value?.avatar.endsWith("default.png") ?? true) const isDefaultAvatar = computed(() => profile.value?.avatar.endsWith("default.png") ?? true)
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef") const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
@@ -95,41 +91,45 @@ async function loadAchievementSummary() {
} }
} }
// 六项一格一项、三列两行。日期比数字长得多,字号单独压小一档(.stat-value.date),
// 不然「2026年9月14日」会把格子撑破
const metrics = computed(() => { const metrics = computed(() => {
if (loading.value) return [] if (loading.value) return []
return [ return [
{
icon: "fluent-emoji:face-with-peeking-eye",
title: learnDuration.value,
content: "总共学习天数",
},
{
icon: "fluent-emoji:cheese-wedge",
title: toLatestAt.value,
content: "距离上次提交",
},
{
icon: "fluent-emoji:dog-face",
title: latestSubmissionAt.value,
content: "最新一次提交时间",
},
{
icon: "fluent-emoji:cat-with-wry-smile",
title: firstSubmissionAt.value,
content: "第一次提交时间",
},
{ {
icon: "fluent-emoji:candy", icon: "fluent-emoji:candy",
title: profile.value?.acceptedNumber ?? 0, title: profile.value?.acceptedNumber ?? 0,
content: "已解决的题目数量", content: "已解决",
animate: true, animate: true,
}, },
{ {
icon: "fluent-emoji:thinking-face", icon: "fluent-emoji:thinking-face",
title: profile.value?.submissionNumber ?? 0, title: profile.value?.submissionNumber ?? 0,
content: "总提交数量", content: "总提交",
animate: true, animate: true,
}, },
{
icon: "fluent-emoji:face-with-peeking-eye",
title: learnDuration.value,
content: "学习天数",
},
{
icon: "fluent-emoji:cheese-wedge",
title: toLatestAt.value,
content: "距上次提交",
},
{
icon: "fluent-emoji:dog-face",
title: latestSubmissionAt.value,
content: "最新一次提交时间",
date: true,
},
{
icon: "fluent-emoji:cat-with-wry-smile",
title: firstSubmissionAt.value,
content: "第一次提交时间",
date: true,
},
] ]
}) })
@@ -141,8 +141,8 @@ onMounted(() => {
<template> <template>
<n-flex class="wrapper" vertical justify="center" align="center" v-if="!loading && profile"> <n-flex class="wrapper" vertical justify="center" align="center" v-if="!loading && profile">
<n-image <n-image
:width="140" :width="96"
:height="140" :height="96"
:src="profile.avatar" :src="profile.avatar"
:preview-disabled="isDefaultAvatar" :preview-disabled="isDefaultAvatar"
object-fit="cover" object-fit="cover"
@@ -169,30 +169,18 @@ onMounted(() => {
</n-button> </n-button>
</n-flex> </n-flex>
<n-grid <div v-if="profile && profile.submissionNumber > 0" class="wrapper">
v-if="profile && profile.submissionNumber > 0" <div class="stats">
class="wrapper" <div v-for="item in metrics" :key="item.content" class="stat">
:cols="isDesktop ? 2 : 1" <Icon :icon="item.icon" :width="28" />
:x-gap="10" <div class="stat-value" :class="{ date: item.date }">
:y-gap="10" <n-number-animation v-if="item.animate" :to="Number(item.title)" />
> <template v-else>{{ item.title }}</template>
<n-gi v-for="item in metrics" :key="item.content"> </div>
<n-card hoverable> <n-text depth="3" class="stat-label">{{ item.content }}</n-text>
<n-flex align="center"> </div>
<Icon :icon="item.icon" :width="isDesktop ? 50 : 40" /> </div>
<div> </div>
<Component :is="isDesktop ? NH2 : NH3" class="number">
<n-number-animation v-if="item.animate" :to="item.title" />
<template v-else>
{{ item.title }}
</template>
</Component>
<n-h4 class="number-label">{{ item.content }}</n-h4>
</div>
</n-flex>
</n-card>
</n-gi>
</n-grid>
<!-- 成就摘要 --> <!-- 成就摘要 -->
<n-card v-if="!loading && profile && achievementSummary" class="wrapper" hoverable> <n-card v-if="!loading && profile && achievementSummary" class="wrapper" hoverable>
@@ -262,13 +250,42 @@ onMounted(() => {
margin: 16px auto 0; margin: 16px auto 0;
} }
.number { .stats {
margin-bottom: 0; display: grid;
font-weight: bold; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
} }
.number-label { @media (max-width: 600px) {
margin: 0; .stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 8px;
border-radius: 8px;
border: 1px solid rgba(128, 128, 128, 0.2);
}
.stat-value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
}
.stat-value.date {
font-size: 16px;
padding: 3px 0;
white-space: nowrap;
} }
h2 { h2 {