refactor: 移动端去除layout顶栏,页面头统一收敛到AppPageHeader

This commit is contained in:
liyy 2026-09-02 15:57:20 +08:00
parent fbf134ccc6
commit bf80cf15b6
13 changed files with 113 additions and 163 deletions

View File

@ -1,21 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
withDefaults( import { useRouter } from 'vue-router'
import AppIcon from './AppIcon.vue'
const props = withDefaults(
defineProps<{ defineProps<{
title: string title?: string
subtitle?: string subtitle?: string
/** page一级页头h1+副标题+右段动作detail详情/子页返回头(紧凑标题,默认带返回) */
size?: 'page' | 'detail'
/** 是否渲染左段返回按钮 */
back?: boolean
/** 返回目标;缺省时使用 history.back() */
backTo?: string
/** 移动端是否纵向堆叠(部分页面移动版按钮在标题下方) */ /** 移动端是否纵向堆叠(部分页面移动版按钮在标题下方) */
stackMobile?: boolean stackMobile?: boolean
/** 移动端吸顶(替代原 layout topbar 的吸顶行为;仅移动端生效) */
stickyMobile?: boolean
}>(), }>(),
{ subtitle: '', stackMobile: false } { title: '', subtitle: '', size: 'page', back: false, backTo: '', stackMobile: false, stickyMobile: false }
) )
const router = useRouter()
function goBack() {
if (props.backTo) router.push(props.backTo)
else router.back()
}
</script> </script>
<template> <template>
<header class="page-head" :class="{ 'page-head--stack': stackMobile }"> <header
class="page-head"
:class="{
'page-head--stack': stackMobile,
'page-head--detail': size === 'detail',
'page-head--sticky-mobile': stickyMobile
}"
>
<button
v-if="back"
type="button"
class="page-head__back"
aria-label="返回"
@click="goBack"
>
<AppIcon name="chevron-left" :size="20" />
</button>
<div class="page-head__title"> <div class="page-head__title">
<h1>{{ title }}</h1> <slot name="title">
<p v-if="subtitle">{{ subtitle }}</p> <h1 v-if="title">{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
</slot>
</div> </div>
<div v-if="$slots.default" class="page-head__actions"><slot /></div> <div v-if="$slots.default" class="page-head__actions"><slot /></div>
</header> </header>
</template> </template>
@ -49,9 +87,53 @@ withDefaults(
gap: var(--space-3); gap: var(--space-3);
flex: none; flex: none;
} }
/* 返回按钮(左段) */
.page-head__back {
display: grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--motion-hover-fast) var(--ease-default);
}
.page-head__back:hover {
background: var(--bg-soft);
}
/* 详情/子页返回头:紧凑行内布局 */
.page-head--detail {
align-items: center;
gap: var(--space-3);
}
.page-head--detail .page-head__title {
display: flex;
align-items: center;
gap: var(--space-3);
}
.page-head--detail h1 {
font-size: var(--fs-h3);
font-weight: var(--fw-h1);
}
@media (max-width: 767px) { @media (max-width: 767px) {
.page-head--stack { .page-head--stack {
flex-direction: column; flex-direction: column;
} }
/* 吸顶(替代原 layout topbar负边距让背景铺满屏宽 */
.page-head--sticky-mobile {
position: sticky;
top: 0;
z-index: 30;
margin-left: calc(-1 * var(--padding-mobile));
margin-right: calc(-1 * var(--padding-mobile));
padding: var(--space-2) var(--padding-mobile);
background: var(--bg-canvas);
}
} }
</style> </style>

View File

