Files
nl-blogs/client/src/pages/BlogDetail.vue
2026-01-20 09:13:51 +08:00

484 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section id="blog-detail" class="page-section block animate-slide-down">
<div class="max-w-4xl mx-auto pt-32 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"
>
<i data-lucide="arrow-left" class="w-4 h-4 transform group-hover:-translate-x-1 transition-transform"></i>
<span class="text-sm font-medium tracking-wide">返回文章列表</span>
</button>
<!-- Loading state -->
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center text-red-500 py-20">
<p class="mb-4">{{ error }}</p>
<button @click="fetchBlogDetail" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
<button @click="$router.push('/blog')" class="mt-4 px-4 py-2 border border-white/30 text-white rounded hover:bg-white/5 transition-colors">
返回文章列表
</button>
</div>
<!-- Blog detail content -->
<div v-else class="space-y-12 relative">
<!-- 目录侧边栏 (仅在 XL 屏幕显示) -->
<aside class="hidden xl:block absolute left-full ml-12 top-0 h-full">
<div class="sticky top-32 w-64 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>
</aside>
<!-- 文章信息 -->
<div class="border-b border-white/5 pb-10">
<div class="flex items-center gap-4 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>
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full overflow-hidden border border-white/20">
<img
src="https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao"
alt="Avatar"
class="w-full h-full object-cover"
>
</div>
<div>
<div class="text-sm font-medium text-white">年糕崽崽</div>
<div class="text-xs text-art-muted">前端架构师</div>
</div>
</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">
<div v-html="renderedContent"></div>
</article>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchPost, Post } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
// 引入 Markdown 解析依赖
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
// 引入代码高亮样式 (Atom One Dark)
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)
// 目录相关状态
interface TocItem {
id: string
text: string
level: number
}
const toc = ref<TocItem[]>([])
const activeId = ref<string>('')
const observer = ref<IntersectionObserver | null>(null)
const post = ref<Post>({
id: Number(postId) || 0,
title: '',
categoryId: 0,
date: '',
excerpt: '',
content: ''
})
const loading = ref(false)
const error = ref('')
const { initObserver } = useScrollAnimation()
// 初始化 MarkdownIt 实例
const md = new MarkdownIt({
html: true, // 允许 HTML 标签
linkify: true, // 自动转换 URL 为链接
typographer: true, // 优化排版
})
// 自定义 heading 渲染规则:自动添加 ID
md.renderer.rules.heading_open = function (tokens, idx) {
const token = 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 风格代码块 + 行号 + 复制功能
md.renderer.rules.fence = function (tokens, idx) {
const token = tokens[idx]
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
let langName = ''
let highlighted
if (info) {
langName = info.split(/\s+/g)[0]
}
// 1. 代码高亮处理
if (langName && hljs.getLanguage(langName)) {
try {
highlighted = hljs.highlight(token.content, { language: langName, ignoreIllegals: true }).value
} catch (__) {
highlighted = md.utils.escapeHtml(token.content)
}
} else {
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 -->
<div class="mac-window-header flex items-center justify-between px-4 py-3 bg-[#282828] border-b border-white/5">
<div class="flex items-center gap-4">
<div class="flex gap-2">
<div class="w-3 h-3 rounded-full bg-[#ff5f56]"></div>
<div class="w-3 h-3 rounded-full bg-[#ffbd2e]"></div>
<div class="w-3 h-3 rounded-full bg-[#27c93f]"></div>
</div>
<div class="text-xs text-white/30 font-mono select-none uppercase tracking-wider">${langName || 'TEXT'}</div>
</div>
<!-- Copy Button -->
<button class="copy-btn flex items-center gap-1.5 px-2 py-1 rounded hover:bg-white/10 transition-all cursor-pointer opacity-0 group-hover/code:opacity-100" aria-label="Copy code">
<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-white/40"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<span class="text-xs text-white/40 font-medium">复制</span>
</button>
</div>
<!-- Code Body -->
<div class="code-content-wrapper flex">
<!-- Line Numbers -->
<div class="line-numbers-wrapper select-none text-right border-r border-white/5 bg-[#1e1e1e] py-4 px-3 min-w-[3rem] text-white/20">
<pre class="font-mono text-sm leading-relaxed" style="margin:0;">${lineNumbers}</pre>
</div>
<!-- Code Content -->
<div class="code-block-wrapper flex-1 overflow-x-auto py-4 px-4 bg-[#1e1e1e]">
<pre class="hljs !bg-transparent !m-0 !p-0 !rounded-none"><code class="!font-mono text-sm leading-relaxed block">${highlighted}</code></pre>
</div>
</div>
</div>
`
}
// 计算属性:将 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[] = []
headers.forEach((header) => {
if (header.id && header.textContent) {
newToc.push({
id: header.id,
text: header.textContent,
level: parseInt(header.tagName.substring(1))
})
}
})
toc.value = newToc
}
// 滚动监听逻辑
const initScrollSpy = () => {
if (!articleRef.value) return
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) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
activeId.value = entry.target.id
}
})
}, { rootMargin: '-80px 0px -80% 0px' })
headers.forEach(header => {
observer.value?.observe(header)
})
}
// 点击滚动到标题
const scrollToHeading = (id: string) => {
const element = document.getElementById(id)
if (element) {
// 减去顶部导航栏的高度偏移 (比如 100px)
const offset = 100
const bodyRect = document.body.getBoundingClientRect().top
const elementRect = element.getBoundingClientRect().top
const elementPosition = elementRect - bodyRect
const offsetPosition = elementPosition - offset
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
})
// 手动更新 activeId防止滚动动画期间观察者跳动
activeId.value = id
}
}
// 计算指示条样式
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`,
height: `${itemHeight}px`,
opacity: 1
}
})
// 处理复制功能的逻辑
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)
} catch (err) {
console.error('Copy failed:', err)
}
}
})
})
}
const fetchBlogDetail = async () => {
loading.value = true
error.value = ''
try {
const postData = await fetchPost(postId)
if (postData) {
post.value = postData
// 初始化动画、事件监听和目录 - 在数据加载完成后
nextTick(() => {
setTimeout(() => {
initObserver()
addCopyListeners()
generateToc() // 生成目录
initScrollSpy() // 启动滚动监听
if (window.lucide) window.lucide.createIcons()
}, 100)
})
} else {
error.value = '未找到该文章'
}
} catch (err) {
console.error('Error fetching blog detail:', err)
error.value = '获取文章详情失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(() => {
fetchBlogDetail()
})
onBeforeUnmount(() => {
if (observer.value) {
observer.value.disconnect()
}
})
</script>
<style scoped>
/* Tailwind Typography (:prose) 覆盖 */
/* 移除 prose 默认的 pre 样式,因为我们自定义了容器 */
:deep(.prose pre) {
background-color: transparent !important;
margin: 0 !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
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);
padding: 0.2em 0.4em;
border-radius: 0.3em;
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; /* 关键:确保点击目录跳转时有足够顶部留白 */
}
:deep(.prose h2) {
font-size: 1.8rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 0.5rem;
}
:deep(.prose h3) {
font-size: 1.4rem;
color: rgba(255, 255, 255, 0.9);
}
/* 链接样式 */
:deep(.prose a) {
color: #d4b383;
text-decoration: none;
border-bottom: 1px dashed rgba(212, 179, 131, 0.5);
transition: all 0.2s;
}
:deep(.prose a:hover) {
color: #fff;
border-bottom-style: solid;
}
/* 引用块样式 */
:deep(.prose blockquote) {
border-left: 3px solid #d4b383;
background: rgba(255, 255, 255, 0.02);
padding: 1rem 1.5rem;
border-radius: 0 0.5rem 0.5rem 0;
color: rgba(255, 255, 255, 0.7);
font-style: italic;
}
:deep(.prose ul li::marker) {
color: #d4b383;
}
/* 自定义滚动条 */
:deep(.code-block-wrapper::-webkit-scrollbar) {
height: 8px;
background-color: transparent;
}
:deep(.code-block-wrapper::-webkit-scrollbar-thumb) {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
:deep(.code-block-wrapper::-webkit-scrollbar-thumb:hover) {
background-color: rgba(255, 255, 255, 0.2);
}
:deep(.code-block-wrapper::-webkit-scrollbar-corner) {
background-color: transparent;
}
:deep(.hljs) {
background: transparent !important;
}
</style>