数据结构优化
This commit is contained in:
@@ -2,15 +2,9 @@
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon/icon.jpg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>艺术与代码 | 年糕崽崽的数字花园</title>
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Sans+SC:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -9,10 +9,15 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/jetbrains-mono": "^5.0.0",
|
||||
"@fontsource/noto-sans-sc": "^5.0.0",
|
||||
"@fontsource/playfair-display": "^5.0.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"echarts": "^6.0.0",
|
||||
"highlight": "^0.2.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.446.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"md-editor-v3": "^6.3.1",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
44
client/pnpm-lock.yaml
generated
44
client/pnpm-lock.yaml
generated
@@ -8,6 +8,18 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fontsource/inter':
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.8
|
||||
'@fontsource/jetbrains-mono':
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.8
|
||||
'@fontsource/noto-sans-sc':
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.9
|
||||
'@fontsource/playfair-display':
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.8
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
@@ -20,6 +32,9 @@ importers:
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
lucide-vue-next:
|
||||
specifier: ^0.446.0
|
||||
version: 0.446.0(vue@3.5.26(typescript@5.6.3))
|
||||
markdown-it:
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0
|
||||
@@ -327,6 +342,18 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fontsource/inter@5.2.8':
|
||||
resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==}
|
||||
|
||||
'@fontsource/jetbrains-mono@5.2.8':
|
||||
resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==}
|
||||
|
||||
'@fontsource/noto-sans-sc@5.2.9':
|
||||
resolution: {integrity: sha512-bTUIWGBgJDpwi5qAr+x0/lcgv80IHTB9vl6s2f6EymZEa7qYV99yNRBZuKFT+SYDKVunZrjCEhWtpxqmbXWl5Q==}
|
||||
|
||||
'@fontsource/playfair-display@5.2.8':
|
||||
resolution: {integrity: sha512-fUEhib70SszNhQVsGbUMSsWJhr7Je0rdeuZdtGpDNu0GKF1xJM8QhpI/y0pckU25GcChXm9TLOmeZupkvvZo2g==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -817,6 +844,11 @@ packages:
|
||||
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lucide-vue-next@0.446.0:
|
||||
resolution: {integrity: sha512-ddx9NRczKulV4a2GcbY9F5l/zuUW0ARB4mRc+kOzFur9XvvrVxVmAmhqVChZUlj9Wmraopn8ujDfhxB6FlVfIw==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
lucide-vue-next@0.543.0:
|
||||
resolution: {integrity: sha512-Az5kpNm/koKAwSNIKjsZ4uHV2tVfmlQlcHwFBygQ8gc5/jFg7An9OrxgDy/aE5m+HLx7VfLYqDxLr8gWecZbQA==}
|
||||
peerDependencies:
|
||||
@@ -1474,6 +1506,14 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@fontsource/inter@5.2.8': {}
|
||||
|
||||
'@fontsource/jetbrains-mono@5.2.8': {}
|
||||
|
||||
'@fontsource/noto-sans-sc@5.2.9': {}
|
||||
|
||||
'@fontsource/playfair-display@5.2.8': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1981,6 +2021,10 @@ snapshots:
|
||||
|
||||
lru-cache@11.2.4: {}
|
||||
|
||||
lucide-vue-next@0.446.0(vue@3.5.26(typescript@5.6.3)):
|
||||
dependencies:
|
||||
vue: 3.5.26(typescript@5.6.3)
|
||||
|
||||
lucide-vue-next@0.543.0(vue@3.5.26(typescript@5.6.3)):
|
||||
dependencies:
|
||||
vue: 3.5.26(typescript@5.6.3)
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
BIN
client/public/icon/icon.jpg
Normal file
BIN
client/public/icon/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -1,25 +1,28 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-art-bg text-art-text relative overflow-hidden">
|
||||
<!--
|
||||
CRITICAL FIX: Removed 'overflow-hidden' from this root div.
|
||||
'overflow-hidden' on an ancestor element breaks 'position: sticky'
|
||||
in child components (like BlogDetail TOC) because it changes the
|
||||
scrolling context.
|
||||
-->
|
||||
<div class="min-h-screen bg-art-bg text-art-text relative">
|
||||
<!-- Star Background (Only for frontend) -->
|
||||
<!-- Removed the gradient background div to keep original theme color -->
|
||||
<StarBackground v-if="!isAdminOrLogin" class="z-0" />
|
||||
|
||||
<!-- Global Noise (Only for frontend) -->
|
||||
<!-- z-index 提高到 60 保持在最上层 -->
|
||||
<div v-if="!isAdminOrLogin" class="fixed inset-0 pointer-events-none z-[60] bg-noise opacity-30 mix-blend-overlay"></div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container" class="toast-container relative z-[70]"></div>
|
||||
|
||||
<!-- Header (Hide on Admin & Login) -->
|
||||
<Header v-if="!isAdminOrLogin" class="z-50" />
|
||||
<Header v-if="!isAdminOrLogin" class="relative z-50" />
|
||||
|
||||
<!-- Mobile Menu (Hide on Admin & Login) -->
|
||||
<MobileMenu v-if="!isAdminOrLogin" class="relative z-50" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<!-- Conditional classes: Apply max-w-7xl only for frontend pages -->
|
||||
<!-- 添加 relative z-10 确保内容浮在星星之上 -->
|
||||
<main :class="[
|
||||
isAdminOrLogin ? 'w-full h-full' : 'pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
|
||||
]">
|
||||
@@ -44,7 +47,7 @@ import Header from './components/Header.vue'
|
||||
import MobileMenu from './components/MobileMenu.vue'
|
||||
import Footer from './components/Footer.vue'
|
||||
import InquiryModal from './components/InquiryModal.vue'
|
||||
import StarBackground from './components/StarBackground.vue' // Import the new component
|
||||
import StarBackground from './components/StarBackground.vue'
|
||||
import { getPublicSettings } from './services/api'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -55,11 +58,16 @@ const isAdminOrLogin = computed(() => {
|
||||
return path.startsWith('/admin') || path === '/login'
|
||||
})
|
||||
|
||||
// 应用网站配置到meta标签(页面标题由路由守卫处理)
|
||||
// 应用网站配置到页面标题和meta标签
|
||||
const applySiteSettings = async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
|
||||
// 设置页面标题
|
||||
if (settings.site_title) {
|
||||
document.title = settings.site_title
|
||||
}
|
||||
|
||||
// 更新meta标签
|
||||
const updateMetaTag = (name: string, content: string) => {
|
||||
if (!content) return
|
||||
|
||||
@@ -62,9 +62,9 @@
|
||||
</button>
|
||||
</nav>
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<a href="#" class="text-art-muted hover:text-white transition-colors"><i data-lucide="github" class="w-5 h-5"></i></a>
|
||||
<a href="#" class="text-art-muted hover:text-white transition-colors"><Icon name="github" :size="20" /></a>
|
||||
<a href="/admin" target="_blank" class="text-art-muted hover:text-white transition-colors" title="后台管理">
|
||||
<i data-lucide="layout-dashboard" class="w-5 h-5"></i>
|
||||
<Icon name="layout-dashboard" :size="20" />
|
||||
</a>
|
||||
<button
|
||||
v-if="visibleMenus.includes('services')"
|
||||
@@ -75,7 +75,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<button class="md:hidden text-white" @click="toggleMobileMenu">
|
||||
<i data-lucide="menu" class="w-6 h-6"></i>
|
||||
<Icon name="menu" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -85,6 +85,7 @@
|
||||
import { ref, onMounted, onUpdated, watch, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { getPublicSettings } from '../services/api'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -122,11 +123,7 @@ const updateActiveNav = () => {
|
||||
else if (path === '/about') activeNav.value = 'about'
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if ((window as any).lucide) {
|
||||
(window as any).lucide.createIcons()
|
||||
}
|
||||
}
|
||||
// Icons are now handled by Vue components
|
||||
|
||||
// 获取网站配置
|
||||
const loadSiteSettings = async () => {
|
||||
@@ -157,19 +154,13 @@ const loadSiteSettings = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
updateActiveNav()
|
||||
refreshIcons()
|
||||
loadSiteSettings()
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
updateActiveNav()
|
||||
nextTick(refreshIcons)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
<template>
|
||||
<i
|
||||
:data-lucide="name"
|
||||
<component
|
||||
:is="iconComponent"
|
||||
:class="className"
|
||||
:style="style"
|
||||
ref="iconRef"
|
||||
></i>
|
||||
:size="size"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import * as LucideIcons from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
className?: string
|
||||
style?: Record<string, any>
|
||||
size?: number | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
className: '',
|
||||
style: () => ({})
|
||||
style: () => ({}),
|
||||
size: 24
|
||||
})
|
||||
|
||||
const iconRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const updateIcon = () => {
|
||||
if (window.lucide && iconRef.value) {
|
||||
// 重新创建图标
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
// 将kebab-case转换为PascalCase,例如 arrow-left -> ArrowLeft
|
||||
const toPascalCase = (str: string) => {
|
||||
return str.split('-').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join('')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateIcon()
|
||||
})
|
||||
|
||||
watch(() => props.name, () => {
|
||||
updateIcon()
|
||||
const iconComponent = computed(() => {
|
||||
const iconName = toPascalCase(props.name) as keyof typeof LucideIcons
|
||||
return LucideIcons[iconName] || LucideIcons.AlertCircle
|
||||
})
|
||||
</script>
|
||||
@@ -4,6 +4,21 @@ import "./style.css"
|
||||
import "highlight.js/styles/atom-one-dark.css"
|
||||
import router from "./router"
|
||||
|
||||
// 导入字体
|
||||
import "@fontsource/inter/300.css"
|
||||
import "@fontsource/inter/400.css"
|
||||
import "@fontsource/inter/500.css"
|
||||
import "@fontsource/inter/600.css"
|
||||
import "@fontsource/noto-sans-sc/300.css"
|
||||
import "@fontsource/noto-sans-sc/400.css"
|
||||
import "@fontsource/noto-sans-sc/500.css"
|
||||
import "@fontsource/noto-sans-sc/700.css"
|
||||
import "@fontsource/playfair-display/400.css"
|
||||
import "@fontsource/playfair-display/400-italic.css"
|
||||
import "@fontsource/playfair-display/600.css"
|
||||
import "@fontsource/playfair-display/700.css"
|
||||
import "@fontsource/jetbrains-mono/400.css"
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount("#app")
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<section id="blog-detail" class="page-section block animate-slide-down">
|
||||
<div class="max-w-7xl mx-auto pt-32 px-6 pb-20 relative">
|
||||
<section id="blog-detail" class="page-section block">
|
||||
<div class="max-w-[90rem] mx-auto pt-32 px-4 md:px-6 pb-20 relative">
|
||||
<!-- 返回按钮 -->
|
||||
<button
|
||||
@click="$router.push('/blog')"
|
||||
class="group flex items-center gap-2 text-art-muted hover:text-white transition-colors mb-12"
|
||||
class="group flex items-center gap-2 text-art-muted hover:text-white transition-colors mb-8 md:mb-12"
|
||||
>
|
||||
<i data-lucide="arrow-left" class="w-4 h-4 transform group-hover:-translate-x-1 transition-transform"></i>
|
||||
<Icon name="arrow-left" :size="16" class="transform group-hover:-translate-x-1 transition-transform" />
|
||||
<span class="text-sm font-medium tracking-wide">返回文章列表</span>
|
||||
</button>
|
||||
|
||||
@@ -26,19 +26,86 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blog detail content -->
|
||||
<div v-else class="xl:flex xl:gap-12 relative">
|
||||
<!-- 主内容区域 -->
|
||||
<div class="flex-1 space-y-12">
|
||||
<!-- 文章信息 -->
|
||||
<div class="border-b border-white/5 pb-10">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<!-- Blog Layout -->
|
||||
<div v-else class="flex flex-col xl:flex-row xl:gap-10 items-start relative">
|
||||
|
||||
<!-- 1. 左侧边栏:元信息 - 仅在大屏显示 -->
|
||||
<aside class="hidden xl:block w-64 flex-shrink-0 order-1 sticky top-32 max-h-[calc(100vh-8rem)] overflow-y-auto hide-scrollbar">
|
||||
<div class="space-y-6">
|
||||
<!-- 专栏跳转卡片 -->
|
||||
<div v-if="post.columnId && post.columnName" class="p-5 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-lg transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-xs font-bold text-white uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Icon name="book-open" :size="14" class="text-art-accent" />
|
||||
所属专栏
|
||||
</h3>
|
||||
<a
|
||||
@click.prevent="router.push(`/columns/${post.columnId}`)"
|
||||
href="#"
|
||||
class="block p-3 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-1">
|
||||
{{ post.columnName }}
|
||||
</div>
|
||||
<div class="text-xs text-art-muted flex items-center gap-1">
|
||||
<Icon name="arrow-right" :size="12" />
|
||||
查看专栏
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 分类搜索卡片 -->
|
||||
<div v-if="post.categoryId && post.categoryName" class="p-5 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-lg transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-xs font-bold text-white uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Icon name="folder" :size="14" class="text-art-accent" />
|
||||
分类
|
||||
</h3>
|
||||
<a
|
||||
@click.prevent="router.push(`/blog?category=${post.categoryId}`)"
|
||||
href="#"
|
||||
class="block p-3 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-1">
|
||||
{{ post.categoryName }}
|
||||
</div>
|
||||
<div class="text-xs text-art-muted flex items-center gap-1">
|
||||
<Icon name="arrow-right" :size="12" />
|
||||
查看分类
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 标签搜索卡片 -->
|
||||
<div v-if="post.tags && post.tags.length > 0" class="p-5 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-lg transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-xs font-bold text-white uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Icon name="tags" :size="14" class="text-art-accent" />
|
||||
标签
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="tag in post.tags"
|
||||
:key="tag.id"
|
||||
@click.prevent="router.push(`/blog?tag=${tag.id}`)"
|
||||
href="#"
|
||||
class="px-2.5 py-1 rounded-md bg-white/5 border border-white/10 hover:bg-art-accent/20 hover:border-art-accent/50 hover:text-art-accent text-xs text-art-muted transition-all duration-200"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 2. 中间:文章主内容 -->
|
||||
<div class="flex-1 min-w-0 order-2">
|
||||
<!-- 文章头图与信息 -->
|
||||
<div class="border-b border-white/5 pb-8 mb-10">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||
<span class="px-3 py-1 border border-white/20 rounded-full text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
|
||||
<span class="text-sm text-art-muted">{{ post.date }}</span>
|
||||
</div>
|
||||
<h1 class="font-serif text-4xl md:text-5xl lg:text-6xl text-white leading-tight mb-8">{{ post.title }}</h1>
|
||||
<h1 class="font-serif text-3xl md:text-5xl lg:text-6xl text-white leading-tight mb-8 break-words">{{ post.title }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden border border-white/20">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden border border-white/20 flex-shrink-0">
|
||||
<img
|
||||
src="https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao"
|
||||
alt="Avatar"
|
||||
@@ -52,148 +119,180 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 - 使用 v-html 渲染 Markdown 解析后的 HTML -->
|
||||
<!-- ref="articleRef" 用于后续绑定复制事件和提取目录 -->
|
||||
<article ref="articleRef" class="blog-content prose prose-invert prose-lg max-w-none text-art-muted leading-relaxed">
|
||||
<!-- 文章内容 -->
|
||||
<article
|
||||
ref="articleRef"
|
||||
class="blog-content prose prose-invert max-w-none text-art-muted leading-relaxed prose-base md:prose-lg"
|
||||
>
|
||||
<div v-html="renderedContent"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 目录侧边栏 (仅在 XL 屏幕显示) -->
|
||||
<aside class="hidden xl:block w-64 flex-shrink-0">
|
||||
<div class="sticky top-32 space-y-6">
|
||||
<!-- 目录导航 -->
|
||||
<div class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="list" class="w-4 h-4 text-art-accent"></i>
|
||||
目录导航
|
||||
</h3>
|
||||
<nav class="space-y-1 relative">
|
||||
<!-- 激活状态的指示条 -->
|
||||
<div
|
||||
class="absolute left-0 w-0.5 bg-art-accent transition-all duration-300 ease-out"
|
||||
:style="activeIndicatorStyle"
|
||||
v-if="toc.length > 0"
|
||||
></div>
|
||||
|
||||
<ul class="space-y-1 border-l border-white/10 ml-[1px]">
|
||||
<li v-for="item in toc" :key="item.id">
|
||||
<a
|
||||
@click.prevent="scrollToHeading(item.id)"
|
||||
href="#"
|
||||
class="block text-xs py-1.5 transition-all duration-200 border-l-2 border-transparent pl-4 hover:text-white"
|
||||
:class="[
|
||||
activeId === item.id ? 'text-art-accent font-medium' : 'text-art-muted',
|
||||
item.level > 2 ? 'pl-8' : 'pl-4'
|
||||
]"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="toc.length === 0" class="text-xs text-art-muted italic pl-4">
|
||||
暂无目录
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 专栏跳转卡片 -->
|
||||
<div v-if="post.columnId && post.columnName" class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="book-open" class="w-4 h-4 text-art-accent"></i>
|
||||
所属专栏
|
||||
</h3>
|
||||
<a
|
||||
@click.prevent="router.push(`/columns/${post.columnId}`)"
|
||||
href="#"
|
||||
class="block p-4 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-1">
|
||||
{{ post.columnName }}
|
||||
</div>
|
||||
<div class="text-xs text-art-muted flex items-center gap-1">
|
||||
<i data-lucide="arrow-right" class="w-3 h-3"></i>
|
||||
查看专栏
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 分类搜索卡片 -->
|
||||
<div v-if="post.categoryId && post.categoryName" class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="folder" class="w-4 h-4 text-art-accent"></i>
|
||||
分类
|
||||
</h3>
|
||||
<a
|
||||
@click.prevent="router.push(`/blog?category=${post.categoryId}`)"
|
||||
href="#"
|
||||
class="block p-4 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-1">
|
||||
{{ post.categoryName }}
|
||||
</div>
|
||||
<div class="text-xs text-art-muted flex items-center gap-1">
|
||||
<i data-lucide="arrow-right" class="w-3 h-3"></i>
|
||||
查看分类
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 标签搜索卡片 -->
|
||||
<div v-if="post.tags && post.tags.length > 0" class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="tags" class="w-4 h-4 text-art-accent"></i>
|
||||
<!-- 移动端专属信息补充区 -->
|
||||
<div class="xl:hidden mt-12 border-t border-white/10 pt-8 space-y-6">
|
||||
<!-- 标签补充 -->
|
||||
<div v-if="post.tags && post.tags.length > 0">
|
||||
<h4 class="text-xs font-bold text-white uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Icon name="tags" :size="14" class="text-art-accent" />
|
||||
标签
|
||||
</h3>
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="tag in post.tags"
|
||||
:key="tag.id"
|
||||
@click.prevent="router.push(`/blog?tag=${tag.id}`)"
|
||||
href="#"
|
||||
class="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 hover:bg-art-accent/20 hover:border-art-accent/50 hover:text-art-accent text-xs text-art-muted transition-all duration-200"
|
||||
class="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-art-muted hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{{ tag.name }}
|
||||
# {{ tag.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐博客 -->
|
||||
<div class="p-6 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-xl transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<i data-lucide="sparkles" class="w-4 h-4 text-art-accent"></i>
|
||||
推荐阅读
|
||||
</h3>
|
||||
<div v-if="loadingRecommendations" class="flex justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-art-accent"></div>
|
||||
<!-- 专栏/分类补充 -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div v-if="post.columnName" class="flex items-center gap-2 text-sm text-art-muted">
|
||||
<Icon name="book-open" :size="14" class="text-art-accent" />
|
||||
专栏: <span class="text-white">{{ post.columnName }}</span>
|
||||
</div>
|
||||
<div v-else-if="recommendedPosts.length === 0" class="text-xs text-art-muted italic py-4">
|
||||
暂无推荐
|
||||
<div v-if="post.categoryName" class="flex items-center gap-2 text-sm text-art-muted">
|
||||
<Icon name="folder" :size="14" class="text-art-accent" />
|
||||
分类: <span class="text-white">{{ post.categoryName }}</span>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部推荐阅读 -->
|
||||
<div class="mt-12 md:mt-20 border-t border-white/10 pt-10">
|
||||
<h3 class="text-lg font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Icon name="sparkles" :size="20" class="text-art-accent" />
|
||||
推荐阅读
|
||||
</h3>
|
||||
<div v-if="loadingRecommendations" class="flex justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-art-accent"></div>
|
||||
</div>
|
||||
<div v-else-if="recommendedPosts.length === 0" class="text-sm text-art-muted italic">
|
||||
暂无推荐
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<router-link
|
||||
v-for="recPost in recommendedPosts"
|
||||
:key="recPost.id"
|
||||
:to="`/blog/${recPost.id}`"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a
|
||||
v-for="recPost in recommendedPosts"
|
||||
:key="recPost.id"
|
||||
@click.prevent="router.push(`/blog/${recPost.id}`)"
|
||||
href="#"
|
||||
class="block p-4 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-200 group"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block p-5 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 hover:border-art-accent/50 transition-all duration-300 group h-full"
|
||||
>
|
||||
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors mb-2 line-clamp-2">
|
||||
<div class="text-base font-medium text-white group-hover:text-art-accent transition-colors mb-3 line-clamp-2">
|
||||
{{ recPost.title }}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-art-muted">
|
||||
<div class="flex items-center justify-between text-xs text-art-muted mt-auto">
|
||||
<span v-if="recPost.categoryName" class="px-2 py-0.5 rounded border border-white/20">
|
||||
{{ recPost.categoryName }}
|
||||
</span>
|
||||
<span>{{ recPost.date }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 右侧边栏:目录 (TOC) - 仅在大屏显示 -->
|
||||
<aside class="hidden xl:block w-64 flex-shrink-0 order-3 sticky top-32 max-h-[calc(100vh-8rem)] overflow-y-auto hide-scrollbar">
|
||||
<!-- 目录导航 -->
|
||||
<div class="p-5 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-lg transition-all duration-300 hover:bg-white/10">
|
||||
<h3 class="text-xs font-bold text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<Icon name="list" :size="14" class="text-art-accent" />
|
||||
目录导航
|
||||
</h3>
|
||||
<nav class="space-y-1 relative">
|
||||
<!-- 激活状态的指示条 -->
|
||||
<div
|
||||
class="absolute left-0 w-0.5 bg-art-accent transition-all duration-300 ease-out"
|
||||
:style="activeIndicatorStyle"
|
||||
v-if="toc.length > 0"
|
||||
></div>
|
||||
|
||||
<ul class="space-y-1 border-l border-white/10 ml-[1px]">
|
||||
<li v-for="item in toc" :key="item.id">
|
||||
<a
|
||||
@click.prevent="scrollToHeading(item.id)"
|
||||
href="#"
|
||||
class="block text-xs py-1.5 transition-all duration-200 border-l-2 border-transparent pl-4 hover:text-white truncate"
|
||||
:class="[
|
||||
activeId === item.id ? 'text-art-accent font-medium' : 'text-art-muted',
|
||||
item.level > 2 ? 'pl-8' : 'pl-4'
|
||||
]"
|
||||
:title="item.text"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="toc.length === 0" class="text-xs text-art-muted italic pl-4">
|
||||
暂无目录
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile TOC Floating Button -->
|
||||
<button
|
||||
v-if="toc.length > 0 && !loading && !error"
|
||||
@click="showMobileToc = true"
|
||||
class="xl:hidden fixed bottom-8 right-6 z-50 w-12 h-12 bg-art-accent/90 backdrop-blur text-white rounded-full shadow-lg shadow-art-accent/20 flex items-center justify-center hover:bg-art-accent active:scale-95 transition-all duration-200"
|
||||
aria-label="目录"
|
||||
>
|
||||
<Icon name="list" :size="20" />
|
||||
</button>
|
||||
|
||||
<!-- Mobile TOC Drawer -->
|
||||
<div v-if="showMobileToc" class="fixed inset-0 z-[100] xl:hidden">
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" @click="showMobileToc = false"></div>
|
||||
|
||||
<!-- Drawer Content -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-[#1a1a1a] border-t border-white/10 rounded-t-2xl max-h-[70vh] flex flex-col animate-slide-up shadow-2xl">
|
||||
<div class="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h3 class="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Icon name="list" :size="16" class="text-art-accent" />
|
||||
文章目录
|
||||
</h3>
|
||||
<button @click="showMobileToc = false" class="text-art-muted hover:text-white p-2">
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto p-4 flex-1">
|
||||
<nav class="space-y-1">
|
||||
<ul class="space-y-2">
|
||||
<li v-for="item in toc" :key="item.id">
|
||||
<a
|
||||
@click.prevent="scrollToHeading(item.id); showMobileToc = false"
|
||||
href="#"
|
||||
class="block text-sm py-1 transition-all duration-200 hover:text-white"
|
||||
:class="[
|
||||
activeId === item.id ? 'text-art-accent font-medium' : 'text-art-muted',
|
||||
item.level > 2 ? 'pl-4' : ''
|
||||
]"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -202,6 +301,7 @@ import { ref, onMounted, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchPost, getRecommendedPosts, Post, getPublicSettings } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
// 引入 Markdown 解析依赖
|
||||
import MarkdownIt from 'markdown-it'
|
||||
@@ -210,10 +310,7 @@ import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/atom-one-dark.css'
|
||||
|
||||
const route = useRoute()
|
||||
// Initialize router for template use ($router.push)
|
||||
const router = useRouter()
|
||||
// Mark router as used (it's used in template via $router)
|
||||
void router
|
||||
const postId = route.params.id as string
|
||||
const articleRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -226,6 +323,10 @@ interface TocItem {
|
||||
const toc = ref<TocItem[]>([])
|
||||
const activeId = ref<string>('')
|
||||
const observer = ref<IntersectionObserver | null>(null)
|
||||
const showMobileToc = ref(false)
|
||||
// 核心修复 1: 引入点击滚动标志位
|
||||
const isClickScrolling = ref(false)
|
||||
let scrollTimeout: number | null = null
|
||||
|
||||
const post = ref<Post>({
|
||||
id: Number(postId) || 0,
|
||||
@@ -256,14 +357,12 @@ md.renderer.rules.heading_open = function (tokens, idx) {
|
||||
const contentToken = tokens[idx + 1]
|
||||
const title = contentToken ? contentToken.content : ''
|
||||
|
||||
// 生成简单的 slug ID (移除特殊字符,转小写)
|
||||
// 注意:这只是一个简单的处理,实际生产中可能需要更严谨的 slugify 库
|
||||
const slug = 'heading-' + title.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-') + '-' + idx
|
||||
|
||||
return `<${token.tag} id="${slug}">`
|
||||
}
|
||||
|
||||
// 自定义 fence 渲染规则以实现 iOS 风格代码块 + 行号 + 复制功能
|
||||
// 自定义 fence 渲染规则
|
||||
md.renderer.rules.fence = function (tokens, idx) {
|
||||
const token = tokens[idx]
|
||||
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
|
||||
@@ -274,7 +373,6 @@ md.renderer.rules.fence = function (tokens, idx) {
|
||||
langName = info.split(/\s+/g)[0]
|
||||
}
|
||||
|
||||
// 1. 代码高亮处理
|
||||
if (langName && hljs.getLanguage(langName)) {
|
||||
try {
|
||||
highlighted = hljs.highlight(token.content, { language: langName, ignoreIllegals: true }).value
|
||||
@@ -285,11 +383,9 @@ md.renderer.rules.fence = function (tokens, idx) {
|
||||
highlighted = md.utils.escapeHtml(token.content)
|
||||
}
|
||||
|
||||
// 2. 生成行号
|
||||
const lines = token.content.trimEnd().split('\n')
|
||||
const lineNumbers = lines.map((_, i) => i + 1).join('\n')
|
||||
|
||||
// 3. 构建 iOS 风格 HTML 结构
|
||||
return `
|
||||
<div class="ios-code-container my-10 rounded-xl overflow-hidden shadow-2xl bg-[#1e1e1e] border border-white/5 relative group/code">
|
||||
<!-- Window Header -->
|
||||
@@ -326,17 +422,14 @@ md.renderer.rules.fence = function (tokens, idx) {
|
||||
`
|
||||
}
|
||||
|
||||
// 计算属性:将 Markdown 内容转换为 HTML
|
||||
const renderedContent = computed(() => {
|
||||
if (!post.value.content) return ''
|
||||
return md.render(post.value.content)
|
||||
})
|
||||
|
||||
// 生成目录结构
|
||||
const generateToc = () => {
|
||||
if (!articleRef.value) return
|
||||
|
||||
// 查找所有 h2, h3 标签
|
||||
const headers = articleRef.value.querySelectorAll('h2, h3')
|
||||
const newToc: TocItem[] = []
|
||||
|
||||
@@ -360,18 +453,21 @@ const initScrollSpy = () => {
|
||||
const headers = articleRef.value.querySelectorAll('h2, h3')
|
||||
if (headers.length === 0) return
|
||||
|
||||
// 断开旧的观察者
|
||||
if (observer.value) observer.value.disconnect()
|
||||
|
||||
// 创建新的观察者
|
||||
// rootMargin: '-10% 0px -80% 0px' 意味着当标题进入视口顶部 10% 到 20% 的区域时触发
|
||||
observer.value = new IntersectionObserver((entries) => {
|
||||
// 核心修复 2: 如果是点击目录触发的滚动,不更新 activeId,防止抽搐
|
||||
if (isClickScrolling.value) return
|
||||
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
activeId.value = entry.target.id
|
||||
}
|
||||
})
|
||||
}, { rootMargin: '-80px 0px -80% 0px' })
|
||||
}, {
|
||||
// 微调 rootMargin,只检测顶部区域
|
||||
rootMargin: '-100px 0px -80% 0px'
|
||||
})
|
||||
|
||||
headers.forEach(header => {
|
||||
observer.value?.observe(header)
|
||||
@@ -382,7 +478,13 @@ const initScrollSpy = () => {
|
||||
const scrollToHeading = (id: string) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
// 减去顶部导航栏的高度偏移 (比如 100px)
|
||||
// 核心修复 3: 开启点击滚动锁,并立即设置高亮
|
||||
isClickScrolling.value = true
|
||||
activeId.value = id
|
||||
|
||||
// 清除可能存在的旧定时器
|
||||
if (scrollTimeout) clearTimeout(scrollTimeout)
|
||||
|
||||
const offset = 100
|
||||
const bodyRect = document.body.getBoundingClientRect().top
|
||||
const elementRect = element.getBoundingClientRect().top
|
||||
@@ -394,18 +496,16 @@ const scrollToHeading = (id: string) => {
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
// 手动更新 activeId,防止滚动动画期间观察者跳动
|
||||
activeId.value = id
|
||||
// 1秒后解锁,此时平滑滚动应该已经结束
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
isClickScrolling.value = false
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算指示条样式
|
||||
const activeIndicatorStyle = computed(() => {
|
||||
const index = toc.value.findIndex(item => item.id === activeId.value)
|
||||
if (index === -1) return { top: '0', opacity: 0 }
|
||||
|
||||
// 假设每个目录项高度大约是 28px (text-xs + py-1.5 + gap)
|
||||
// 这里可以根据实际 DOM 计算更精确,简单起见用固定高度估算
|
||||
const itemHeight = 28
|
||||
return {
|
||||
top: `${index * itemHeight}px`,
|
||||
@@ -414,28 +514,21 @@ const activeIndicatorStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 处理复制功能的逻辑
|
||||
const addCopyListeners = () => {
|
||||
if (!articleRef.value) return
|
||||
|
||||
const buttons = articleRef.value.querySelectorAll('.copy-btn')
|
||||
|
||||
buttons.forEach(btn => {
|
||||
const newBtn = btn.cloneNode(true) as HTMLElement
|
||||
btn.parentNode?.replaceChild(newBtn, btn)
|
||||
|
||||
newBtn.addEventListener('click', async () => {
|
||||
const container = newBtn.closest('.ios-code-container')
|
||||
const codeElement = container?.querySelector('code')
|
||||
|
||||
if (codeElement) {
|
||||
try {
|
||||
const text = codeElement.innerText
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
const originalContent = newBtn.innerHTML
|
||||
newBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-green-400"><polyline points="20 6 9 17 4 12"/></svg><span class="text-green-400 text-xs font-medium">已复制</span>`
|
||||
|
||||
setTimeout(() => {
|
||||
newBtn.innerHTML = originalContent
|
||||
}, 2000)
|
||||
@@ -447,7 +540,6 @@ const addCopyListeners = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 更新页面标题
|
||||
const updatePageTitle = async (articleTitle: string) => {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
@@ -455,7 +547,6 @@ const updatePageTitle = async (articleTitle: string) => {
|
||||
document.title = `${siteTitle} | ${articleTitle}`
|
||||
} catch (error) {
|
||||
console.error('Failed to update page title:', error)
|
||||
// 即使获取配置失败,也更新标题
|
||||
document.title = `年糕崽崽.Dev | ${articleTitle}`
|
||||
}
|
||||
}
|
||||
@@ -467,23 +558,16 @@ const fetchBlogDetail = async () => {
|
||||
const postData = await fetchPost(postId)
|
||||
if (postData) {
|
||||
post.value = postData
|
||||
|
||||
// 更新页面标题
|
||||
if (postData.title) {
|
||||
await updatePageTitle(postData.title)
|
||||
}
|
||||
|
||||
// 获取推荐文章
|
||||
fetchRecommendations()
|
||||
|
||||
// 初始化动画、事件监听和目录 - 在数据加载完成后
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
initObserver()
|
||||
addCopyListeners()
|
||||
generateToc() // 生成目录
|
||||
initScrollSpy() // 启动滚动监听
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
generateToc()
|
||||
initScrollSpy()
|
||||
}, 100)
|
||||
})
|
||||
} else {
|
||||
@@ -497,7 +581,6 @@ const fetchBlogDetail = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取推荐文章
|
||||
const fetchRecommendations = async () => {
|
||||
loadingRecommendations.value = true
|
||||
try {
|
||||
@@ -519,13 +602,23 @@ onBeforeUnmount(() => {
|
||||
if (observer.value) {
|
||||
observer.value.disconnect()
|
||||
}
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Tailwind Typography (:prose) 覆盖 */
|
||||
/* 隐藏滚动条但保留滚动功能 */
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 移除 prose 默认的 pre 样式,因为我们自定义了容器 */
|
||||
/* Tailwind Typography (:prose) 覆盖 */
|
||||
:deep(.prose pre) {
|
||||
background-color: transparent !important;
|
||||
margin: 0 !important;
|
||||
@@ -535,12 +628,10 @@ onBeforeUnmount(() => {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* 代码字体栈 */
|
||||
:deep(.prose code) {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* 行内代码样式优化 */
|
||||
:deep(.prose :not(pre) > code) {
|
||||
color: #d4b383;
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
@@ -549,14 +640,13 @@ onBeforeUnmount(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 标题样式 - 增加 scroll-margin-top 以防止锚点跳转被顶部遮挡 */
|
||||
:deep(.prose h1), :deep(.prose h2), :deep(.prose h3) {
|
||||
color: #fff;
|
||||
font-family: serif;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.3;
|
||||
scroll-margin-top: 120px; /* 关键:确保点击目录跳转时有足够顶部留白 */
|
||||
scroll-margin-top: 120px;
|
||||
}
|
||||
|
||||
:deep(.prose h2) {
|
||||
@@ -570,7 +660,6 @@ onBeforeUnmount(() => {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
:deep(.prose a) {
|
||||
color: #d4b383;
|
||||
text-decoration: none;
|
||||
@@ -583,7 +672,6 @@ onBeforeUnmount(() => {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
/* 引用块样式 */
|
||||
:deep(.prose blockquote) {
|
||||
border-left: 3px solid #d4b383;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
@@ -597,7 +685,6 @@ onBeforeUnmount(() => {
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
:deep(.code-block-wrapper::-webkit-scrollbar) {
|
||||
height: 8px;
|
||||
background-color: transparent;
|
||||
@@ -619,4 +706,19 @@ onBeforeUnmount(() => {
|
||||
:deep(.hljs) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* 移动端动画 */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="pt-8 flex items-center gap-6">
|
||||
<button @click="$router.push('/works')" class="group flex items-center gap-2 text-white border-b border-white pb-1 hover:text-art-accent hover:border-art-accent transition-all">
|
||||
<span>浏览作品集</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4 transform group-hover:translate-x-1 transition-transform"></i>
|
||||
<Icon name="arrow-right" :size="16" class="transform group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
>
|
||||
<div class="text-white/50 text-xs">最新作品</div>
|
||||
<div class="font-serif text-3xl italic text-white">{{ latestWork.title }}</div>
|
||||
<div class="flex justify-end"><i data-lucide="arrow-up-right" class="text-white"></i></div>
|
||||
<div class="flex justify-end"><Icon name="arrow-up-right" :size="24" class="text-white" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,8 +63,8 @@
|
||||
class="art-card rounded-2xl p-8 flex flex-col justify-between group"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<i data-lucide="code-2" class="w-8 h-8 text-white/40 group-hover:text-art-accent transition-colors"></i>
|
||||
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white/20"></i>
|
||||
<Icon name="code-2" :size="32" class="text-white/40 group-hover:text-art-accent transition-colors" />
|
||||
<Icon name="arrow-up-right" :size="20" class="text-white/20" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-4xl font-mono font-bold text-white mb-2">120+</div>
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<span>访问代码实验室</span>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-art-accent"></i>
|
||||
<Icon name="chevron-right" :size="16" class="text-art-accent" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -96,7 +96,7 @@
|
||||
<p class="text-art-muted max-w-sm">不论是 WebGL 3D 交互网站,还是高性能的 SaaS 管理系统,我都能提供专业的解决方案。</p>
|
||||
</div>
|
||||
<div class="h-16 w-16 rounded-full border border-white/20 flex items-center justify-center group-hover:bg-white group-hover:text-black transition-all duration-300">
|
||||
<i data-lucide="arrow-right" class="w-6 h-6"></i>
|
||||
<Icon name="arrow-right" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,6 +108,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchWorks, fetchPosts, Work, Post } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const { initObserver } = useScrollAnimation()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<section id="work-detail" class="work-detail-page page-section block">
|
||||
<button @click="$router.push('/works')" class="fixed top-8 right-8 z-[60] mix-blend-difference text-white hover:scale-110 transition-transform">
|
||||
<div class="rounded-full border border-white/20 p-4 backdrop-blur-md bg-white/5"><i data-lucide="x" class="w-6 h-6"></i></div>
|
||||
<div class="rounded-full border border-white/20 p-4 backdrop-blur-md bg-white/5"><Icon name="x" :size="24" /></div>
|
||||
</button>
|
||||
<div class="fixed top-0 left-0 h-1 bg-art-accent z-[60] w-0 transition-all duration-100" id="scroll-progress"></div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<!-- Play Button Overlay -->
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-24 h-24 rounded-full border border-white/20 bg-white/5 backdrop-blur-md flex items-center justify-center cursor-pointer hover:scale-110 hover:bg-white/10 transition-all duration-500 group-hover:border-art-accent/50 z-20">
|
||||
<i data-lucide="play" class="w-8 h-8 text-white fill-white ml-1"></i>
|
||||
<Icon name="play" :size="32" class="text-white fill-white ml-1" />
|
||||
<div class="absolute inset-0 rounded-full border border-white/20 animate-pulse-slow"></div>
|
||||
</div>
|
||||
|
||||
@@ -77,10 +77,10 @@
|
||||
class="group flex items-center justify-between w-full py-4 border-t border-white/10 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span class="font-serif text-xl italic text-white group-hover:text-art-accent transition-colors flex items-center gap-3">
|
||||
<i data-lucide="globe" class="w-5 h-5"></i>
|
||||
<Icon name="globe" :size="20" />
|
||||
访问线上项目
|
||||
</span>
|
||||
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white group-hover:rotate-45 transition-transform"></i>
|
||||
<Icon name="arrow-up-right" :size="20" class="text-white group-hover:rotate-45 transition-transform" />
|
||||
</a>
|
||||
<!-- Demo Link -->
|
||||
<a
|
||||
@@ -91,10 +91,10 @@
|
||||
class="group flex items-center justify-between w-full py-4 border-t border-white/10 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span class="font-serif text-xl italic text-white group-hover:text-art-accent transition-colors flex items-center gap-3">
|
||||
<i data-lucide="play-circle" class="w-5 h-5"></i>
|
||||
<Icon name="play-circle" :size="20" />
|
||||
查看演示
|
||||
</span>
|
||||
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white group-hover:rotate-45 transition-transform"></i>
|
||||
<Icon name="arrow-up-right" :size="20" class="text-white group-hover:rotate-45 transition-transform" />
|
||||
</a>
|
||||
<!-- GitHub Link -->
|
||||
<a
|
||||
@@ -105,10 +105,10 @@
|
||||
class="group flex items-center justify-between w-full py-4 border-t border-white/10 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span class="font-serif text-xl italic text-white group-hover:text-art-accent transition-colors flex items-center gap-3">
|
||||
<i data-lucide="github" class="w-5 h-5"></i>
|
||||
<Icon name="github" :size="20" />
|
||||
查看源码
|
||||
</span>
|
||||
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white group-hover:rotate-45 transition-transform"></i>
|
||||
<Icon name="arrow-up-right" :size="20" class="text-white group-hover:rotate-45 transition-transform" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,6 +152,7 @@ import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchWork, Work } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -226,10 +227,7 @@ watch(() => route.params.id, (newId) => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkDetails()
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
// Icons are now handled by Vue components
|
||||
// 添加滚动事件监听
|
||||
window.addEventListener('scroll', updateScrollProgress)
|
||||
})
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
@click.stop
|
||||
class="inline-flex items-center gap-2 px-4 py-2 border border-white/10 rounded-full text-xs text-white/70 hover:border-art-accent hover:text-art-accent transition-all"
|
||||
>
|
||||
<i data-lucide="globe" class="w-3 h-3"></i>
|
||||
<Icon name="globe" :size="12" />
|
||||
线上项目
|
||||
</a>
|
||||
<a
|
||||
@@ -87,7 +87,7 @@
|
||||
@click.stop
|
||||
class="inline-flex items-center gap-2 px-4 py-2 border border-white/10 rounded-full text-xs text-white/70 hover:border-art-accent hover:text-art-accent transition-all"
|
||||
>
|
||||
<i data-lucide="play-circle" class="w-3 h-3"></i>
|
||||
<Icon name="play-circle" :size="12" />
|
||||
演示
|
||||
</a>
|
||||
<a
|
||||
@@ -97,14 +97,14 @@
|
||||
@click.stop
|
||||
class="inline-flex items-center gap-2 px-4 py-2 border border-white/10 rounded-full text-xs text-white/70 hover:border-art-accent hover:text-art-accent transition-all"
|
||||
>
|
||||
<i data-lucide="github" class="w-3 h-3"></i>
|
||||
<Icon name="github" :size="12" />
|
||||
源码
|
||||
</a>
|
||||
</div>
|
||||
<!-- 查看详情按钮 -->
|
||||
<div class="pt-4" :class="[index % 2 === 1 ? 'flex md:justify-end' : '']">
|
||||
<span class="inline-flex items-center gap-2 text-sm font-medium text-white border-b border-transparent hover:border-art-accent hover:text-art-accent transition-all">
|
||||
查看项目详情 <i data-lucide="arrow-right" class="w-4 h-4"></i>
|
||||
查看项目详情 <Icon name="arrow-right" :size="16" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +118,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchWorks, Work } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const works = ref<Work[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -142,9 +143,6 @@ const fetchWorksData = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorksData()
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
// Icons are now handled by Vue components
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user