This commit is contained in:
@@ -75,13 +75,13 @@ pnpm preview # 预览生产构建
|
||||
### 路由与布局
|
||||
|
||||
5 个 view,两种 layout:
|
||||
- `DefaultLayout`(`/`):顶栏 + 侧栏 + 内容 + 页脚,包 home / chanlun / articles / about。
|
||||
- `DefaultLayout`(`/`):顶栏 + 内容 + 页脚,包 home / chanlun / articles / about。
|
||||
- `ReaderLayout`(`/read/:category/:slug`):沉浸阅读,含阅读进度、阅读设置、上下篇导航。
|
||||
- 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` 等目前是纯工具类。改动它们时按上面的「标记类名」约定补齐。
|
||||
|
||||
## 其他约定
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ pnpm typecheck # 仅类型检查
|
||||
src/
|
||||
├── components/
|
||||
│ ├── ui/ # 基础组件:BaseButton / Badge / Card / SearchInput
|
||||
│ ├── layout/ # AppHeader / AppSidebar / AppFooter
|
||||
│ ├── layout/ # AppHeader / AppFooter
|
||||
│ ├── ArticleCard.vue # 文章列表卡片
|
||||
│ ├── TableOfContents.vue
|
||||
│ ├── ThemeToggle.vue / ReaderSettings.vue / ReadingProgress.vue
|
||||
@@ -47,7 +47,7 @@ src/
|
||||
│ ├── essays.ts # 杂谈随笔(示例数据)
|
||||
│ └── categories.ts # 分类信息
|
||||
├── layouts/
|
||||
│ ├── DefaultLayout.vue # 顶栏 + 侧栏 + 内容 + 页脚
|
||||
│ ├── DefaultLayout.vue # 顶栏 + 内容 + 页脚
|
||||
│ └── ReaderLayout.vue # 沉浸阅读布局
|
||||
├── views/ # 5 个页面
|
||||
├── stores/ # theme / reading(Pinia)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<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">
|
||||
<span class="font-medium text-foreground">缠论研习社</span>
|
||||
<span class="ml-2 text-subtle">专注缠论的系统性研习</span>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
|
||||
defineEmits<{ 'toggle-sidebar': [] }>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const nav = [
|
||||
@@ -21,38 +18,27 @@ function isActive(to: string, exact: boolean) {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<!-- 移动端菜单 -->
|
||||
<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')"
|
||||
<div
|
||||
class="app-header__inner mx-auto flex h-14 max-w-[1400px] items-center gap-3 px-4 sm:px-6"
|
||||
>
|
||||
<Menu class="size-5" />
|
||||
</button>
|
||||
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="flex shrink-0 items-center gap-2.5">
|
||||
<RouterLink to="/" class="app-header__logo flex shrink-0 items-center">
|
||||
<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>
|
||||
</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
|
||||
v-for="item in nav"
|
||||
:key="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="
|
||||
isActive(item.to, item.exact)
|
||||
? 'bg-surface font-medium text-foreground'
|
||||
@@ -63,7 +49,7 @@ function isActive(to: string, exact: boolean) {
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<div class="app-header__actions ml-auto flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,21 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||
|
||||
const mobileOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<AppSidebar :mobile-open="mobileOpen" @close="mobileOpen = false" />
|
||||
|
||||
<main class="min-w-0 flex-1 px-4 py-8 sm:px-6 lg:px-10">
|
||||
<main class="mx-auto w-full max-w-[1400px] flex-1 px-4 py-8 sm:px-6 lg:px-10">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
@@ -28,7 +21,6 @@ const mobileOpen = ref(false)
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from 'lucide-vue-next'
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
@@ -11,6 +12,47 @@ function goBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -18,7 +60,8 @@ function goBack() {
|
||||
<ReadingProgress />
|
||||
|
||||
<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
|
||||
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="/"
|
||||
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
|
||||
class="reader-header__logo-text hidden text-[15px] font-semibold tracking-tight text-foreground sm:block"
|
||||
>
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
/* 始终为滚动条预留槽位,避免切换页面时因滚动条出现/消失导致布局水平抖动 */
|
||||
scrollbar-gutter: stable;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function clearQuery() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<div class="mx-auto w-full max-w-5xl">
|
||||
<!-- 页头 -->
|
||||
<header>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
@@ -60,7 +60,7 @@ function clearQuery() {
|
||||
</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" />
|
||||
</section>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ const categoryIcon: Record<ArticleCategory, typeof BookOpen> = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-[1200px]">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<!-- ============ Hero ============ -->
|
||||
<section class="relative overflow-hidden py-6">
|
||||
<!-- 极淡装饰:走势曲线 -->
|
||||
|
||||
Reference in New Issue
Block a user