88999c4961
- 调整全站毛玻璃背景、光晕与渐变的透明度,降低色彩饱和度使视觉更柔和 - 替换 $http 全局类型声明,改用具体模块类型替代 any - 增强 API 请求函数类型,使用 Record<string, unknown> 替代 any,新增泛型响应接口 - 修复热搜标题与热度数字颜色,支持 hover 变色 - 新增四川母暴龙自定义代理配置文件
915 lines
31 KiB
Vue
915 lines
31 KiB
Vue
<template>
|
||
<div
|
||
class="relative overflow-hidden bg-[radial-gradient(circle_at_12%_18%,rgba(219,95,81,0.05),transparent_24%),radial-gradient(circle_at_86%_12%,rgba(249,154,159,0.04),transparent_24%),radial-gradient(circle_at_50%_100%,rgba(222,217,178,0.03),transparent_28%),linear-gradient(180deg,#fcfaf6_0%,#fbf8f7_44%,#fefcfb_100%)]"
|
||
:style="boxStyle">
|
||
<div
|
||
class="pointer-events-none absolute left-[-5rem] top-6 h-80 w-80 rounded-full bg-[rgba(219,95,81,0.10)] opacity-48 blur-[88px]">
|
||
</div>
|
||
<div
|
||
class="pointer-events-none absolute right-[-5rem] top-32 h-[22rem] w-[22rem] rounded-full bg-[rgba(222,217,178,0.08)] opacity-48 blur-[88px]">
|
||
</div>
|
||
<div
|
||
class="pointer-events-none absolute inset-0 opacity-14 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:36px_36px] [mask-image:linear-gradient(180deg,rgba(0,0,0,0.8),transparent_85%)]">
|
||
</div>
|
||
|
||
<!-- 阅读进度条 -->
|
||
<div class="pointer-events-none absolute inset-x-0 top-0 z-[60] h-[3px] bg-white/10">
|
||
<div
|
||
class="h-full rounded-r-full bg-gradient-to-r from-[#D71A44] via-[#DB5F51] to-[#DED9B2] transition-[width] duration-150 ease-out"
|
||
:style="{ width: `${progress}%` }"></div>
|
||
</div>
|
||
|
||
<div class="relative z-[1] mx-auto flex h-full w-[92%] max-w-[1500px] gap-6 py-6 xl:w-[86%]">
|
||
<aside v-if="!loading && !loadError" class="hidden w-[21rem] shrink-0 lg:block">
|
||
<div class="sticky top-6">
|
||
<section class="blog-detail-card p-6">
|
||
<div class="relative z-[1]">
|
||
<h1 class="mt-3 text-center text-[2rem] leading-[1.2] text-[#2c3a4a]">阅读目录</h1>
|
||
<div class="mx-auto mt-2 h-1 w-16 rounded-full bg-gradient-to-r from-[#D71A44] via-[#DB5F51] to-[#DED9B2] opacity-70">
|
||
</div>
|
||
|
||
<div class="blog-detail-card-soft mt-6 p-4">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<span class="text-sm font-semibold text-[#72515a]">目录</span>
|
||
<span class="rounded-full cursor-pointer bg-primary px-3 py-1 text-xs text-white">正文导航</span>
|
||
</div>
|
||
|
||
<div class="toc-list text-[13px] leading-5 text-[#72515a]">
|
||
<template v-for="item in tocItems" :key="item.id">
|
||
<details v-if="item.children.length" class="group" :open="item.open">
|
||
<summary
|
||
class="flex cursor-pointer list-none items-center gap-2 rounded-lg py-1 text-[#72515a] marker:hidden select-none">
|
||
<span
|
||
class="text-[11px] text-[#8a98a1] transition-transform duration-200 group-open:rotate-90">▶</span>
|
||
<span class="truncate transition-colors duration-200"
|
||
:class="isNodeActive(item) ? 'text-primary font-semibold' : 'hover:text-[#5c3d46]'"
|
||
@click.stop="scrollToHeading(item.id)">
|
||
{{ item.text }}
|
||
</span>
|
||
</summary>
|
||
<div class="ml-4 border-l border-[#d7dfdf] pl-3">
|
||
<div v-for="child in item.children" :key="child.id" class="py-1"
|
||
:style="{ paddingLeft: `${Math.min(child.level - 2, 3) * 10}px` }">
|
||
<div class="toc-child cursor-pointer truncate rounded-md transition-colors duration-200"
|
||
:class="activeHeadingId === child.id ? 'toc-active text-primary font-semibold' : 'text-[#687983] hover:text-[#5c3d46]'"
|
||
@click="scrollToHeading(child.id)">
|
||
{{ child.text }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<div v-else class="toc-leaf cursor-pointer rounded-md py-1 transition-colors duration-200"
|
||
:class="activeHeadingId === item.id ? 'toc-active text-primary font-semibold' : 'text-[#72515a] hover:text-[#5c3d46]'"
|
||
@click="scrollToHeading(item.id)">
|
||
{{ item.text }}
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="tocItems.length === 0" class="px-3 py-2 text-sm text-[#8a6267]">
|
||
暂无可生成的目录
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="blog-list-panel min-w-0 flex-1 overflow-hidden p-4 sm:p-6">
|
||
<!-- 加载骨架屏 -->
|
||
<div v-if="loading" class="flex h-full flex-col gap-5">
|
||
<section class="blog-detail-card p-6">
|
||
<div class="flex items-center justify-between">
|
||
<div class="blog-skeleton h-5 w-24 rounded-full"></div>
|
||
<div class="blog-skeleton h-7 w-40 rounded-full"></div>
|
||
</div>
|
||
<div class="mt-6 space-y-3">
|
||
<div class="blog-skeleton mx-auto h-9 w-2/3 rounded-xl"></div>
|
||
<div class="blog-skeleton mx-auto h-4 w-1/3 rounded-full"></div>
|
||
<div class="mx-auto mt-5 flex gap-3">
|
||
<div class="blog-skeleton h-6 w-16 rounded-full"></div>
|
||
<div class="blog-skeleton h-6 w-20 rounded-full"></div>
|
||
<div class="blog-skeleton h-6 w-14 rounded-full"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="blog-detail-card p-6">
|
||
<div class="blog-skeleton h-10 w-36 rounded-xl"></div>
|
||
<div class="mt-6 space-y-3">
|
||
<div class="blog-skeleton h-4 w-full rounded-full"></div>
|
||
<div class="blog-skeleton h-4 w-11/12 rounded-full"></div>
|
||
<div class="blog-skeleton h-4 w-4/5 rounded-full"></div>
|
||
<div class="blog-skeleton h-4 w-full rounded-full"></div>
|
||
<div class="blog-skeleton h-4 w-2/3 rounded-full"></div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- 加载失败 -->
|
||
<div v-else-if="loadError" class="flex h-full flex-col items-center justify-center gap-4 text-center">
|
||
<div class="blog-detail-card px-10 py-12">
|
||
<div class="text-[1.2rem] font-semibold text-[#5c3d46]">文章加载失败</div>
|
||
<p class="mt-2 text-sm text-[#8a98a1]">{{ loadError }}</p>
|
||
<button type="button" @click="reload"
|
||
class="mt-6 cursor-pointer rounded-full bg-primary px-6 py-2 text-sm text-white shadow-[0_10px_24px_rgba(128,118,126,0.24)] transition-transform hover:-translate-y-0.5">
|
||
重新加载
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<n-scrollbar ref="detailScrollbar" :style="contentStyle" trigger="none"
|
||
class="pr-1 mb-4 rounded-[3em] overflow-hidden">
|
||
<div class="space-y-5 pr-1">
|
||
<section class="blog-detail-card p-6">
|
||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||
<div class="min-w-0">
|
||
<div class="text-[0.82rem] font-semibold tracking-[0.16em] text-[#718195]">文章详情</div>
|
||
<h1
|
||
class="my-4 text-center text-[1.9rem] font-semibold leading-[1.3] text-[#5c3d46] sm:text-[2.15rem]">
|
||
{{ blogData.title || '文章内容' }}
|
||
</h1>
|
||
<div
|
||
class="mx-auto mb-1 h-1 w-24 rounded-full bg-gradient-to-r from-[#D71A44] via-[#DB5F51] to-[#DED9B2] opacity-70">
|
||
</div>
|
||
</div>
|
||
<n-breadcrumb class="shrink-0 rounded-full border border-white/52 bg-white/16 px-4 text-[#72515a]"
|
||
separator="|">
|
||
<n-breadcrumb-item href="/blog/blog">文章</n-breadcrumb-item>
|
||
<n-breadcrumb-item>{{ blogData.title }}</n-breadcrumb-item>
|
||
</n-breadcrumb>
|
||
</div>
|
||
<div
|
||
class="mt-2 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-sm text-[#708091]">
|
||
<em
|
||
class="flex items-center gap-1.5 rounded-full bg-white/46 px-3 py-1 not-italic text-primary shadow-[0_4px_12px_rgba(128,118,126,0.08)]">
|
||
<n-icon><icon-author /></n-icon>
|
||
{{ blogData.nickname }}
|
||
</em>
|
||
<em class="flex items-center gap-1.5 not-italic">
|
||
<n-icon><icon-date /></n-icon>
|
||
{{ formatTime(blogData.updated_at, 'YYYY年MM月DD日 hh时') }}
|
||
</em>
|
||
</div>
|
||
|
||
<div class="mt-5 flex flex-wrap justify-center gap-3">
|
||
<div v-for="(item, index) in tags" :key="index" :style="{ backgroundColor: getTagColor(item) }"
|
||
class="inline-flex cursor-pointer items-center gap-1 rounded-full px-3 py-1 text-sm text-white shadow-[0_8px_20px_rgba(128,118,126,0.16)] transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_12px_26px_rgba(128,118,126,0.28)]">
|
||
<n-icon><icon-tag /></n-icon>
|
||
{{ item }}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="blog-detail-card p-4 sm:p-5">
|
||
<n-collapse :expanded-names="aiExpanded ? ['ai-summary'] : []" arrow-placement="right"
|
||
@update:expanded-names="onAiExpand">
|
||
<n-collapse-item name="ai-summary">
|
||
<template #header>
|
||
<div class="flex items-center gap-2.5">
|
||
<div
|
||
class="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-[#D71A44] to-[#DB5F51] text-white shadow-[0_6px_14px_rgba(215,26,68,0.28)]">
|
||
<n-icon :class="aiState === 'loading' || aiState === 'streaming' ? 'animate-spin' : ''">
|
||
<icon-star class="h-4 w-4" />
|
||
</n-icon>
|
||
</div>
|
||
<div class="text-[20px] font-bold text-[#5c3d46]">AI 摘要</div>
|
||
</div>
|
||
</template>
|
||
<template #header-extra>
|
||
<div class="flex items-center gap-2 text-[0.78rem] text-[#8a98a1]">
|
||
<span v-if="aiState === 'loading'">正在思考…</span>
|
||
<span v-else-if="aiState === 'streaming'">生成中…</span>
|
||
<span v-else-if="aiState === 'done'">已完成</span>
|
||
<span v-else-if="aiState === 'error'">生成失败</span>
|
||
<span v-else>点击展开生成</span>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="whitespace-pre-line pt-1 text-[#66778b]">
|
||
<!-- 加载中 -->
|
||
<div v-if="aiState === 'loading'" class="flex items-center gap-3 py-2 text-[#8a98a1]">
|
||
<n-spin :size="18" />
|
||
<span class="text-sm">AI 正在阅读文章,请稍候…</span>
|
||
</div>
|
||
|
||
<!-- 流式输出 / 已完成 -->
|
||
<div v-else-if="aiState === 'streaming' || (aiState === 'done' && aiMsg)"
|
||
class="indent-[2em] leading-7">
|
||
{{ aiMsg }}<span v-if="aiState === 'streaming'" class="ai-cursor"></span>
|
||
</div>
|
||
|
||
<!-- 生成失败 -->
|
||
<div v-else-if="aiState === 'error'"
|
||
class="flex flex-col items-start gap-3 rounded-2xl bg-[#fff5f5] px-4 py-3 text-sm text-[#c96a6a]">
|
||
<span>摘要生成失败:{{ aiError || '未知错误' }}</span>
|
||
<n-button @click="triggerAi"
|
||
class="cursor-pointer rounded-full bg-primary px-4 py-1 text-xs text-white transition-opacity hover:opacity-85">
|
||
重新生成
|
||
</n-button>
|
||
</div>
|
||
|
||
<!-- 初始提示 -->
|
||
<div v-else class="py-2 text-sm text-[#8a98a1]">
|
||
点击上方标题展开,AI 将为你生成这篇文章的摘要
|
||
</div>
|
||
|
||
<!-- 完成后重新生成 -->
|
||
<div v-if="aiState === 'done' && aiMsg" class="mt-3 flex justify-end">
|
||
<n-button @click="triggerAi"
|
||
class="cursor-pointer rounded-full bg-white/60 px-4 py-1 text-xs text-[#8a6267] shadow-[0_8px_20px_rgba(128,118,126,0.1)] transition-all hover:text-primary">
|
||
重新生成
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
</n-collapse-item>
|
||
</n-collapse>
|
||
</section>
|
||
|
||
<section class="blog-detail-card px-5 py-6 sm:px-8 sm:py-8">
|
||
<div
|
||
class="pointer-events-none absolute inset-x-10 top-0 h-[3px] rounded-b-full bg-gradient-to-r from-[#D71A44] via-[#DB5F51] to-[#DED9B2] opacity-60">
|
||
</div>
|
||
<MdPreview class="relative article-preview !bg-transparent" :modelValue="markdown"
|
||
:onHtmlChanged="scheduleSyncHeadings" />
|
||
</section>
|
||
</div>
|
||
</n-scrollbar>
|
||
|
||
<button v-show="showBackToTop" type="button" aria-label="回到顶部"
|
||
class="absolute bottom-6 right-6 z-[3] flex h-11 w-11 items-center justify-center rounded-full outline-none border-none bg-white/24 text-primary shadow-[0_16px_28px_rgba(128,118,126,0.14),inset_0_1px_0_rgba(255,255,255,0.24)] backdrop-blur-[16px] transition-all duration-300 hover:-translate-y-1 hover:bg-white/36 hover:shadow-[0_20px_36px_rgba(128,118,126,0.22),inset_0_1px_0_rgba(255,255,255,0.32)]"
|
||
@click="scrollToTop">
|
||
<icon-backtop class="h-5 w-5" />
|
||
</button>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { formatTime } from '@/util';
|
||
import type { ExposeParam } from 'md-editor-v3';
|
||
import { MdPreview } from 'md-editor-v3';
|
||
import type { ScrollbarInst as NScrollbarInst } from 'naive-ui';
|
||
|
||
definePage({
|
||
name: 'blog/:bid',
|
||
path: '/blog/:bid',
|
||
meta: {
|
||
title: '文章详情',
|
||
}
|
||
})
|
||
|
||
interface BlogDetail {
|
||
aid: string
|
||
updated_at: string | number
|
||
title: string
|
||
nickname: string
|
||
tags: string
|
||
cont: string
|
||
}
|
||
|
||
interface BlogDetailResponse {
|
||
data: BlogDetail
|
||
}
|
||
|
||
interface RouteParams {
|
||
bid?: string
|
||
}
|
||
|
||
interface TocItem {
|
||
id: string
|
||
text: string
|
||
level: number
|
||
children: TocChildItem[]
|
||
open: boolean
|
||
}
|
||
|
||
interface TocChildItem {
|
||
id: string
|
||
text: string
|
||
level: number
|
||
}
|
||
|
||
interface DetailScrollbarInst extends NScrollbarInst {
|
||
scrollbarInstRef?: {
|
||
containerRef: HTMLElement | null
|
||
contentRef: HTMLElement | null
|
||
} | null
|
||
}
|
||
|
||
const SOFT_TAG_PALETTE = [
|
||
'#d46a7c',
|
||
'#c97956',
|
||
'#b9894d',
|
||
'#6f9d86',
|
||
'#5d92a8',
|
||
'#7d88b8',
|
||
'#9a78b5',
|
||
'#8f7d6b',
|
||
'#c86b8f',
|
||
'#cf7f6a',
|
||
'#a98f55',
|
||
'#7aa37b',
|
||
'#5d9f95',
|
||
'#6d8fc7',
|
||
'#8a80c4',
|
||
'#a16fa1',
|
||
'#b67b67',
|
||
'#927f72',
|
||
'#6f8b5f',
|
||
'#4f8aa8',
|
||
'#7a96a1',
|
||
'#b38754',
|
||
'#a66767',
|
||
'#7b6aa8',
|
||
] as const
|
||
|
||
const editorRef = ref<ExposeParam>()
|
||
const detailScrollbar = ref<DetailScrollbarInst | null>(null)
|
||
const scrollElement = ref<HTMLElement | undefined>(undefined)
|
||
const activeHeadingId = ref('')
|
||
const showBackToTop = ref(false)
|
||
const progress = ref(0)
|
||
const headingElementMap = shallowRef(new Map<string, HTMLElement>())
|
||
const route = useRoute()
|
||
const nav = $store.nav.useNavStore() as { navH: number }
|
||
|
||
const boxStyle = ref<Record<string, string>>({})
|
||
const contentStyle = ref<Record<string, string>>({})
|
||
const tags = ref<string[]>([])
|
||
const markdown = ref('')
|
||
const blogData = ref<BlogDetail>({
|
||
aid: 'preview-only',
|
||
updated_at: Date.now(),
|
||
title: '',
|
||
nickname: '',
|
||
tags: '',
|
||
cont: '',
|
||
})
|
||
|
||
/** 页面加载状态 */
|
||
const loading = ref(true)
|
||
const loadError = ref('')
|
||
|
||
/** AI 摘要状态机 */
|
||
type AiState = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
|
||
const aiState = ref<AiState>('idle')
|
||
const aiExpanded = ref(false)
|
||
const aiMsg = ref('')
|
||
const aiError = ref('')
|
||
let aiAbortController: AbortController | null = null
|
||
|
||
function getTagColor(tag: string) {
|
||
const normalized = tag.trim()
|
||
if (!normalized) return SOFT_TAG_PALETTE[0]
|
||
|
||
let hash = 0
|
||
for (const char of normalized) {
|
||
hash = (hash * 31 + char.charCodeAt(0)) % SOFT_TAG_PALETTE.length
|
||
}
|
||
|
||
return SOFT_TAG_PALETTE[Math.abs(hash) % SOFT_TAG_PALETTE.length]
|
||
}
|
||
|
||
/** 文章字数与预估阅读时间(按 300 字/分钟估算) */
|
||
const stats = computed(() => {
|
||
const text = markdown.value
|
||
.replace(/```[\s\S]*?```/g, ' ')
|
||
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
||
.replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
|
||
.replace(/[#>*`~\-_|]/g, ' ')
|
||
.trim()
|
||
const chars = text.replace(/\s+/g, '').length
|
||
const minutes = Math.max(1, Math.ceil(chars / 300))
|
||
return { chars, minutes }
|
||
})
|
||
|
||
const tocItems = computed<TocItem[]>(() => {
|
||
const lines = (blogData.value.cont || '').split(/\r?\n/)
|
||
const flatItems: TocChildItem[] = []
|
||
let inFence = false
|
||
|
||
lines.forEach((line) => {
|
||
const trimmed = line.trim()
|
||
// 跳过围栏代码块,避免其中的 # 行被误解析为标题
|
||
if (/^(`{3,}|~{3,})/.test(trimmed)) {
|
||
inFence = !inFence
|
||
return
|
||
}
|
||
if (inFence) return
|
||
// 跳过引用块内的伪标题
|
||
if (/^>/.test(trimmed)) return
|
||
|
||
const match = line.match(/^(#{1,6})\s+(.+?)\s*$/)
|
||
if (!match) return
|
||
|
||
const level = match[1].length
|
||
// 去除 Markdown 标记与转义反斜杠(如 1\. 引言 → 1. 引言)
|
||
const text = match[2].replace(/[`*_~\\]/g, '').trim()
|
||
if (!text) return
|
||
|
||
flatItems.push({
|
||
id: createHeadingId(text, flatItems.length),
|
||
text,
|
||
level,
|
||
})
|
||
})
|
||
|
||
const tree: TocItem[] = []
|
||
let currentParent: TocItem | null = null
|
||
|
||
flatItems.forEach((item) => {
|
||
if (item.level <= 2 || !currentParent) {
|
||
currentParent = {
|
||
...item,
|
||
children: [],
|
||
open: true,
|
||
}
|
||
tree.push(currentParent)
|
||
return
|
||
}
|
||
|
||
currentParent.children.push(item)
|
||
})
|
||
|
||
return tree
|
||
})
|
||
|
||
function updateLayout() {
|
||
const viewportHeight = window.innerHeight - nav.navH - 1
|
||
boxStyle.value = { height: `${viewportHeight}px` }
|
||
contentStyle.value = { height: `${Math.max(viewportHeight - 40, 320)}px` }
|
||
}
|
||
|
||
function createHeadingId(text: string, index: number) {
|
||
return `blog-heading-${index}-${text
|
||
.toLowerCase()
|
||
.replace(/[^\w\u4e00-\u9fa5]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')}`
|
||
}
|
||
|
||
function isNodeActive(item: TocItem) {
|
||
return activeHeadingId.value === item.id || item.children.some(child => child.id === activeHeadingId.value)
|
||
}
|
||
|
||
function getScrollbarContainer() {
|
||
return detailScrollbar.value?.scrollbarInstRef?.containerRef || null
|
||
}
|
||
|
||
function getScrollbarContent() {
|
||
return detailScrollbar.value?.scrollbarInstRef?.contentRef || null
|
||
}
|
||
|
||
function getPreviewRoot() {
|
||
return getScrollbarContent()?.querySelector('.article-preview') as HTMLElement | null
|
||
}
|
||
|
||
async function syncScrollElement() {
|
||
// n-scrollbar 内部容器可能跨多个帧才挂载完成,带重试等待
|
||
for (let i = 0; i < 10; i++) {
|
||
await nextTick()
|
||
const container = getScrollbarContainer()
|
||
if (container) {
|
||
scrollElement.value = container
|
||
bindScrollListener()
|
||
scheduleSyncHeadings()
|
||
return
|
||
}
|
||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||
}
|
||
scrollElement.value = getScrollbarContainer() || undefined
|
||
}
|
||
|
||
/** 绑定滚动监听(幂等,避免重复绑定) */
|
||
function bindScrollListener() {
|
||
const el = scrollElement.value
|
||
if (!el || el.dataset.blogScrollBound === '1') return
|
||
el.dataset.blogScrollBound = '1'
|
||
el.addEventListener('scroll', updateActiveHeading)
|
||
}
|
||
|
||
/** 等待渲染完成后再同步标题映射(md-editor 渲染可能跨多个帧) */
|
||
function scheduleSyncHeadings() {
|
||
nextTick(() => {
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
syncPreviewHeadings()
|
||
})
|
||
})
|
||
})
|
||
}
|
||
|
||
function syncPreviewHeadings() {
|
||
const previewRoot = getPreviewRoot()
|
||
if (!previewRoot) return
|
||
|
||
const headings = previewRoot.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
|
||
const flatTocItems = tocItems.value.flatMap(item => [item, ...item.children])
|
||
const nextHeadingMap = new Map<string, HTMLElement>()
|
||
const used = new Set<number>()
|
||
|
||
headings.forEach((heading) => {
|
||
// 优先按标题文本匹配(渲染结果),其次按顺序兜底,避免解析与渲染不一致导致错位
|
||
const headingText = (heading.textContent || '').trim()
|
||
let itemIndex = flatTocItems.findIndex((item, i) => !used.has(i) && item.text === headingText)
|
||
if (itemIndex === -1) {
|
||
itemIndex = flatTocItems.findIndex((item, i) => !used.has(i))
|
||
}
|
||
if (itemIndex === -1) return
|
||
|
||
used.add(itemIndex)
|
||
const item = flatTocItems[itemIndex]
|
||
heading.id = item.id
|
||
heading.style.scrollMarginTop = '24px'
|
||
nextHeadingMap.set(item.id, heading)
|
||
})
|
||
|
||
headingElementMap.value = nextHeadingMap
|
||
updateActiveHeading()
|
||
}
|
||
|
||
function updateActiveHeading() {
|
||
const container = scrollElement.value
|
||
showBackToTop.value = !!container && container.scrollTop > 240
|
||
if (container) {
|
||
const max = container.scrollHeight - container.clientHeight
|
||
progress.value = max > 0 ? Math.min(100, Math.round((container.scrollTop / max) * 100)) : 0
|
||
}
|
||
const previewRoot = getPreviewRoot()
|
||
if (!container || !previewRoot) return
|
||
|
||
const headings = previewRoot.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
|
||
if (!headings.length) {
|
||
activeHeadingId.value = ''
|
||
return
|
||
}
|
||
|
||
const containerRect = container.getBoundingClientRect()
|
||
const flatTocItems = tocItems.value.flatMap(item => [item, ...item.children])
|
||
let currentId = flatTocItems[0]?.id || ''
|
||
|
||
headings.forEach((heading) => {
|
||
const rect = heading.getBoundingClientRect()
|
||
if (rect.top - containerRect.top <= 80 && heading.id) {
|
||
currentId = heading.id
|
||
}
|
||
})
|
||
|
||
activeHeadingId.value = currentId
|
||
}
|
||
|
||
function scrollToHeading(id: string) {
|
||
const container = scrollElement.value
|
||
if (!container) return
|
||
// 优先取内存映射,兜底直接按 id 查找 DOM(防止映射未就绪)
|
||
let target = headingElementMap.value.get(id) || null
|
||
if (!target) {
|
||
target = document.getElementById(id)
|
||
}
|
||
if (!target) return
|
||
|
||
const containerRect = container.getBoundingClientRect()
|
||
const targetRect = target.getBoundingClientRect()
|
||
const nextTop = container.scrollTop + (targetRect.top - containerRect.top) - 24
|
||
|
||
smoothScrollTo(container, Math.max(0, nextTop))
|
||
activeHeadingId.value = id
|
||
}
|
||
|
||
function scrollToTop() {
|
||
const container = scrollElement.value
|
||
if (container) smoothScrollTo(container, 0)
|
||
activeHeadingId.value = tocItems.value[0]?.id || ''
|
||
}
|
||
|
||
/** 平滑滚动(原生实现,兼容不支持 options 的环境) */
|
||
function smoothScrollTo(container: HTMLElement, top: number) {
|
||
try {
|
||
container.scrollTo({ top, behavior: 'smooth' })
|
||
} catch {
|
||
container.scrollTop = top
|
||
}
|
||
}
|
||
|
||
watch([markdown, tocItems], async () => {
|
||
scheduleSyncHeadings()
|
||
})
|
||
|
||
function parseTags(str: string) {
|
||
const normalized = (str || '').replace(/,/g, ',')
|
||
return normalized.split(',').map(tag => tag.trim().slice(0, 4)).filter(Boolean)
|
||
}
|
||
|
||
async function getBlogDetail() {
|
||
const bid = (route.params as RouteParams).bid || '0'
|
||
try {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
const res = await $http.blog.getBlogDetail(bid) as BlogDetailResponse
|
||
blogData.value = res.data
|
||
tags.value = parseTags(res.data.tags)
|
||
markdown.value = res.data.cont
|
||
document.title = res.data.title ? `${res.data.title} - 文章详情` : '文章详情'
|
||
// 先结束 loading,让 v-else 分支的 n-scrollbar 真正挂载,再同步滚动容器
|
||
loading.value = false
|
||
await syncScrollElement()
|
||
} catch (err) {
|
||
loadError.value = (err as Error)?.message || '网络异常,请稍后重试'
|
||
window.$msg?.error('文章加载失败')
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function reload() {
|
||
loadError.value = ''
|
||
getBlogDetail()
|
||
}
|
||
|
||
async function copyLink() {
|
||
const url = window.location.href
|
||
try {
|
||
await navigator.clipboard.writeText(url)
|
||
window.$msg?.success('文章链接已复制')
|
||
} catch {
|
||
// 兼容非安全上下文(http)
|
||
try {
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = url
|
||
textarea.style.position = 'fixed'
|
||
textarea.style.opacity = '0'
|
||
document.body.appendChild(textarea)
|
||
textarea.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(textarea)
|
||
window.$msg?.success('文章链接已复制')
|
||
} catch {
|
||
window.$msg?.warning('复制失败,请手动复制地址栏链接')
|
||
}
|
||
}
|
||
}
|
||
|
||
function onAiExpand(names: string | string[] | null) {
|
||
const expanded = Array.isArray(names) ? names.includes('ai-summary') : names === 'ai-summary'
|
||
aiExpanded.value = expanded
|
||
if (expanded) triggerAi()
|
||
}
|
||
|
||
async function triggerAi() {
|
||
if (!blogData.value.cont) {
|
||
aiState.value = 'error'
|
||
aiError.value = '文章内容为空,无法生成摘要'
|
||
return
|
||
}
|
||
if (aiState.value === 'loading' || aiState.value === 'streaming') return
|
||
|
||
aiState.value = 'loading'
|
||
aiMsg.value = ''
|
||
aiError.value = ''
|
||
aiAbortController?.abort()
|
||
aiAbortController = new AbortController()
|
||
|
||
try {
|
||
const bid = (route.params as RouteParams).bid || '0'
|
||
const baseURL = 'https://www.hxyouzi.com'
|
||
const response = await fetch(`${baseURL}/api/art/ai/${bid}`, {
|
||
method: 'POST',
|
||
signal: aiAbortController.signal,
|
||
})
|
||
|
||
if (!response.ok) throw new Error(`请求失败(HTTP ${response.status})`)
|
||
const reader = response.body?.getReader()
|
||
if (!reader) throw new Error('当前浏览器不支持流式读取')
|
||
|
||
const decoder = new TextDecoder('utf-8')
|
||
let buffer = ''
|
||
aiState.value = 'streaming'
|
||
|
||
while (true) {
|
||
const { value, done } = await reader.read()
|
||
if (done) break
|
||
// 累积解码,避免 SSE 事件被 chunk 边界截断
|
||
buffer += decoder.decode(value ?? new Uint8Array(), { stream: true })
|
||
const lines = buffer.split('\n')
|
||
buffer = lines.pop() ?? ''
|
||
lines.forEach(line => handleSseLine(line.trim()))
|
||
}
|
||
if (buffer.trim()) handleSseLine(buffer.trim())
|
||
|
||
if (!aiMsg.value) {
|
||
aiState.value = 'error'
|
||
aiError.value = '未获取到摘要内容,请重试'
|
||
} else {
|
||
aiState.value = 'done'
|
||
}
|
||
} catch (err) {
|
||
if ((err as Error)?.name === 'AbortError') return
|
||
aiState.value = 'error'
|
||
aiError.value = (err as Error)?.message || '生成失败,请稍后重试'
|
||
}
|
||
}
|
||
|
||
function handleSseLine(line: string) {
|
||
if (!line.startsWith('data: ')) return
|
||
const payload = line.slice(6).trim()
|
||
if (!payload || payload === '[DONE]') return
|
||
// 服务端错误事件,如 [ERROR]xxx
|
||
if (/^\[(error|ERROR)\]/.test(payload)) {
|
||
aiState.value = 'error'
|
||
aiError.value = payload.replace(/^\[(error|ERROR)\]/, '') || '服务端返回错误'
|
||
return
|
||
}
|
||
aiMsg.value += payload
|
||
}
|
||
|
||
onMounted(async () => {
|
||
updateLayout()
|
||
window.addEventListener('resize', updateLayout)
|
||
// getBlogDetail 内部会在 n-scrollbar 挂载后调用 syncScrollElement 绑定滚动事件
|
||
await getBlogDetail()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
scrollElement.value?.removeEventListener('scroll', updateActiveHeading)
|
||
window.removeEventListener('resize', updateLayout)
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
/* 目录列表:激活指示条与悬停反馈 */
|
||
.toc-list {
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
border-radius: 9999px;
|
||
background: rgba(139, 158, 166, 0.35);
|
||
}
|
||
|
||
&::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
}
|
||
|
||
.toc-leaf,
|
||
.toc-child {
|
||
position: relative;
|
||
padding-left: 10px;
|
||
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
width: 3px;
|
||
height: 0;
|
||
border-radius: 9999px;
|
||
background: #D71A44;
|
||
transform: translateY(-50%);
|
||
transition: height 0.25s ease;
|
||
}
|
||
|
||
&:hover {
|
||
background: rgba(255, 255, 255, 0.5);
|
||
}
|
||
}
|
||
|
||
.toc-active {
|
||
background: linear-gradient(90deg, rgba(236, 102, 171, 0.08), rgba(236, 102, 171, 0));
|
||
box-shadow: inset 3px 0 0 rgba(236, 102, 171, 0.85);
|
||
|
||
&::before {
|
||
height: 14px;
|
||
}
|
||
}
|
||
|
||
/* 加载骨架屏 */
|
||
.blog-skeleton {
|
||
background: linear-gradient(90deg, rgba(210, 220, 225, 0.35) 25%, rgba(255, 255, 255, 0.65) 50%, rgba(210, 220, 225, 0.35) 75%);
|
||
background-size: 200% 100%;
|
||
animation: blog-skeleton-wave 1.4s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes blog-skeleton-wave {
|
||
0% {
|
||
background-position: 200% 0;
|
||
}
|
||
|
||
100% {
|
||
background-position: -200% 0;
|
||
}
|
||
}
|
||
|
||
/* AI 摘要流式输出光标 */
|
||
.ai-cursor {
|
||
display: inline-block;
|
||
width: 2px;
|
||
height: 1.1em;
|
||
margin-left: 2px;
|
||
vertical-align: -2px;
|
||
background: #D71A44;
|
||
animation: ai-cursor-blink 0.9s steps(2, start) infinite;
|
||
}
|
||
|
||
@keyframes ai-cursor-blink {
|
||
to {
|
||
visibility: hidden;
|
||
}
|
||
}
|
||
|
||
:deep(.article-preview .md-editor-preview) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.article-preview .md-editor-preview-wrapper) {
|
||
padding: 0 !important;
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.article-preview .md-editor) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.article-preview .md-editor-content),
|
||
:deep(.article-preview .md-editor-preview-theme),
|
||
:deep(.article-preview .md-editor-previewOnly),
|
||
:deep(.article-preview .md-editor-previewOnly .md-editor-preview) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.article-preview .md-editor-toolbar),
|
||
:deep(.article-preview .md-editor-footer),
|
||
:deep(.article-preview .md-editor-catalog) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.n-collapse),
|
||
:deep(.n-collapse-item),
|
||
:deep(.n-collapse-item__header),
|
||
:deep(.n-collapse-item__content-wrapper),
|
||
:deep(.n-collapse-item__content-inner) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
:deep(.article-preview .md-editor-previewOnly) {
|
||
font-size: 16px;
|
||
color: #425466;
|
||
line-height: 1.95;
|
||
}
|
||
|
||
:deep(.article-preview h1),
|
||
:deep(.article-preview h2),
|
||
:deep(.article-preview h3),
|
||
:deep(.article-preview h4),
|
||
:deep(.article-preview h5),
|
||
:deep(.article-preview h6) {
|
||
color: #5c3d46;
|
||
font-weight: 700;
|
||
line-height: 1.35;
|
||
margin-top: 1.6em;
|
||
margin-bottom: 0.7em;
|
||
}
|
||
|
||
:deep(.article-preview p),
|
||
:deep(.article-preview li),
|
||
:deep(.article-preview blockquote) {
|
||
color: #5f6f7f;
|
||
line-height: 1.95;
|
||
}
|
||
|
||
:deep(.article-preview p) {
|
||
margin: 0.95em 0;
|
||
}
|
||
|
||
:deep(.article-preview blockquote) {
|
||
border-left: 4px solid rgba(236, 102, 171, 0.32);
|
||
background: transparent;
|
||
border-radius: 0 16px 16px 0;
|
||
padding: 0.9rem 1rem;
|
||
}
|
||
|
||
:deep(.article-preview pre) {
|
||
border-radius: 18px;
|
||
box-shadow: 0 14px 30px rgba(88, 108, 125, 0.12);
|
||
}
|
||
|
||
:deep(.article-preview img) {
|
||
border-radius: 18px;
|
||
box-shadow: 0 16px 30px rgba(88, 108, 125, 0.12);
|
||
}
|
||
|
||
:deep(.article-preview table) {
|
||
overflow: hidden;
|
||
border-radius: 16px;
|
||
background: transparent;
|
||
}
|
||
</style>
|