Files
blog/src/views/blog/[bid].vue
T
youzi 88999c4961 feat: 优化全局样式与类型,新增四川母暴龙代理配置
- 调整全站毛玻璃背景、光晕与渐变的透明度,降低色彩饱和度使视觉更柔和
- 替换 $http 全局类型声明,改用具体模块类型替代 any
- 增强 API 请求函数类型,使用 Record<string, unknown> 替代 any,新增泛型响应接口
- 修复热搜标题与热度数字颜色,支持 hover 变色
- 新增四川母暴龙自定义代理配置文件
2026-08-07 11:29:59 +08:00

915 lines
31 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>
<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>