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

@@ -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 {