引言
生命不息,折腾不止。为了提供更好的阅读体验和交互功能,近期对博客模板进行了多项底层与UI层面的升级。本文详细记录了核心代码的修改过程。
1. 添加字体切换组件
在侧边栏新增了 <FontToggle /> 组件,支持用户在 Serif(衬线体)、Sans-serif(无衬线体)等字体间无缝切换,满足不同用户的阅读偏好。
<script setup lang="ts">
const { fonts, currentFontName, setFont } = useFont()
const showMenu = ref(false)
const isTouch = ref(false)
const onMouseEnter = () => {
if (!isTouch.value) {
showMenu.value = true
}
}
const onMouseLeave = () => {
if (!isTouch.value) {
showMenu.value = false
}
}
const toggleMenu = () => {
if (isTouch.value) {
showMenu.value = !showMenu.value
} else {
showMenu.value = true
}
}
const onTouchStart = () => {
isTouch.value = true
}
const selectFont = (name: string) => {
setFont(name)
showMenu.value = false
}
</script>
<template>
<div class="font-toggle-wrapper" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @touchstart="onTouchStart">
<div class="font-toggle-btn">
<button @click="toggleMenu" aria-label="Switch Font">
<Icon name="ph:text-aa-bold" />
</button>
</div>
<Transition name="slide-fade">
<div v-if="showMenu" class="font-menu">
<button
v-for="font in fonts"
:key="font.name"
:class="{ active: currentFontName === font.name }"
@click="selectFont(font.name)"
>
{{ font.label }}
</button>
</div>
</Transition>
</div>
</template>
<style lang="scss" scoped>
.font-toggle-wrapper {
position: relative;
width: fit-content;
margin: 0 auto;
}
.font-toggle-btn {
display: flex;
justify-content: center;
padding: 2px;
border: 1px solid var(--c-border);
border-radius: 1rem;
background-color: var(--c-bg-2);
> button {
padding: 4px 1rem;
border-radius: 1rem;
transition: all 0.1s;
&:hover {
background-color: var(--c-bg-soft);
color: var(--c-text-1);
}
}
}
.font-menu {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 0.5rem;
padding: 0.3rem;
background-color: var(--ld-bg-card);
border: 1px solid var(--c-border);
border-radius: 0.5rem;
box-shadow: 0 0 1rem var(--ld-shadow);
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: max-content;
z-index: 20;
&::before {
content: "";
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: 0.5rem;
}
button {
padding: 0.4rem 0.8rem;
border-radius: 0.3rem;
text-align: center;
font-size: 0.9em;
transition: background-color 0.1s;
white-space: nowrap;
&:hover {
background-color: var(--c-bg-soft);
}
&.active {
background-color: var(--c-primary-soft);
color: var(--c-primary);
}
}
}
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.2s ease;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
</style>
2. 增加代码块 Diff 高亮支持
此功能允许在 Markdown 代码块中使用 ins 和 del 参数来高亮显示新增或删除的行,极大地提升了技术文章中代码变更的可读性。
Shiki Store 改造
在 app/stores/shiki.ts 中添加了解析 ins、del 和 startlinenumber 参数的逻辑,并实现了自定义的 transformer 来处理 diff 高亮和行号偏移。
import type { BundledLanguage, CodeToHastOptions, HighlighterCore, RegexEngine } from 'shiki'
import { transformerColorizedBrackets } from '@shikijs/colorized-brackets'
import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight, transformerRenderIndentGuides, transformerRenderWhitespace } from '@shikijs/transformers'
function parseLineRanges(meta: string, key: string): Set<number> {
const match = meta.match(new RegExp(`\\b${key}=(?:(["'])([\\d,-]+)\\1|([\\d,-]+))`))
if (!match)
return new Set()
const content = match[2] || match[3]
if (!content)
return new Set()
const ranges = content.split(',')
const lines = new Set<number>()
for (const range of ranges) {
const [start, end] = range.split('-').map(Number)
if (!isNaN(start)) {
if (!isNaN(end)) {
for (let i = start; i <= end; i++)
lines.add(i)
}
else {
lines.add(start)
}
}
}
return lines
}
let promise: Promise<HighlighterCore>
let shiki: HighlighterCore
type CustomTransformerOptions = Array<
| 'ignoreColorizedBrackets'
| 'ignoreRenderWhitespace'
| 'ignoreRenderIndentGuides'
>
type ShikiOptions = CodeToHastOptions<BundledLanguage, string>
export const useShikiStore = defineStore('shiki', () => {
const getOptions = (
lang: string,
transformerOptions?: CustomTransformerOptions,
extraShikiOptions?: Omit<ShikiOptions, 'lang'>,
): ShikiOptions => {
const metaRaw = (extraShikiOptions?.meta as any)?.__raw || ''
const insLines = parseLineRanges(metaRaw, 'ins')
const delLines = parseLineRanges(metaRaw, 'del')
const startLineMatch = metaRaw.match(/\bstartlinenumber=(\d+)/)
const startLine = startLineMatch ? Number(startLineMatch[1]) : 1
return {
lang,
themes: {
light: 'catppuccin-latte',
dark: 'one-dark-pro',
},
transformers: [
transformerNotationDiff(),
transformerNotationHighlight(),
transformerNotationWordHighlight(),
transformerNotationFocus(),
transformerNotationErrorLevel(),
transformerOptions?.includes('ignoreRenderIndentGuides') || ['ansi', 'log', 'text'].includes(lang)
? {}
: transformerRenderIndentGuides(),
transformerOptions?.includes('ignoreRenderWhitespace') || ['ansi', 'log', 'text'].includes(lang)
? {}
: transformerRenderWhitespace(),
transformerMetaHighlight(),
transformerMetaWordHighlight(),
transformerOptions?.includes('ignoreColorizedBrackets')
? {}
: transformerColorizedBrackets(),
{
name: 'meta-diff',
line(node, line) {
const currentLine = line + startLine - 1
if (insLines.has(currentLine))
this.addClassToHast(node, 'diff add')
if (delLines.has(currentLine))
this.addClassToHast(node, 'diff remove')
},
},
{
root: hast => ({
type: 'root',
children: (hast.children[0] as any).children[0].children,
}),
line(node, line) {
node.properties['data-line'] = line + startLine - 1
},
},
],
...extraShikiOptions,
}
}
async function load() {
promise ??= loadShiki()
shiki ??= await promise
return shiki
}
async function loadShiki() {
const [
{ createHighlighterCore },
{ createJavaScriptRegexEngine },
catppuccinLatte,
oneDarkPro,
] = await Promise.all([
import('shiki/core'),
import('shiki/engine-javascript.mjs'),
import('shiki/themes/catppuccin-latte.mjs'),
import('shiki/themes/one-dark-pro.mjs'),
])
// 测试是否支持正则 Modifier: `(?ims-ims:...)`
let engine: RegexEngine
try {
// eslint-disable-next-line prefer-regex-literals, regexp/strict
void new RegExp('(?i: )')
engine = createJavaScriptRegexEngine()
}
catch {
const { createOnigurumaEngine } = await import('shiki/engine-oniguruma.mjs')
// @ts-expect-error CDN 动态引入的包无类型
engine = await createOnigurumaEngine(import('[https://esm.sh/shiki/wasm](https://esm.sh/shiki/wasm)'))
}
return createHighlighterCore({ themes: [catppuccinLatte, oneDarkPro], engine })
}
async function loadLang(...langs: string[]) {
// @ts-expect-error CDN 动态引入的包无类型
const { bundledLanguages } = await import('[https://esm.sh/shiki/langs](https://esm.sh/shiki/langs)') as typeof import('shiki/langs')
const loadedLangs = shiki.getLoadedLanguages()
await Promise.all(langs
.filter(unjudged => !loadedLangs.includes(unjudged) && unjudged in bundledLanguages)
.map(unloaded => bundledLanguages[unloaded as BundledLanguage])
.map(dynamicLang => dynamicLang().then(grammar => shiki.loadLanguage(grammar))),
)
}
return {
getOptions,
load,
loadLang,
}
})
3. 新增文章最后编辑时间卡片
在文章页面底部添加了一个显示文章最后编辑时间的卡片,提示读者信息的时效性。
<script setup lang="ts">
import type ArticleProps from '~/types/article'
defineOptions({ inheritAttrs: false })
const props = defineProps<ArticleProps>()
const timeDiff = ref('')
let timer: NodeJS.Timeout | null = null
const lastDate = computed(() => {
return props.updated ? new Date(props.updated) : (props.date ? new Date(props.date) : null)
})
function updateTime() {
if (!lastDate.value) return
const now = new Date()
const diff = now.getTime() - lastDate.value.getTime()
if (diff < 0) {
timeDiff.value = '刚刚'
return
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
let result = ''
if (days > 0) result += `${days}天`
if (hours > 0) result += `${hours}小时`
result += `${String(minutes).padStart(2, '0')}分`
result += `${String(seconds).padStart(2, '0')}秒`
timeDiff.value = result
}
onMounted(() => {
updateTime()
timer = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div v-if="lastDate" class="post-time-status">
<Icon name="material-symbols:history-rounded" class="bg-icon" />
<section>
<div class="title text-creative">
距离上次编辑:{{ timeDiff }}
</div>
<div class="content">
<p>
部分信息可能已经过时。
</p>
</div>
</section>
</div>
</template>
<style lang="scss" scoped>
.post-time-status {
position: relative;
overflow: hidden;
margin: 2rem 0.5rem;
border: 1px solid var(--c-border);
border-radius: 1rem;
background-color: var(--c-bg-2);
}
.bg-icon {
position: absolute;
top: -2rem;
right: 2rem;
z-index: 0;
color: var(--c-text);
font-size: 10rem;
opacity: 0.05;
pointer-events: none;
}
section {
position: relative;
z-index: 1;
padding: 1rem;
}
.title {
font-weight: bold;
color: var(--c-text);
font-size: 0.9rem;
}
.content {
margin-top: 0.5em;
font-size: 0.8rem;
}
</style>
4. 修改 Footer 版权卡片样式
重构了文章底部版权卡片,增加了文章作者、发布时间及协议跳转,视觉上更加整洁美观。
<script setup lang="ts">
import type ArticleProps from '~/types/article'
defineOptions({ inheritAttrs: false })
const props = defineProps<ArticleProps>()
const appConfig = useAppConfig()
const fullUrl = computed(() => {
return new URL(props.path || '', appConfig.url).href
})
const formattedDate = computed(() => {
return props.date ? new Date(props.date).toLocaleString() : ''
})
</script>
<template>
<div class="post-footer">
<section v-if="references" class="reference">
<div id="references" class="title text-creative">
参考链接
</div>
<div class="content">
<ul>
<li v-for="{ title, link }, i in references" :key="i">
<ProseA :href="link || ''">
{{ title ?? link }}
</ProseA>
</li>
</ul>
</div>
</section>
<section class="license">
<Icon name="ri:creative-commons-line" class="cc-icon" />
<div class="license-row">
<div class="license-item">
<div class="title text-creative" style="font-size: 1.2rem;">
{{ title }}
</div>
<div class="content">
<ProseA :href="fullUrl">
{{ fullUrl }}
</ProseA>
</div>
</div>
</div>
<div class="license-row">
<div class="license-item">
<div class="title text-creative">
文章作者
</div>
<div class="content">
{{ appConfig.author.name }}
</div>
</div>
<div class="license-item">
<div class="title text-creative">
发布时间
</div>
<div class="content">
{{ formattedDate }}
</div>
</div>
</div>
<div class="license-row">
<div class="license-item">
<div class="title text-creative">
许可协议
</div>
<div class="content">
<p>
<ProseA :href="appConfig.copyright.url">
{{ appConfig.copyright.name }}
</ProseA>
</p>
</div>
</div>
</div>
</section>
</div>
</template>
<style lang="scss" scoped>
.post-footer {
position: relative;
overflow: hidden;
margin: 2rem 0.5rem;
border: 1px solid var(--c-border);
border-radius: 1rem;
background-color: var(--c-bg-2);
}
.cc-icon {
position: absolute;
bottom: -2rem;
right: 2rem;
z-index: 0;
color: var(--c-text);
font-size: 10rem;
opacity: 0.05;
pointer-events: none;
}
section {
position: relative;
z-index: 1;
padding: 1rem;
overflow: hidden;
& + section {
border-top: 1px solid var(--c-border);
}
}
.license-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.license-item {
flex: 1;
min-width: 100px;
}
.title {
font-weight: bold;
font-size: 0.9rem;
color: var(--c-text);
}
.content {
margin-top: 0.5em;
font-size: 0.8rem;
word-break: break-all;
li {
margin: 0.5em 0;
}
}
</style>
5. 评论系统改为 Artalk
将博客的评论系统从原有的组件替换为 Artalk,以提供更丰富的评论功能(表情、图片灯箱、LaTeX 公式)和更好的隐私掌控。
<script setup lang="ts">
import { LazyPopoverLightbox } from '#components'
import ArtalkManager from '~/utils/artalk-manager'
const appConfig = useAppConfig()
const route = useRoute()
const colorMode = useColorMode()
const artalkManager = ArtalkManager.getInstance()
const popoverStore = usePopoverStore()
const artalkEl = ref<HTMLElement | null>(null)
// 动态加载 KaTeX 脚本
function loadKaTeX() {
return new Promise((resolve, reject) => {
if (typeof window !== 'undefined' && window.katex) {
resolve(window.katex)
return
}
const existingScript = document.querySelector('script[src*="katex"]')
if (existingScript) {
existingScript.addEventListener('load', () => resolve(window.katex))
existingScript.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = '[https://lib.baomitu.com/KaTeX/0.16.9/katex.min.js](https://lib.baomitu.com/KaTeX/0.16.9/katex.min.js)'
script.crossOrigin = 'anonymous'
script.onload = () => resolve(window.katex)
script.onerror = reject
document.head.appendChild(script)
})
}
// KaTeX math rendering function
async function renderMathInComments() {
try {
await loadKaTeX()
const commentElements = document.querySelectorAll('#artalk .atk-content:not(.math-processed)')
commentElements.forEach((element: Element) => {
element.classList.add('math-processed')
let content = element.innerHTML
const originalContent = content
content = content.replace(/\$\$([^$]+)\$\$/g, (match, formula) => {
try {
return `<span class="math-display">${window.katex.renderToString(formula.trim(), { displayMode: true })}</span>`
}
catch (e) {
console.warn('KaTeX display render error:', e)
return match
}
})
content = content.replace(/\$([^$\n]+)\$/g, (match, formula) => {
if (match.includes('<span class="math-')) {
return match
}
try {
return `<span class="math-inline">${window.katex.renderToString(formula.trim(), { displayMode: false })}</span>`
}
catch (e) {
console.warn('KaTeX inline render error:', e)
return match
}
})
// eslint-disable-next-line regexp/no-super-linear-backtracking
content = content.replace(/```math\s*([\s\S]*?)```/g, (match, formula) => {
try {
return `<div class="math-block">${window.katex.renderToString(formula.trim(), { displayMode: true })}</div>`
}
catch (e) {
console.warn('KaTeX math block render error:', e)
return match
}
})
if (content !== originalContent) {
element.innerHTML = content
}
})
}
catch (error) {
console.error('Failed to load KaTeX:', error)
setTimeout(() => renderMathInComments(), 1000)
}
}
// 为评论区图片添加灯箱功能
function addLightboxToImages() {
const commentImages = document.querySelectorAll('#artalk .atk-content img')
commentImages.forEach((img: Element) => {
const imgElement = img as HTMLImageElement
if (imgElement.style.cursor !== 'zoom-in') {
imgElement.style.cursor = 'zoom-in'
imgElement.addEventListener('click', () => {
const { open } = popoverStore.use(() => h(LazyPopoverLightbox, {
el: imgElement,
}))
open()
})
}
})
}
let commentObserver: MutationObserver | null = null
function watchCommentChanges() {
const artalkContainer = document.getElementById('artalk')
if (!artalkContainer)
return
if (commentObserver) {
commentObserver.disconnect()
}
commentObserver = new MutationObserver((mutations) => {
let shouldUpdateImages = false
let shouldRenderMath = false
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
if (element.tagName === 'IMG' || element.querySelector('img')) {
shouldUpdateImages = true
}
if (element.classList?.contains('atk-content') || element.querySelector('.atk-content')) {
shouldRenderMath = true
}
}
})
}
})
if (shouldUpdateImages) {
setTimeout(() => addLightboxToImages(), 100)
}
if (shouldRenderMath) {
setTimeout(() => renderMathInComments(), 500)
}
})
commentObserver.observe(artalkContainer, {
childList: true,
subtree: true,
})
}
async function initArtalk() {
if (!artalkEl.value)
return
try {
console.log('开始初始化 Artalk...')
await artalkManager.init({
el: artalkEl.value,
pageKey: route.path,
pageTitle: document.title.replace(` | ${appConfig.title}`, ''),
server: appConfig.artalk?.server,
site: appConfig.artalk?.sitename,
emoticons: '/assets/Owo-Artalk.json',
darkMode: colorMode.value === 'dark',
})
console.log('Artalk 初始化完成')
await nextTick(() => {
setTimeout(() => {
addLightboxToImages()
setTimeout(() => renderMathInComments(), 1000)
watchCommentChanges()
}, 500)
})
}
catch (error) {
console.error('评论系统初始化失败:', error)
}
}
onMounted(() => {
nextTick(() => {
setTimeout(initArtalk, 100)
})
})
watch(() => route.path, () => {
nextTick(() => {
setTimeout(initArtalk, 100)
})
})
watch(() => colorMode.value, (newMode) => {
artalkManager.setDarkMode(newMode === 'dark')
})
onUnmounted(() => {
if (commentObserver) {
commentObserver.disconnect()
commentObserver = null
}
artalkManager.destroy()
})
</script>
<template>
<section class="z-comment">
<h3 class="text-creative">
<div class="comment-tip">评论</div>
</h3>
<div class="commentCard">
<div id="artalk" ref="artalkEl">
<p class="loading-box">
<Icon name="line-md:loading-twotone-loop" class="loadig-img" />评论加载中...
</p>
</div>
</div>
</section>
</template>
<style lang="scss" scoped>
.z-comment {
margin: 3rem 0.5rem;
> h3 {
margin-top: 3rem;
margin-left: 0.2rem;
font-size: 1.25rem;
}
}
.text-creative {
display: flex;
> .comment-tip{
font-size: 1.45rem;
margin-right: 0.8rem;
margin-bottom: 1rem;
}
> .comment-nav {
font-size: 1.45rem;
margin-right: 0.8rem;
margin-bottom: 1rem;
}
}
.comment-tip{
font-size: 1.45rem;
margin-right: 0.8rem;
}
#artalk {
.loading-box{
text-align: center;
font-size: 1.1rem;
.loading-img{
margin-right: 0.6rem;
}
}
margin-top: 1rem;
.atk-main-editor {
border-radius: 0.8rem !important;
background-color: var(--ld-bg-card);
box-shadow: 0 0.1em 0.2em var(--ld-shadow);
border:none !important;
transition: all 0.2s ease;
&:hover{
box-shadow: 0 0.5em 1em var(--ld-shadow);
transform: translateY(-2px);
}
}
.atk-textarea{
background-color: var(--ld-bg-card);
}
.atk-send-btn {
color: #fff !important;
background-color: var(--c-primary) !important;
border-radius: 16px !important;
transition: all 0.2s;
}
.atk-comment-wrap {
margin: 0.6rem 0;
background-color: var(--ld-bg-card);;
border-radius: 0.8rem;
box-shadow: 0 0.1em 0.2em var(--ld-shadow);
}
.atk-comment-wrap .atk-comment {
padding: 10px;
}
.atk-comment-children > .atk-comment-wrap {
margin: 10px 0 0 0;
background-color: transparent;
border-radius: 0;
box-shadow: none;
}
.atk-comment > .atk-avatar img {
border-radius: 50% !important;
}
.atk-nick a {
font-size: 0.9rem !important;
color: var(--c-brand) !important;
}
.atk-reply-at > .atk-nick {
font-size: 0.8rem !important;
color: var(--c-brand) !important;
}
.atk-comment > .atk-main > .atk-header {
padding-top: 5px;
}
.atk-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.atk-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 4px;
}
.atk-common-action-btn, .atk-actions span {
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
.atk-dropdown {
list-style: none !important;
margin: 0 !important;
padding: 0 !important;
.atk-dropdown-item {
list-style: none !important;
margin: 0 !important;
padding: 8px 12px !important;
span{
padding: 0 1rem !important;
}
&::marker {
display: none !important;
}
&::before {
display: none !important;
}
}
}
@media (max-width: 576px) {
.atk-comment-wrap {
margin: 12px 0;
}
.atk-comment-wrap .atk-comment {
padding: 12px;
}
}
.dark & {
.atk-comment-wrap {
background-color: var(--c-bg-2);
}
.atk-main-editor {
background-color: var(--c-bg-2) !important;
box-shadow: 0 0.1em 0.2em var(--ld-shadow);
color: var(--c-text-1) !important;
border:none !important;
}
.atk-content p {
color: var(--c-text-1) !important;
font-size: 0.9rem !important;
}
.atk-nick a {
color: var(--c-brand-light) !important;
}
.atk-reply-at > .atk-nick {
color: var(--c-brand-light) !important;
}
}
.atk-time {
color: var(--c-text-3);
}
.atk-content {
margin-top: 0.1rem;
img {
border-radius: 0.5em;
}
}
.atk-nick {
font-family: var(--font-basic);
font-weight: bold;
}
pre {
border-radius: 0.5rem;
font-size: 0.8125rem;
}
p {
margin: 0.2em 0;
}
.atk-emotion {
width: auto;
height: 1.4em;
vertical-align: text-bottom;
}
/* KaTeX math rendering styles */
.math-block {
margin: 1rem 0;
text-align: center;
overflow-x: auto;
}
.katex {
font-size: 1.1em;
}
.katex-display {
margin: 1rem 0;
text-align: center;
}
menu, ol, ul:not(.atk-dropdown) {
margin: 0.5em 0;
padding: 0 0 0 1.5em;
list-style: revert;
> li {
margin: 0.2em 0;
&::marker {
font-size: 0.8em;
color: var(--c-primary);
}
}
}
blockquote {
margin: 0.5em 0;
padding: 1.2rem;
border-left: 4px solid var(--c-border);
border-radius: 4px;
background-color: var(--c-bg-2);
font-size: 0.9rem;
> .z-codeblock {
margin: 0 -0.8rem;
}
}
}
</style>
6. 增加标签云页面
使用线性插值算法根据标签文章数量动态计算字体大小,实现标签云效果。
<script setup lang="ts">
const appConfig = useAppConfig()
useSeoMeta({
title: '标签',
description: `${appConfig.title}的所有文章标签。`,
})
const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-log'])
const { data: listRaw } = await useAsyncData('index_posts', () => useArticleIndexOptions(), { default: () => [] })
// 提取所有标签并统计
const tagsMap = computed(() => {
const map = new Map<string, number>()
listRaw.value.forEach(article => {
article.tags?.forEach(tag => {
map.set(tag, (map.get(tag) || 0) + 1)
})
})
return map
})
// 转换为数组并排序
const tagsList = computed(() => {
return Array.from(tagsMap.value.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
})
// 当前选中的标签
const currentTag = useRouteQuery('tag')
// 筛选后的文章列表
const filteredList = computed(() => {
if (!currentTag.value) return []
return listRaw.value.filter(article => article.tags?.includes(currentTag.value as string))
})
// 排序逻辑
const { listSorted } = useArticleSort(filteredList)
// 显示的标签列表
const displayedTags = computed(() => {
if (currentTag.value) {
return tagsList.value.filter(tag => tag.name === currentTag.value)
}
return tagsList.value
})
// 标签云样式计算 (保留部分逻辑,但主要样式由CSS控制)
function getTagStyle(count: number) {
if (currentTag.value) return {} // 选中状态下不应用动态样式
const maxCount = tagsList.value[0]?.count || 1
const ratio = count / maxCount
return {
opacity: 0.6 + ratio * 0.4,
fontSize: (0.85 + ratio * 0.5) + 'rem',
fontWeight: 400 + Math.round(ratio * 3) * 100
}
}
</script>
<template>
<div class="tags-page proper-height">
<h1 v-if="!currentTag" class="page-title">标签</h1>
<div class="tags-cloud" :class="{ 'is-active': currentTag }">
<TransitionGroup name="tag-anim">
<NuxtLink
v-for="tag in displayedTags"
:key="tag.name"
:to="{ query: { tag: tag.name } }"
class="tag-item"
:class="{ active: currentTag === tag.name }"
:style="getTagStyle(tag.count)"
>
<Icon name="ph:hash-bold" class="tag-icon" />
{{ tag.name }}
<span class="tag-count">{{ tag.count }}</span>
</NuxtLink>
</TransitionGroup>
<Transition name="fade">
<NuxtLink
v-if="currentTag"
:to="{ query: {} }"
class="back-btn"
title="返回标签云"
>
<Icon name="ph:arrow-u-up-left-bold" />
<span class="text">返回</span>
</NuxtLink>
</Transition>
</div>
<div v-if="currentTag" class="tag-results">
<TransitionGroup tag="menu" class="archive-list" name="float-in">
<PostArchive
v-for="(article, index) in listSorted"
:key="article.path"
v-bind="article"
:to="article.path"
:style="{ '--delay': `${index * 0.03}s` }"
/>
</TransitionGroup>
</div>
</div>
</template>
<style lang="scss" scoped>
.tags-page {
margin: 1rem;
min-height: 60vh;
}
.page-title {
text-align: center;
margin-bottom: 2rem;
font-size: 2rem;
font-weight: bold;
}
.tags-cloud {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
justify-content: center;
align-items: center;
margin-bottom: 2rem;
transition: all 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
&.is-active {
justify-content: flex-start;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px dashed var(--c-border);
.tag-item {
font-size: 1.2rem;
background: var(--c-primary);
color: #fff;
opacity: 1 !important;
pointer-events: none; // 选中后不可点击
box-shadow: 0 4px 12px var(--c-primary-soft);
// margin-right: auto; // Removed to use justify-content
.tag-count {
background: rgba(255,255,255,0.2);
color: #fff;
}
.tag-icon {
width: 1.2em;
opacity: 1;
margin-right: 0.5em;
}
}
}
}
.tag-item {
display: inline-flex;
align-items: center;
// gap: 0.5em; // Removed to prevent jitter, using margins
padding: 0.5em 1em;
border-radius: 999px;
background: var(--c-bg-2);
color: var(--c-text-1);
text-decoration: none;
transition: all 0.3s ease;
font-size: 1rem;
border: 1px solid transparent;
&:hover {
background: var(--c-bg-3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
&.active {
// 选中样式在 .is-active 中定义
}
.tag-count {
font-size: 0.8em;
background: var(--c-bg-3);
padding: 0.1em 0.5em;
border-radius: 10px;
min-width: 1.5em;
text-align: center;
transition: all 0.3s;
margin-left: 0.5em;
}
.tag-icon {
width: 0;
opacity: 0;
margin-right: 0;
overflow: hidden;
transition: all 0.3s ease;
}
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.5em 1em;
border-radius: 8px;
color: var(--c-text-2);
text-decoration: none;
transition: all 0.3s;
font-size: 0.9rem;
position: absolute;
right: 1rem;
&:hover {
background: var(--c-bg-2);
color: var(--c-primary);
}
}
.tag-results {
animation: fade-in 0.5s ease 0.3s backwards;
}
.archive-list {
display: flex;
flex-direction: column;
// gap: 1rem; // Removed to match archive.vue style
}
/* 动画定义 */
.tag-anim-move,
.tag-anim-enter-active {
transition: all 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.tag-anim-enter-from {
opacity: 0;
transform: scale(0.5);
}
.tag-anim-leave-active {
display: none;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
7. 升级 LinkCard 图标支持
现在 ::link-card 组件支持直接使用 Iconify 图标集合(例如 ph:check),同时兼容填入图片 URL。
<slot name="icon" class="link-card-icon-slot">
<Icon v-if="icon && icon.includes(':') && !icon.includes('/')" :name="icon" class="link-card-icon" size="2.5rem" />
<UtilImg v-else-if="icon" class="link-card-icon" :src="icon" :mirror />
</slot>
8. 增加文章加密功能
实现了一个纯前端的文章加密方案。在构建阶段使用 AES 算法加密文章正文,浏览器端通过用户输入的答案进行解密渲染。既保护了隐私,又无需后端服务支持。
核心实现
编写了一个 Nuxt 模块,在 content:file:afterParse 钩子中拦截 Markdown 解析结果,将 body 对象序列化并加密,替换为 <DecryptVerifier> 组件节点。
import { defineNuxtModule } from '@nuxt/kit'
import CryptoJS from 'crypto-js'
import fs from 'node:fs'
import path from 'node:path'
export default defineNuxtModule({
meta: {
name: 'encrypt-content'
},
setup(options, nuxt) {
nuxt.hook('content:file:afterParse', (ctx: any) => {
const content = ctx.content
// 尝试查找 encrypt 配置
const encryptConfig = content.encrypt || content.meta?.encrypt || content.body?.encrypt
if (encryptConfig?.enable && encryptConfig?.answer) {
const answer = encryptConfig.answer
// 收集所有图片 URL,用于预构建
const images: string[] = []
const collectImages = (node: any) => {
// Handle Minimark array structure: [tag, props, ...children]
if (Array.isArray(node)) {
const [tag, props, ...children] = node;
// Check for image src in props
if (props && typeof props === 'object' && props.src) {
images.push(props.src);
}
// Recursively check children
children.forEach((child: any) => collectImages(child));
return;
}
// Handle standard object structure
if (node && typeof node === 'object') {
// Handle Minimark root
if (node.type === 'minimark' && Array.isArray(node.value)) {
node.value.forEach((child: any) => collectImages(child));
return;
}
// Standard AST
if (node.tag === 'img' && node.props?.src) {
images.push(node.props.src)
}
// 检查自定义组件,如 Pic, NuxtImg 等
if (node.props?.src) {
images.push(node.props.src)
}
if (node.children && Array.isArray(node.children)) {
node.children.forEach(collectImages)
}
}
}
collectImages(content.body)
// 去重
const uniqueImages = [...new Set(images)]
// 构造加密负载,包含 body 和 toc
const payload = {
body: content.body,
toc: content.body?.toc || content.toc
}
const bodyString = JSON.stringify(payload)
const encrypted = CryptoJS.AES.encrypt(bodyString, answer).toString()
// 清除原始 TOC 信息,防止泄露
if (content.toc) delete content.toc
if (content.body?.toc) delete content.body.toc
// 替换 body 为包含 DecryptVerifier 组件的结构
content.body = {
type: 'root',
children: [
{
type: 'element',
tag: 'DecryptVerifier',
props: {
question: encryptConfig.question,
cipherText: encrypted,
images: uniqueImages
},
children: []
}
]
}
// 移除敏感信息
if (content.encrypt) delete content.encrypt.answer
if (content.meta?.encrypt) delete content.meta.encrypt.answer
}
})
}
})
9. 新增动态(Echo)功能
新增了类似朋友圈的“动态”板块,用于发布短篇想法或生活点滴。支持 Markdown 渲染、图片九宫格、外部扩展(视频、链接)以及点赞评论互动。
核心特性
- API 集成:对接外部 API 获取动态数据,支持分页加载。
- Markdown 渲染:在动态卡片中支持部分 Markdown 语法及 MDC 组件渲染。
- 智能时间显示:实现了仿微信的“刚刚”、“几分钟前”等相对时间显示。
- 交互体验:支持点赞(本地存储状态)、评论(Artalk 集成)、长文折叠、图片灯箱。
<script setup lang="ts">
// ... 数据获取逻辑
const fetchEchoes = async () => {
// ...
const data = await $fetch<EchoResponse>(url)
// ...
}
</script>
<template>
<div class="echo-page">
<div class="echo-header">
<h2 class="echo-title">动态</h2>
<p class="echo-desc">记录生活中的点滴与思考</p>
</div>
<div class="echo-container">
<TransitionGroup name="list" tag="div" class="echo-list">
<EchoCard
v-for="echo in echoes"
:key="echo.id"
:echo="echo"
@like="handleLike"
@tag-click="handleTagClick"
/>
</TransitionGroup>
</div>
</div>
</template>

评论加载中...