feat(前台): 登录后首页改成个人首页;题目详情页的代码要求挪到最前面、样例并排显示

- 登录后 / 是个人首页:继续学习、最近提交、进行中/即将开始的比赛、公告;
  没登录仍是题目列表,题目列表另有 /problem 地址,顶栏「题目」指向它
- 继续学习按服务端留痕取最近打开的一课,不用 localStorage(机房一机多人)
- 题目详情:标题下加难度与时空限制,代码要求提到最前,小节收紧,
  样例输入/输出并排;工具栏「提交信息」「更多操作」改为「本题提交」「更多」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 20:08:47 +08:00
1 parent d3823430f9
commit 31aa5f6e74
6 files changed
+731 -131

No files matched your search

+486
View File
@@ -0,0 +1,486 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import {
getAnnouncement,
getAnnouncementList,
getContestList,
getLearnProgress,
getSubmissions,
getTutorials,
getWeeklyRank,
} from "oj/api"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { ContestStatus, CONTEST_STATUS } from "utils/constants"
import { duration, parseTime, zonedParts } from "utils/functions"
import type { AnnouncementListItem, Contest, SubmissionListItem, WeeklyRank } from "utils/types"
type TutorialType = "python" | "c"
interface Track {
type: TutorialType
label: string
/** 接着学的那一课,1 起 */
step: number
title: string
total: number
/** 打开过的课数 */
read: number
lastViewedAt: string | null
}
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const tracks = ref<Track[]>([])
const contests = ref<Contest[]>([])
const submissions = ref<SubmissionListItem[]>([])
const announcements = ref<AnnouncementListItem[]>([])
const weekly = ref<WeeklyRank | null>(null)
const loaded = ref(false)
const keyword = ref("")
const greeting = computed(() => {
const hour = zonedParts(new Date())!.hour
if (hour < 5) return "夜深了"
if (hour < 11) return "早上好"
if (hour < 13) return "中午好"
if (hour < 18) return "下午好"
return "晚上好"
})
const weeklyText = computed(() => {
const me = weekly.value?.me
if (!weekly.value) return ""
const where = weekly.value.scope === "class" ? "班里" : "全站"
if (!me) return "本周还没有新通过的题,做出 1 题就能上进步榜"
return `本周新通过 ${me.solvedCount} 题,${where}第 ${me.rank} 名`
})
/**
* 接着学哪一课看的是**服务端留痕**里最近打开的那一课,不是导航栏用的
* localStorage:机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
*/
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
const [titles, progress] = await Promise.all([getTutorials(type), getLearnProgress(type)])
if (!titles.length) return null
const seen = progress.filter((p) => p.lastViewedAt)
const latest = seen.reduce<(typeof seen)[number] | null>(
(acc, p) => (!acc || p.lastViewedAt! > acc.lastViewedAt! ? p : acc),
null,
)
const index = latest ? titles.findIndex((t) => t.id === latest.tutorialId) : -1
const step = index >= 0 ? index + 1 : 1
return {
type,
label,
step,
title: titles[step - 1]!.title,
total: titles.length,
read: seen.length,
lastViewedAt: latest?.lastViewedAt ?? null,
}
}
async function loadTracks() {
const res = await Promise.all([loadTrack("python", "Python"), loadTrack("c", "C 语言")])
// 最近在学的那门排前面;两门都没碰过就按原顺序
tracks.value = res
.filter((t): t is Track => !!t)
.sort((a, b) => (b.lastViewedAt ?? "").localeCompare(a.lastViewedAt ?? ""))
}
async function loadContests() {
const query = { offset: 0, limit: 5, keyword: "", tag: "" }
const [underway, upcoming] = await Promise.all([
getContestList({ ...query, status: ContestStatus.underway }),
getContestList({ ...query, status: ContestStatus.not_started }),
])
contests.value = [...underway.results, ...upcoming.results].slice(0, 5)
}
async function loadSubmissions() {
const res = await getSubmissions({ myself: "1", offset: 0, limit: 6, language: "" })
submissions.value = res.results
}
async function loadAnnouncements() {
const res = await getAnnouncementList(0, 4)
announcements.value = res.results
}
async function loadWeekly() {
weekly.value = await getWeeklyRank(userStore.user?.className ? "class" : "global")
}
async function load() {
// 各块互不依赖,哪块挂了就空着那一块,别让一个接口拖垮整页
await Promise.allSettled([
loadTracks(),
loadContests(),
loadSubmissions(),
loadAnnouncements(),
loadWeekly(),
])
loaded.value = true
}
watch(
() => userStore.isFinished && userStore.isAuthed,
(ready) => ready && load(),
{ immediate: true },
)
function learnLink(track: Track) {
return `/learn/${track.type}/${track.step.toString().padStart(2, "0")}`
}
function searchProblem() {
const value = keyword.value.trim()
router.push({ path: "/problem", query: value ? { keyword: value } : {} })
}
function openSubmission(row: SubmissionListItem) {
if (row.showLink) router.push(`/submission/${row.id}`)
else router.push(`/problem/${row.problemDisplayId}`)
}
/** 刚刚 / 12 分钟前 / 今天 14:03 / 9月21日 */
function relativeTime(value: string) {
const diff = Date.now() - new Date(value).getTime()
if (diff < 60_000) return "刚刚"
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
const now = zonedParts(new Date())!
const then = zonedParts(value)!
if (now.year === then.year && now.month === then.month && now.day === then.day) {
return parseTime(value, "今天 HH:mm")
}
return parseTime(value, now.year === then.year ? "M月D日" : "YYYY年M月D日")
}
function contestTime(contest: Contest) {
if (contest.status === ContestStatus.underway) {
return `${parseTime(contest.endTime, "M月D日 HH:mm")} 结束`
}
return `${parseTime(contest.startTime, "M月D日 HH:mm")} 开始 · 时长 ${duration(contest.startTime, contest.endTime)}`
}
const announcementShow = ref(false)
const announcementTitle = ref("")
const announcementContent = ref("")
async function openAnnouncement(item: AnnouncementListItem) {
const res = await getAnnouncement(item.id)
announcementTitle.value = item.title
announcementContent.value = res.content
announcementShow.value = true
}
</script>
<template>
<div class="home">
<section class="hero">
<div>
<h1 class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
<n-text depth="3">
已解决 {{ userStore.profile?.acceptedNumber ?? 0 }} 题 · 共提交
{{ userStore.profile?.submissionNumber ?? 0 }} 次
<template v-if="weeklyText">
·
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
</template>
</n-text>
</div>
<n-input-group class="search">
<n-input
v-model:value="keyword"
placeholder="题号或题目名"
clearable
@keyup.enter="searchProblem"
/>
<n-button type="primary" @click="searchProblem">找题</n-button>
</n-input-group>
</section>
<div class="grid">
<div class="column">
<n-card title="继续学习" size="small" :bordered="false" class="card">
<n-flex vertical :size="12">
<router-link
v-for="track in tracks"
:key="track.type"
:to="learnLink(track)"
class="track"
>
<div class="track-main">
<n-text depth="3" class="track-label">
{{ track.label }} · 第 {{ track.step }} / {{ track.total }} 课
</n-text>
<div class="track-title">{{ track.title }}</div>
<n-progress
type="line"
:percentage="Math.round((track.read / track.total) * 100)"
status="success"
:show-indicator="false"
:height="6"
/>
<n-text depth="3" class="track-meta">
已打开 {{ track.read }} 课
<template v-if="track.lastViewedAt">
· 上次 {{ relativeTime(track.lastViewedAt) }}
</template>
</n-text>
</div>
<n-button :type="track.lastViewedAt ? 'primary' : 'default'" tag="span">
{{ track.lastViewedAt ? "继续" : "开始" }}
</n-button>
</router-link>
<n-empty v-if="loaded && !tracks.length" description="还没有公开的教程" />
</n-flex>
</n-card>
<n-card title="最近提交" size="small" :bordered="false" class="card">
<template #header-extra>
<router-link to="/submission?myself=1" class="more">全部</router-link>
</template>
<div
v-for="row in submissions"
:key="row.id"
class="row clickable"
@click="openSubmission(row)"
>
<SubmissionResultTag :result="row.result" size="small" />
<span class="row-title">{{ row.problemDisplayId }} {{ row.problemTitle }}</span>
<n-text depth="3" class="row-meta">{{ relativeTime(row.createTime) }}</n-text>
</div>
<n-empty v-if="loaded && !submissions.length" description="还没有提交过">
<template #extra>
<n-button size="small" @click="router.push('/problem')">去做题</n-button>
</template>
</n-empty>
</n-card>
</div>
<div class="column">
<n-card title="比赛" size="small" :bordered="false" class="card">
<template #header-extra>
<router-link to="/contest" class="more">全部</router-link>
</template>
<router-link
v-for="contest in contests"
:key="contest.id"
:to="`/contest/${contest.id}`"
class="row stacked"
>
<n-flex align="center" :size="8" :wrap="false">
<n-tag :type="CONTEST_STATUS[contest.status].type" size="small" :bordered="false">
{{ CONTEST_STATUS[contest.status].name }}
</n-tag>
<span class="row-title">{{ contest.title }}</span>
</n-flex>
<n-text depth="3" class="row-meta">{{ contestTime(contest) }}</n-text>
</router-link>
<n-text v-if="loaded && !contests.length" depth="3">
现在没有进行中或即将开始的比赛
</n-text>
</n-card>
<n-card title="公告" size="small" :bordered="false" class="card">
<template #header-extra>
<router-link to="/announcement" class="more">全部</router-link>
</template>
<div
v-for="item in announcements"
:key="item.id"
class="row clickable"
@click="openAnnouncement(item)"
>
<n-tag v-if="item.top" type="error" size="small" :bordered="false">置顶</n-tag>
<span class="row-title">{{ item.title }}</span>
<n-text depth="3" class="row-meta">{{ parseTime(item.createTime, "M月D日") }}</n-text>
</div>
</n-card>
<n-card size="small" :bordered="false" class="card">
<n-flex :size="8" class="shortcuts">
<n-button secondary @click="router.push('/problem')">
<template #icon><Icon icon="fluent-emoji:memo" /></template>
题库
</n-button>
<n-button secondary @click="router.push('/problemset')">
<template #icon><Icon icon="fluent-emoji:clipboard" /></template>
题单
</n-button>
<n-button secondary @click="router.push('/achievement')">
<template #icon><Icon icon="fluent-emoji:sports-medal" /></template>
成就
</n-button>
<n-button secondary @click="router.push('/ai-analysis')">
<template #icon><Icon icon="fluent-emoji:crystal-ball" /></template>
智能分析
</n-button>
</n-flex>
</n-card>
</div>
</div>
<n-modal
v-model:show="announcementShow"
preset="card"
:style="{ maxWidth: isDesktop ? '70vw' : undefined, maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="announcementTitle"
>
<div v-html="announcementContent"></div>
</n-modal>
</div>
</template>
<style scoped>
.home {
max-width: 1200px;
margin: 0 auto;
}
.hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin: 8px 0 20px;
}
.hello {
font-size: 24px;
font-weight: 600;
margin: 0 0 6px;
}
.search {
width: 320px;
max-width: 100%;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 16px;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
.column {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.card {
border-radius: 8px;
background-color: var(--n-color-embedded, rgba(128, 128, 128, 0.06));
}
.track {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
border-radius: 6px;
color: inherit;
text-decoration: none;
background-color: rgba(128, 128, 128, 0.06);
transition: background-color 0.2s;
}
.track:hover {
background-color: rgba(24, 160, 88, 0.1);
}
.track-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.track-label,
.track-meta,
.row-meta {
font-size: 12px;
}
.track-title {
font-size: 16px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
border-radius: 4px;
color: inherit;
text-decoration: none;
}
.row + .row {
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.row.stacked {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.clickable,
a.row {
cursor: pointer;
}
.clickable:hover,
a.row:hover {
background-color: rgba(128, 128, 128, 0.08);
}
.row-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-meta {
flex-shrink: 0;
}
.more,
.plain-link {
color: inherit;
text-decoration: none;
}
.more {
font-size: 13px;
opacity: 0.7;
}
.more:hover,
.plain-link:hover {
color: #18a058;
}
</style>