页面布局调整
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-08-12 11:51:24 +08:00
parent 3dda09975b
commit 3df57cfed6
10 changed files with 77 additions and 198 deletions

View File

@@ -75,13 +75,13 @@ pnpm preview # 预览生产构建
### 路由与布局 ### 路由与布局
5 个 view两种 layout 5 个 view两种 layout
- `DefaultLayout``/`):顶栏 + 侧栏 + 内容 + 页脚,包 home / chanlun / articles / about。 - `DefaultLayout``/`):顶栏 + 内容 + 页脚,包 home / chanlun / articles / about。
- `ReaderLayout``/read/:category/:slug`):沉浸阅读,含阅读进度、阅读设置、上下篇导航。 - `ReaderLayout``/read/:category/:slug`):沉浸阅读,含阅读进度、阅读设置、上下篇导航。
- markdown 的 h2/h3 会被 `useMarkdown.ts` 注入 `id`slug 来自 `utils/slug.ts`),便于锚点跳转。 - markdown 的 h2/h3 会被 `useMarkdown.ts` 注入 `id`slug 来自 `utils/slug.ts`),便于锚点跳转。
### 现存组件缺标记类名 ### 现存组件缺标记类名
`components/ui/`BaseButton/Badge/Card/SearchInput`components/layout/`AppHeader/AppSidebar/AppFooter`ArticleCard` 等目前是纯工具类。改动它们时按上面的「标记类名」约定补齐。 `components/ui/`BaseButton/Badge/Card/SearchInput`components/layout/`AppHeader/AppFooter`ArticleCard` 等目前是纯工具类。改动它们时按上面的「标记类名」约定补齐。
## 其他约定 ## 其他约定

View File

@@ -35,7 +35,7 @@ pnpm typecheck # 仅类型检查
src/ src/
├── components/ ├── components/
│ ├── ui/ # 基础组件BaseButton / Badge / Card / SearchInput │ ├── ui/ # 基础组件BaseButton / Badge / Card / SearchInput
│ ├── layout/ # AppHeader / AppSidebar / AppFooter │ ├── layout/ # AppHeader / AppFooter
│ ├── ArticleCard.vue # 文章列表卡片 │ ├── ArticleCard.vue # 文章列表卡片
│ ├── TableOfContents.vue │ ├── TableOfContents.vue
│ ├── ThemeToggle.vue / ReaderSettings.vue / ReadingProgress.vue │ ├── ThemeToggle.vue / ReaderSettings.vue / ReadingProgress.vue
@@ -47,7 +47,7 @@ src/
│ ├── essays.ts # 杂谈随笔(示例数据) │ ├── essays.ts # 杂谈随笔(示例数据)
│ └── categories.ts # 分类信息 │ └── categories.ts # 分类信息
├── layouts/ ├── layouts/
│ ├── DefaultLayout.vue # 顶栏 + 侧栏 + 内容 + 页脚 │ ├── DefaultLayout.vue # 顶栏 + 内容 + 页脚
│ └── ReaderLayout.vue # 沉浸阅读布局 │ └── ReaderLayout.vue # 沉浸阅读布局
├── views/ # 5 个页面 ├── views/ # 5 个页面
├── stores/ # theme / readingPinia ├── stores/ # theme / readingPinia

View File