@ -1,22 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import AppIcon from '../components/base/AppIcon.vue' import AppIcon from '../components/base/AppIcon.vue'
import { mobileNav } from '../router/nav' import { mobileNav } from '../router/nav'
const route = useRoute() const route = useRoute()
const title = computed(() => String(route.meta.title ?? '备考通'))
//
const isHome = computed(() => route.path === '/')
// AppPageHeader
const hideTopbar = computed(() => isHome.value || route.path.startsWith('/practice'))
</script> </script>
<template> <template>
<div class="layout-mobile"> <div class="layout-mobile">
<header v-if="!hideTopbar" class="topbar">
<h1>{{ title }}</h1>
</header>
<main class="content"><slot /></main> <main class="content"><slot /></main>
<nav class="tabbar"> <nav class="tabbar">
<RouterLink <RouterLink
@ -37,22 +28,6 @@ const hideTopbar = computed(() => isHome.value || route.path.startsWith('/practi
.layout-mobile { .layout-mobile {
min-height: 100vh; min-height: 100vh;
} }
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
height: 56px;
padding: 0 var(--padding-mobile);
background: var(--bg-canvas);
}
.topbar h1 {
margin: 0;
font-size: 20px;
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.content { .content {
padding: 0 var(--padding-mobile) var(--tabbar-height-mobile); padding: 0 var(--padding-mobile) var(--tabbar-height-mobile);
} }

View File

@ -130,7 +130,7 @@ watch(
<template> <template>
<div class="mock-form"> <div class="mock-form">
<AppPageHeader :title="title" subtitle="仅支持行测五个模块分数,总分自动汇总"> <AppPageHeader :title="title" subtitle="仅支持行测五个模块分数,总分自动汇总" sticky-mobile>
<AppButton variant="ghost" @click="back"> <AppButton variant="ghost" @click="back">
<AppIcon name="chevron-left" :size="16" />返回模考分析 <AppIcon name="chevron-left" :size="16" />返回模考分析
</AppButton> </AppButton>

View File

@ -98,7 +98,7 @@ async function confirmRemove() {
/> />
<template v-else> <template v-else>
<AppPageHeader title="模考分析" subtitle="行测成绩趋势 · 目标分差 · 模块对比" :stack-mobile="isMobile"> <AppPageHeader title="模考分析" subtitle="行测成绩趋势 · 目标分差 · 模块对比" :stack-mobile="isMobile" sticky-mobile>
<AppButton variant="primary" @click="goEntry"> <AppButton variant="primary" @click="goEntry">
<AppIcon name="plus" :size="16" />录入成绩 <AppIcon name="plus" :size="16" />录入成绩
</AppButton> </AppButton>

View File

@ -8,8 +8,7 @@ import { formatDateShort } from '../../utils/format'
import AppLoading from '../../components/feedback/AppLoading.vue' import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue' import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue' import AppEmpty from '../../components/feedback/AppEmpty.vue'
import AppIcon from '../../components/base/AppIcon.vue' import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppButton from '../../components/base/AppButton.vue'
const route = useRoute() const route = useRoute()
const id = computed(() => String(route.params.id ?? '')) const id = computed(() => String(route.params.id ?? ''))
@ -32,12 +31,9 @@ const html = computed(() => renderMarkdown(detail.data.value?.content ?? ''))
/> />
<template v-else-if="detail.data.value"> <template v-else-if="detail.data.value">
<header class="head"> <AppPageHeader back class="news-detail__head" sticky-mobile>
<AppButton class="head__back" variant="ghost" size="md" aria-label="返回" @click="$router.back()"> <template #title><span class="head__label">要闻详情</span></template>
<AppIcon name="chevron-left" :size="20" /> </AppPageHeader>
</AppButton>
<span class="head__label">要闻详情</span>
</header>
<article class="article"> <article class="article">
<section class="article__hero"> <section class="article__hero">
@ -61,25 +57,9 @@ const html = computed(() => renderMarkdown(detail.data.value?.content ?? ''))
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.head { .news-detail__head {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
.head__back {
display: grid !important;
place-items: center;
width: 36px !important;
height: 36px !important;
padding: 0 !important;
border: none !important;
border-radius: 50% !important;
background: var(--bg-card) !important;
color: var(--text-primary) !important;
gap: 0 !important;
cursor: pointer;
}
.head__label { .head__label {
font-size: var(--fs-subtitle); font-size: var(--fs-subtitle);
color: var(--text-secondary); color: var(--text-secondary);

View File

@ -96,7 +96,7 @@ async function doImport() {
<template v-else> <template v-else>
<!-- 页头 --> <!-- 页头 -->
<AppPageHeader :title="isMobile ? '要闻公告' : '要闻'" subtitle="最新招考资讯与政策动态"> <AppPageHeader :title="isMobile ? '要闻公告' : '要闻'" subtitle="最新招考资讯与政策动态" sticky-mobile>
<AppButton variant="ghost" @click="openImport">订阅推送</AppButton> <AppButton variant="ghost" @click="openImport">订阅推送</AppButton>
<AppButton variant="primary" @click="openImport"> <AppButton variant="primary" @click="openImport">
<AppIcon name="inbox" :size="16" />发布公告 <AppIcon name="inbox" :size="16" />发布公告

View File

@ -4,8 +4,7 @@ import { plansApi } from '../../api'
import { useRequest } from '../../composables/useRequest' import { useRequest } from '../../composables/useRequest'
import { formatDateShort, formatMinutes, weekdayName } from '../../utils/format' import { formatDateShort, formatMinutes, weekdayName } from '../../utils/format'
import AppCard from '../../components/base/AppCard.vue' import AppCard from '../../components/base/AppCard.vue'
import AppIcon from '../../components/base/AppIcon.vue' import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppLoading from '../../components/feedback/AppLoading.vue' import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue' import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue' import AppEmpty from '../../components/feedback/AppEmpty.vue'
@ -45,13 +44,10 @@ const weakTone = ['danger', 'warning', 'primary']
<template v-else-if="data"> <template v-else-if="data">
<!-- 页头 --> <!-- 页头 -->
<header class="review__head"> <AppPageHeader back sticky-mobile>
<AppButton class="review__back" variant="ghost" size="md" aria-label="返回" @click="$router.back()"> <template #title><h1 class="review__title">周报复盘</h1></template>
<AppIcon name="chevron-left" :size="20" />
</AppButton>
<h1>周报复盘</h1>
<span class="review__export">本周小结</span> <span class="review__export">本周小结</span>
</header> </AppPageHeader>
<!-- 周区间 + 汇总 --> <!-- 周区间 + 汇总 -->
<section class="review-hero"> <section class="review-hero">
@ -120,32 +116,13 @@ const weakTone = ['danger', 'warning', 'primary']
flex-direction: column; flex-direction: column;
gap: var(--space-5); gap: var(--space-5);
} }
.review__head { .review__title {
display: flex;
align-items: center;
gap: var(--space-3);
}
.review__back {
display: grid !important;
place-items: center;
width: 36px !important;
height: 36px !important;
padding: 0 !important;
border: none !important;
border-radius: 50% !important;
background: var(--bg-card) !important;
color: var(--text-primary) !important;
gap: 0 !important;
cursor: pointer;
}
.review__head h1 {
margin: 0; margin: 0;
font-size: var(--fs-h1); font-size: var(--fs-h1);
font-weight: var(--fw-h1); font-weight: var(--fw-h1);
color: var(--text-primary); color: var(--text-primary);
} }
.review__export { .review__export {
margin-left: auto;
font-size: var(--fs-label); font-size: var(--fs-label);
color: var(--text-muted); color: var(--text-muted);
} }

View File

@ -234,7 +234,7 @@ const profileDays = computed(() => (examProfile.data.value ? daysSince(examProfi
<!-- ============ 桌面端备考计划 ============ --> <!-- ============ 桌面端备考计划 ============ -->
<div v-else-if="todayData" class="plan-desktop"> <div v-else-if="todayData" class="plan-desktop">
<AppPageHeader title="备考计划" :subtitle="`安排阶段任务,稳步推进备考进度 · 备考第 ${profileDays} 天`"> <AppPageHeader title="备考计划" :subtitle="`安排阶段任务,稳步推进备考进度 · 备考第 ${profileDays} 天`" sticky-mobile>
<RouterLink class="btn-link" to="/plan/review"> <RouterLink class="btn-link" to="/plan/review">
<AppIcon name="chart" :size="16" />周报复盘 <AppIcon name="chart" :size="16" />周报复盘
</RouterLink> </RouterLink>

View File

@ -7,6 +7,7 @@ import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue' import AppButton from '../../components/base/AppButton.vue'
import AppIcon from '../../components/base/AppIcon.vue' import AppIcon from '../../components/base/AppIcon.vue'
import AppBadge from '../../components/base/AppBadge.vue' import AppBadge from '../../components/base/AppBadge.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue' import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue' import AppError from '../../components/feedback/AppError.vue'
@ -68,15 +69,11 @@ const mistakeText = computed(() => {
<template v-else-if="explain"> <template v-else-if="explain">
<!-- 顶栏 --> <!-- 顶栏 -->
<header class="ai-explain__top"> <AppPageHeader :title="title" size="detail" back>
<button type="button" class="ai-explain__back" @click="back">
<AppIcon name="chevron-left" :size="20" />
</button>
<h1 class="ai-explain__title">{{ title }}</h1>
<AppBadge :variant="isAi ? 'primary' : 'soft'"> <AppBadge :variant="isAi ? 'primary' : 'soft'">
{{ isAi ? 'AI 生成' : '题库解析回退' }} {{ isAi ? 'AI 生成' : '题库解析回退' }}
</AppBadge> </AppBadge>
</header> </AppPageHeader>
<!-- 结论 --> <!-- 结论 -->
<AppCard title="讲解结论" icon="sparkles"> <AppCard title="讲解结论" icon="sparkles">
@ -131,30 +128,6 @@ const mistakeText = computed(() => {
flex-direction: column; flex-direction: column;
gap: var(--space-5); gap: var(--space-5);
} }
.ai-explain__top {
display: flex;
align-items: center;
gap: var(--space-3);
}
.ai-explain__back {
display: grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
}
.ai-explain__title {
flex: 1;
margin: 0;
font-size: var(--fs-h2);
font-weight: var(--fw-h2);
color: var(--text-primary);
}
.ai-explain__summary { .ai-explain__summary {
margin: 0; margin: 0;
font-size: var(--fs-body); font-size: var(--fs-body);

View File

@ -5,6 +5,7 @@ import { reviewApi, type ReviewDetail } from '../../api'
import AppButton from '../../components/base/AppButton.vue' import AppButton from '../../components/base/AppButton.vue'
import AppBadge from '../../components/base/AppBadge.vue' import AppBadge from '../../components/base/AppBadge.vue'
import AppIcon from '../../components/base/AppIcon.vue' import AppIcon from '../../components/base/AppIcon.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue' import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue' import AppError from '../../components/feedback/AppError.vue'
import { formatDateShort, formatDateWeekday } from '../../utils/format' import { formatDateShort, formatDateWeekday } from '../../utils/format'
@ -75,10 +76,6 @@ async function markMastered() {
} }
} }
function back() {
router.back()
}
const statusLabel = computed(() => const statusLabel = computed(() =>
assessResult.value assessResult.value
? assessResult.value.status === 'mastered' ? assessResult.value.status === 'mastered'
@ -119,15 +116,9 @@ function goAiExplain() {
<template v-else-if="detail"> <template v-else-if="detail">
<!-- 顶栏返回 + 标题 + 状态 --> <!-- 顶栏返回 + 标题 + 状态 -->
<header class="wd__top"> <AppPageHeader title="错题详情" size="detail" back>
<button type="button" class="wd__back" @click="back"> <AppBadge :variant="statusMeta.variant">{{ statusLabel }}</AppBadge>
<AppIcon name="chevron-left" :size="20" /> </AppPageHeader>
</button>
<h1 class="wd__title">错题详情</h1>
<span class="wd__status-badge">
<AppBadge :variant="statusMeta.variant">{{ statusLabel }}</AppBadge>
</span>
</header>
<!-- 题目卡片 --> <!-- 题目卡片 -->
<section class="wd__question"> <section class="wd__question">
@ -255,34 +246,6 @@ function goAiExplain() {
} }
/* 顶栏 */ /* 顶栏 */
.wd__top {
display: flex;
align-items: center;
gap: var(--space-3);
}
.wd__back {
display: grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
}
.wd__title {
margin: 0;
font-size: var(--fs-h3);
font-weight: var(--fw-h1);
color: var(--text-primary);
flex: 1;
}
.wd__status-badge {
flex: none;
}
/* 题目 */ /* 题目 */
.wd__question { .wd__question {
background: var(--bg-card); background: var(--bg-card);

View File

@ -168,7 +168,7 @@ function editProfile() {
<!-- ============ 桌面端我的 ============ --> <!-- ============ 桌面端我的 ============ -->
<div v-else-if="d" class="profile-desktop"> <div v-else-if="d" class="profile-desktop">
<AppPageHeader title="我的" subtitle="管理个人资料与备考偏好"> <AppPageHeader title="我的" subtitle="管理个人资料与备考偏好" sticky-mobile>
<AppButton variant="secondary" @click="editProfile"> <AppButton variant="secondary" @click="editProfile">
<AppIcon name="user" :size="16" />编辑资料 <AppIcon name="user" :size="16" />编辑资料
</AppButton> </AppButton>

View File

@ -326,7 +326,7 @@ function markAnswer(key: OptionKey) {
<div class="questions"> <div class="questions">
<!-- ======================= 手动录入模式 ======================= --> <!-- ======================= 手动录入模式 ======================= -->
<template v-if="mode === 'entry'"> <template v-if="mode === 'entry'">
<AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入"> <AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入" sticky-mobile>
<AppButton variant="ghost" @click="mode = 'list'"> <AppButton variant="ghost" @click="mode = 'list'">
<AppIcon name="chevron-left" :size="16" />返回列表 <AppIcon name="chevron-left" :size="16" />返回列表
</AppButton> </AppButton>
@ -454,7 +454,7 @@ function markAnswer(key: OptionKey) {
/> />
<template v-else> <template v-else>
<AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入"> <AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入" sticky-mobile>
<AppButton variant="secondary" @click="openImport"> <AppButton variant="secondary" @click="openImport">
<AppIcon name="inbox" :size="16" />导入真题 <AppIcon name="inbox" :size="16" />导入真题
</AppButton> </AppButton>

View File

@ -96,7 +96,7 @@ async function save() {
<template v-else-if="settings.data.value"> <template v-else-if="settings.data.value">
<!-- 页头 --> <!-- 页头 -->
<AppPageHeader title="设置" subtitle="调整偏好、通知与数据策略" stack-mobile> <AppPageHeader title="设置" subtitle="调整偏好、通知与数据策略" stack-mobile sticky-mobile>
<AppButton variant="primary" :disabled="saving" @click="save"> <AppButton variant="primary" :disabled="saving" @click="save">
<AppIcon name="check" :size="16" />{{ saving ? '保存中…' : '保存设置' }} <AppIcon name="check" :size="16" />{{ saving ? '保存中…' : '保存设置' }}
</AppButton> </AppButton>