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>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { useUserStore } from "shared/store/user"
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
const Dashboard = defineAsyncComponent(() => import("./dashboard.vue"))
const ProblemList = defineAsyncComponent(() => import("oj/problem/list.vue"))
const userStore = useUserStore()
// profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是
// 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表
const showDashboard = computed(() =>
userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED),
)
</script>
<template>
<Dashboard v-if="showDashboard" />
<ProblemList v-else />
</template>
@@ -76,7 +76,7 @@ const showCollabBar = computed(() => collabHere.value && userStore.isTeacherOrAb
/**
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
* (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。
* (语言 / 提交 / 本题提交 / 课堂统计 / 更多 / 求助)在 1280 的机房屏上放不下。
*/
const helpButtonText = computed(() => {
if (collabStore.helpStatus === "active") {
@@ -122,7 +122,7 @@ const menuOptions = computed<DropdownOption[]>(() => {
if (!isDesktop.value) {
if (showGoSubmissionButton.value) {
options.push({
label: "提交信息",
label: "本题提交",
key: "submissions",
})
}
@@ -272,7 +272,7 @@ onMounted(() => {
<SubmitCode v-else />
<n-button v-if="isDesktop && showGoSubmissionButton" :size="buttonSize" @click="goSubmissions">
提交信息
本题提交
</n-button>
<n-button
@@ -283,14 +283,14 @@ onMounted(() => {
课堂统计
</n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上提交信息 / 课堂统计 -->
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上本题提交 / 课堂统计 -->
<n-dropdown
v-if="menuOptions.length"
trigger="click"
:options="menuOptions"
@select="handleMenuSelect"
>
<n-button :size="buttonSize">更多操作</n-button>
<n-button :size="buttonSize">更多</n-button>
</n-dropdown>
<template v-if="showCollabBar">
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY } from "utils/constants"
import { getTagColor } from "utils/functions"
import { createTestSubmission } from "utils/judge"
import type { ProblemDetail, ProblemRow, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue"
@@ -20,8 +21,6 @@ type Sample = ProblemDetail["samples"][number] & {
loading: boolean
}
const theme = useThemeVars()
const style = computed(() => "color: " + theme.value.primaryColor)
const isDark = useDark()
const route = useRoute()
const codeStore = useCodeStore()
@@ -190,16 +189,47 @@ function type(status: ProblemStatus) {
</n-alert>
</template>
<n-flex align="center">
<n-tag>{{ problem._id }}</n-tag>
<h2 class="problemTitle">{{ problem.title }}</h2>
</n-flex>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:checklist"></Icon>
描述
<header class="problem-head">
<n-flex align="center" :size="10" :wrap="false">
<n-tag :bordered="false">{{ problem._id }}</n-tag>
<h2 class="problemTitle">{{ problem.title }}</h2>
</n-flex>
</p>
<n-flex align="center" :size="8" class="problem-meta">
<n-tag
v-if="problem.difficulty"
size="small"
:bordered="false"
:type="getTagColor(problem.difficulty)"
>
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>
<n-text depth="3" v-if="!isSQL">
时间限制 {{ problem.timeLimit }} ms · 内存限制 {{ problem.memoryLimit }} MB
</n-text>
</n-flex>
</header>
<!-- 代码要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 -->
<div v-if="astRequirements.length > 0" class="requirements">
<n-flex align="center" :size="6" class="requirements-head">
<Icon icon="streamline-ultimate-color:check-button" :width="18"></Icon>
<span>代码要求</span>
<n-text depth="3" class="requirements-note">提交时会逐条检查</n-text>
</n-flex>
<div v-for="[lang, rules] in astRequirements" :key="lang" class="requirements-lang">
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span>
<n-flex :size="6">
<n-tag v-for="(rule, i) in rules" :key="i" :type="KIND_TAG_TYPE[rule.kind]" size="small">
{{ rule.description }}
</n-tag>
</n-flex>
</div>
</div>
<h3 class="title">
<Icon icon="streamline-ultimate-color:checklist"></Icon>
描述
</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.description"
@@ -207,24 +237,20 @@ function type(status: ProblemStatus) {
/>
<template v-if="!isSQL">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
输入
</n-flex>
</p>
<h3 class="title">
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
输入
</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.inputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
输出
</n-flex>
</p>
<h3 class="title">
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
输出
</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.outputDescription"
@@ -232,13 +258,51 @@ function type(status: ProblemStatus) {
/>
</template>
<template v-if="isSQL && sqlDisplay">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="devicon:sqlite"></Icon>
数据表
<template v-if="!isSQL">
<section v-for="(sample, index) of samples" :key="index" class="sample">
<n-flex align="center" justify="space-between" class="sample-head">
<h3 class="title">
<Icon icon="streamline-emojis:microscope"></Icon>
例子 {{ index + 1 }}
</h3>
<n-button
size="small"
secondary
:type="type(sample.status) || 'default'"
:loading="sample.loading"
@click="test(sample, index)"
>
{{ label(sample.status, sample.loading) }}
</n-button>
</n-flex>
</p>
<div class="sample-grid">
<div class="sample-box">
<n-flex align="center" justify="space-between" class="sample-label">
<span>输入</span>
<Copy :value="sample.input" />
</n-flex>
<pre class="testcase">{{ sample.input }}</pre>
</div>
<div class="sample-box">
<n-flex align="center" justify="space-between" class="sample-label">
<span>输出</span>
<Copy :value="sample.output" />
</n-flex>
<pre class="testcase">{{ sample.output }}</pre>
</div>
</div>
<div v-if="sample.msg" class="sample-box sample-result">
<div class="sample-label">运行结果</div>
<pre class="testcase">{{ sample.msg }}</pre>
</div>
</section>
</template>
<template v-if="isSQL && sqlDisplay">
<h3 class="title">
<Icon icon="devicon:sqlite"></Icon>
数据表
</h3>
<div v-for="t in sqlDisplay.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
@@ -249,12 +313,10 @@ function type(status: ProblemStatus) {
/>
</div>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
期望结果
</n-flex>
</p>
<h3 class="title">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
期望结果
</h3>
<template v-if="sqlExpectedQuery">
<SQLDataTable
:columns="sqlExpectedQuery.columns"
@@ -279,12 +341,10 @@ function type(status: ProblemStatus) {
</template>
<div v-if="problem.hint">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:man-tipping-hand-1"></Icon>
提示
</n-flex>
</p>
<h3 class="title">
<Icon icon="streamline-emojis:man-tipping-hand-1"></Icon>
提示
</h3>
<MdPreview
preview-theme="preview"
:model-value="problem.hint"
@@ -292,72 +352,11 @@ function type(status: ProblemStatus) {
/>
</div>
<!-- 代码要求(AST 规则) -->
<div v-if="astRequirements.length > 0">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
要求
</n-flex>
</p>
<div v-for="[lang, rules] in astRequirements" :key="lang">
<p v-if="astRequirements.length > 1" class="lang-label">
{{ lang }}
</p>
<n-list bordered style="margin-bottom: 8px">
<n-list-item v-for="(rule, i) in rules" :key="i">
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag>
</n-list-item>
</n-list>
</div>
</div>
<template v-if="!isSQL">
<div v-for="(sample, index) of samples" :key="index">
<n-flex align="center">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:microscope"></Icon>
例子 {{ index + 1 }}
</n-flex>
</p>
<n-button size="small" :type="type(sample.status)" @click="test(sample, index)">
{{ label(sample.status, sample.loading) }}
</n-button>
</n-flex>
<n-descriptions bordered :column="2" label-style="width: 50%; min-width: 100px">
<n-descriptions-item>
<template #label>
<n-flex>
<span>输入</span>
<Copy :value="sample.input" />
</n-flex>
</template>
<div class="testcase">{{ sample.input }}</div>
</n-descriptions-item>
<n-descriptions-item>
<template #label>
<n-flex>
<span>输出</span>
<Copy :value="sample.output" />
</n-flex>
</template>
<div class="testcase">{{ sample.output }}</div>
</n-descriptions-item>
<n-descriptions-item label="运行结果" v-if="sample.msg">
<div class="testcase">{{ sample.msg }}</div>
</n-descriptions-item>
</n-descriptions>
</div>
</template>
<div v-if="problem.source">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon>
来源
</n-flex>
</p>
<h3 class="title">
<Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon>
来源
</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.source"
@@ -368,12 +367,10 @@ function type(status: ProblemStatus) {
<!-- 相似题目推荐 -->
<div v-if="similarProblems.length > 0">
<n-divider />
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:like"></Icon>
相似题目推荐
</n-flex>
</p>
<h3 class="title">
<Icon icon="streamline-ultimate-color:like"></Icon>
相似题目推荐
</h3>
<n-list bordered>
<n-list-item v-for="sp in similarProblems" :key="sp._id">
<n-flex align="center" justify="space-between">
@@ -414,28 +411,121 @@ function type(status: ProblemStatus) {
</template>
<style scoped>
.problem-head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.problemTitle {
margin: 0;
font-size: 22px;
line-height: 1.3;
}
.problem-meta {
font-size: 13px;
}
.title {
font-size: 20px;
margin: 12px 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
margin: 20px 0 4px;
}
.testcase {
font-size: 14px;
white-space: pre;
font-family: "Monaco";
/* md-editor 的预览自带一圈 padding 和段落外边距,放在小节标题下面显得很散 */
:deep(.md-editor-preview-wrapper) {
padding: 0;
}
.status-alert {
margin-bottom: 16px;
:deep(.md-editor-preview > :first-child) {
margin-top: 0;
}
:deep(.md-editor-preview > :last-child) {
margin-bottom: 0;
}
.requirements {
padding: 10px 12px;
border-radius: 6px;
border: 1px solid rgba(24, 160, 88, 0.3);
background-color: rgba(24, 160, 88, 0.06);
display: flex;
flex-direction: column;
gap: 8px;
}
.requirements-head {
font-weight: 600;
}
.requirements-note {
font-size: 12px;
font-weight: normal;
}
.requirements-lang {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.lang-label {
font-weight: 600;
margin: 8px 0 4px;
font-size: 13px;
}
.sample {
margin-top: 20px;
}
.sample-head {
margin-bottom: 8px;
}
.sample-head .title {
margin: 0;
}
.sample-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 8px;
}
.sample-box {
min-width: 0;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
padding: 6px 10px 10px;
}
.sample-result {
margin-top: 8px;
}
.sample-label {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
}
.testcase {
margin: 0;
font-size: 14px;
white-space: pre;
overflow-x: auto;
font-family: Monaco, Consolas, monospace;
}
.status-alert {
margin-bottom: 16px;
}
.sqlTableName {
+3 -1
View File
@@ -4,7 +4,9 @@ export const ojs: RouteRecordRaw = {
path: "/",
component: () => import("shared/layout/default.vue"),
children: [
{ path: "", component: () => import("oj/problem/list.vue") },
// 登录后是个人首页,没登录还是题目列表(见 oj/home/index.vue)
{ path: "", component: () => import("oj/home/index.vue"), name: "home" },
{ path: "problem", component: () => import("oj/problem/list.vue"), name: "problems" },
{
path: "problem/:problemID",
component: () => import("oj/problem/detail.vue"),
+3 -3
View File
@@ -83,8 +83,8 @@ const titleTags = computed(() =>
)
// 一级路径就是菜单 key,对不上的页面(/user、/setting、/achievement 等)
// 自然没有一项亮着
const active = computed(() => route.path.split("/")[1] || "problem")
// 自然没有一项亮着。根路径没登录时就是题目列表,登录了是个人首页,不亮任何一项
const active = computed(() => route.path.split("/")[1] || (userStore.isAuthed ? "" : "problem"))
async function handleLogout() {
await userStore.signOut()
@@ -127,7 +127,7 @@ const menus = computed<MenuOption[]>(() => [
],
},
{
label: () => h(RouterLink, { to: "/" }, { default: () => "题目" }),
label: () => h(RouterLink, { to: "/problem" }, { default: () => "题目" }),
key: "problem",
icon: renderIcon("fluent-emoji:memo"),
},