@@ -16,11 +16,6 @@ const links = [
class="mx-auto flex max-w-[1400px] flex-col items-center justify-between gap-4 px-4 py-8 sm:flex-row sm:px-6" class="mx-auto flex max-w-[1400px] flex-col items-center justify-between gap-4 px-4 py-8 sm:flex-row sm:px-6"
> >
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">
<span
class="flex size-7 items-center justify-center rounded-md bg-primary text-white"
>
<i class="ri-line-chart-line text-base" aria-hidden="true"></i>
</span>
<div class="text-sm"> <div class="text-sm">
<span class="font-medium text-foreground">缠论研习社</span> <span class="font-medium text-foreground">缠论研习社</span>
<span class="ml-2 text-subtle">专注缠论的系统性研习</span> <span class="ml-2 text-subtle">专注缠论的系统性研习</span>

View File

@@ -1,10 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { Menu } from 'lucide-vue-next'
import ThemeToggle from '@/components/ThemeToggle.vue' import ThemeToggle from '@/components/ThemeToggle.vue'
defineEmits<{ 'toggle-sidebar': [] }>()
const route = useRoute() const route = useRoute()
const nav = [ const nav = [
@@ -21,38 +18,27 @@ function isActive(to: string, exact: boolean) {
<template> <template>
<header <header
class="sticky top-0 z-30 border-b border-line bg-app/80 backdrop-blur-lg" class="app-header sticky top-0 z-30 border-b border-line bg-app/80 backdrop-blur-lg"
> >
<div class="mx-auto flex h-14 max-w-[1400px] items-center gap-3 px-4 sm:px-6"> <div
<!-- 移动端菜单 --> class="app-header__inner mx-auto flex h-14 max-w-[1400px] items-center gap-3 px-4 sm:px-6"
<button
type="button"
class="flex size-9 items-center justify-center rounded-full text-muted hover:bg-surface hover:text-foreground lg:hidden"
aria-label="打开菜单"
@click="$emit('toggle-sidebar')"
> >
<Menu class="size-5" />
</button>
<!-- Logo --> <!-- Logo -->
<RouterLink to="/" class="flex shrink-0 items-center gap-2.5"> <RouterLink to="/" class="app-header__logo flex shrink-0 items-center">
<span <span
class="flex size-8 items-center justify-center rounded-md bg-primary text-white shadow-sm" class="app-header__title hidden text-[15px] font-semibold tracking-tight text-foreground sm:block"
> >
<i class="ri-line-chart-line text-xl" aria-hidden="true"></i>
</span>
<span class="hidden text-[15px] font-semibold tracking-tight text-foreground sm:block">
缠论研习社 缠论研习社
</span> </span>
</RouterLink> </RouterLink>
<!-- 桌面端导航 --> <!-- 导航移动端与桌面端共用 -->
<nav class="ml-4 hidden items-center gap-0.5 lg:flex"> <nav class="app-header__nav flex items-center gap-0.5 sm:ml-2">
<RouterLink <RouterLink
v-for="item in nav" v-for="item in nav"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="rounded-full px-3.5 py-1.5 text-sm transition-colors" class="rounded-full px-3 py-1.5 text-sm transition-colors sm:px-3.5"
:class=" :class="
isActive(item.to, item.exact) isActive(item.to, item.exact)
? 'bg-surface font-medium text-foreground' ? 'bg-surface font-medium text-foreground'
@@ -63,7 +49,7 @@ function isActive(to: string, exact: boolean) {
</RouterLink> </RouterLink>
</nav> </nav>
<div class="ml-auto flex items-center gap-1"> <div class="app-header__actions ml-auto flex items-center gap-1">
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>

View File

@@ -1,134 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { BookOpen, Feather, Lightbulb, X } from 'lucide-vue-next'
import { categories } from '@/data/categories'
import { useArticles } from '@/composables/useArticles'
import { cn } from '@/utils/cn'
import type { ArticleCategory } from '@/types/article'
const props = defineProps<{ mobileOpen?: boolean }>()
const emit = defineEmits<{ close: [] }>()
const route = useRoute()
const { chanlun, essaysList } = useArticles()
const countMap = computed<Record<ArticleCategory, number>>(() => ({
chanlun: chanlun.value.length,
essay: essaysList.value.length,
notes: 0,
}))
const routeOf = (key: ArticleCategory) =>
key === 'chanlun' ? '/chanlun' : key === 'essay' ? '/articles' : '/about'
const iconOf = (key: ArticleCategory) =>
key === 'chanlun' ? BookOpen : key === 'essay' ? Feather : Lightbulb
const isActive = (key: ArticleCategory) => {
if (key === 'chanlun') return route.path === '/chanlun' || route.path.startsWith('/read/chanlun')
if (key === 'essay') return route.path === '/articles' || route.path.startsWith('/read/essay')
return false
}
/** 抽屉打开时:锁定背景滚动(防 iOS 惯性穿透)+ 支持 Esc 关闭 */
const prevOverflow = ref('')
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
watch(
() => props.mobileOpen,
(v) => {
if (v) {
prevOverflow.value = document.body.style.overflow
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKeydown)
} else {
document.body.style.overflow = prevOverflow.value
window.removeEventListener('keydown', onKeydown)
}
},
)
onBeforeUnmount(() => {
document.body.style.overflow = prevOverflow.value
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<!-- 移动端遮罩 -->
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-150"
leave-to-class="opacity-0"
>
<div
v-if="mobileOpen"
class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm lg:hidden"
@click="emit('close')"
/>
</Transition>
</Teleport>
<aside
:class="
cn(
'fixed inset-y-0 left-0 z-50 w-72 max-w-[calc(100vw_-_2.5rem)] shrink-0 overflow-y-auto border-r border-line bg-app p-5 transition-transform duration-300 lg:sticky lg:top-14 lg:z-0 lg:h-[calc(100vh-3.5rem)] lg:overflow-visible lg:translate-x-0 lg:bg-transparent lg:p-6',
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
)
"
>
<!-- 移动端顶栏 -->
<div class="mb-5 flex items-center justify-between lg:hidden">
<span class="text-sm font-semibold text-foreground">导航</span>
<button
type="button"
class="flex size-9 items-center justify-center rounded-full text-muted hover:bg-surface"
aria-label="关闭菜单"
@click="emit('close')"
>
<X class="size-5" />
</button>
</div>
<!-- 分类导航 -->
<nav class="space-y-1">
<p class="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-subtle">分类</p>
<RouterLink
v-for="cat in categories"
:key="cat.key"
:to="routeOf(cat.key)"
class="group flex items-start gap-3 rounded-md p-2.5 transition-colors"
:class="isActive(cat.key) ? 'bg-surface' : 'hover:bg-surface/60'"
@click="emit('close')"
>
<span
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md transition-colors"
:class="
isActive(cat.key)
? 'bg-primary-soft text-primary'
: 'bg-surface-2 text-subtle group-hover:text-muted'
"
>
<component :is="iconOf(cat.key)" class="size-[18px]" />
</span>
<span class="min-w-0 flex-1">
<span class="flex items-center justify-between gap-2">
<span
class="text-sm font-medium"
:class="isActive(cat.key) ? 'text-foreground' : 'text-muted'"
>
{{ cat.label }}
</span>
<span class="shrink-0 text-xs text-subtle">{{ countMap[cat.key] }}</span>
</span>
<span class="mt-0.5 block line-clamp-2 text-xs leading-relaxed text-subtle">
{{ cat.description }}
</span>
</span>
</RouterLink>
</nav>
</aside>
</template>

View File

@@ -1,21 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import AppHeader from '@/components/layout/AppHeader.vue' import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import AppFooter from '@/components/layout/AppFooter.vue' import AppFooter from '@/components/layout/AppFooter.vue'
const mobileOpen = ref(false)
</script> </script>
<template> <template>
<div class="flex min-h-dvh flex-col"> <div class="flex min-h-dvh flex-col">
<AppHeader @toggle-sidebar="mobileOpen = !mobileOpen" /> <AppHeader />
<div class="mx-auto flex w-full max-w-[1400px] flex-1"> <main class="mx-auto w-full max-w-[1400px] flex-1 px-4 py-8 sm:px-6 lg:px-10">
<AppSidebar :mobile-open="mobileOpen" @close="mobileOpen = false" />
<main class="min-w-0 flex-1 px-4 py-8 sm:px-6 lg:px-10">
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<Transition <Transition
enter-active-class="transition duration-300 ease-out" enter-active-class="transition duration-300 ease-out"
@@ -28,7 +21,6 @@ const mobileOpen = ref(false)
</Transition> </Transition>
</RouterView> </RouterView>
</main> </main>
</div>
<AppFooter /> <AppFooter />
</div> </div>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router' import { RouterLink, RouterView, useRouter } from 'vue-router'
import { ArrowLeft } from 'lucide-vue-next' import { ArrowLeft } from 'lucide-vue-next'
import ThemeToggle from '@/components/ThemeToggle.vue' import ThemeToggle from '@/components/ThemeToggle.vue'
@@ -11,6 +12,47 @@ function goBack() {
if (window.history.length > 1) router.back() if (window.history.length > 1) router.back()
else router.push('/') else router.push('/')
} }
// 顶栏随滚动自动隐藏 / 显示,营造沉浸阅读体验
// - 向下滑过阈值即隐藏
// - 向上回退一定距离后才重新显示
// - 接近页面顶部时始终显示
const HIDE_AFTER = 6 // 向下滑动多少 px 后隐藏
const SHOW_AFTER = 12 // 向上回退多少 px 后才显示(比 HIDE 略大,避免轻微抖动反复切换)
const TOP_EDGE = 10 // 距顶部多少 px 内强制显示
const headerHidden = ref(false)
let lastY = 0 // 上一次状态切换时的滚动位置(连续同向滚动不更新,避免累加)
let ticking = false
function onScroll() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
const y = window.scrollY
const delta = y - lastY
if (y <= TOP_EDGE) {
headerHidden.value = false
lastY = y
} else if (delta > HIDE_AFTER) {
headerHidden.value = true
lastY = y
} else if (delta < -SHOW_AFTER) {
headerHidden.value = false
lastY = y
}
ticking = false
})
}
onMounted(() => {
lastY = window.scrollY
window.addEventListener('scroll', onScroll, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll)
})
</script> </script>
<template> <template>
@@ -18,7 +60,8 @@ function goBack() {
<ReadingProgress /> <ReadingProgress />
<header <header
class="reader-header sticky top-0 z-30 border-b border-line bg-app/80 backdrop-blur-lg" class="reader-header sticky top-0 z-30 border-b border-line bg-app/80 backdrop-blur-lg transition-transform duration-300 ease-in-out"
:class="{ '-translate-y-full': headerHidden }"
> >
<div <div
class="reader-header__inner mx-auto flex h-14 max-w-[1180px] items-center gap-3 px-4 sm:px-6" class="reader-header__inner mx-auto flex h-14 max-w-[1180px] items-center gap-3 px-4 sm:px-6"
@@ -36,11 +79,6 @@ function goBack() {
to="/" to="/"
class="reader-header__logo flex shrink-0 items-center gap-2.5 transition-opacity hover:opacity-80" class="reader-header__logo flex shrink-0 items-center gap-2.5 transition-opacity hover:opacity-80"
> >
<span
class="reader-header__logo-icon flex size-8 items-center justify-center rounded-md bg-primary text-white shadow-sm"
>
<i class="ri-line-chart-line text-xl" aria-hidden="true"></i>
</span>
<span <span
class="reader-header__logo-text hidden text-[15px] font-semibold tracking-tight text-foreground sm:block" class="reader-header__logo-text hidden text-[15px] font-semibold tracking-tight text-foreground sm:block"
> >

View File

@@ -128,6 +128,8 @@
html { html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
scroll-behavior: smooth; scroll-behavior: smooth;
/* 始终为滚动条预留槽位,避免切换页面时因滚动条出现/消失导致布局水平抖动 */
scrollbar-gutter: stable;
tab-size: 2; tab-size: 2;
} }

View File

@@ -23,7 +23,7 @@ function clearQuery() {
</script> </script>
<template> <template>
<div class="mx-auto w-full max-w-3xl"> <div class="mx-auto w-full max-w-5xl">
<!-- 页头 --> <!-- 页头 -->
<header> <header>
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
@@ -60,7 +60,7 @@ function clearQuery() {
</div> </div>
<!-- 课程列表 --> <!-- 课程列表 -->
<section v-if="filtered.length" class="mt-6 flex flex-col gap-3"> <section v-if="filtered.length" class="mt-6 grid gap-4 sm:grid-cols-2">
<ArticleCard v-for="article in filtered" :key="article.id" :article="article" /> <ArticleCard v-for="article in filtered" :key="article.id" :article="article" />
</section> </section>

View File

@@ -77,7 +77,7 @@ const categoryIcon: Record<ArticleCategory, typeof BookOpen> = {
</script> </script>
<template> <template>
<div class="mx-auto max-w-[1200px]"> <div class="mx-auto max-w-5xl">
<!-- ============ Hero ============ --> <!-- ============ Hero ============ -->
<section class="relative overflow-hidden py-6"> <section class="relative overflow-hidden py-6">
<!-- 极淡装饰走势曲线 --> <!-- 极淡装饰走势曲线 -->