1. 添加字体切换组件
在侧边栏添加了一个字体切换组件<FontToggle />,允许用户在不同字体之间切换,以提升阅读体验。
<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>
<footer class="sidebar-footer"> <FontToggle /> <BlogThemeToggle /> <ZIconNavList :list="appConfig.footer.iconNav" /> </footer> </aside> </template>
2. 增加代码块diff高亮支持
在代码块组件中增加了对diff语法的支持,可以高亮显示添加和删除的行,提升代码对比的可读性。
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'))
}
return createHighlighterCore({ themes: [catppuccinLatte, oneDarkPro], engine })
}
async function loadLang(...langs: string[]) {
// @ts-expect-error CDN 动态引入的包无类型
const { bundledLanguages } = await import('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,
}
})
组件修改
在 app/components/content/ProsePre.vue 中,将原始 meta 数据传递给 store,并添加了 diff 相关的样式。
{ meta: { indent: getIndent(), __raw: props.meta } },
&.diff {
&::after {
position: absolute;
inset-inline-start: 0.5em;
z-index: 2;
}
&.add {
background-color: rgba(16, 185, 129, 0.14);
&::after {
content: "+";
color: #10b981;
}
&::before {
color: #10b981;
}
}
&.remove {
background-color: rgba(244, 63, 94, 0.14);
&::after {
content: "-";
color: #f43f5e;
}
&::before {
color: #f43f5e;
}
}
}
}
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>
<PostFooter v-bind="post" /> <PostTimeStatus v-bind="post" /> <PostSurround /> <PostComment /> </template>
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,以提供更丰富的评论功能和更好的用户体验。
<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'
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>
/**
* Artalk 评论系统管理器
* - 全局单例
* - 支持 init(完整评论) / loadCountWidget(纯计数)
*/
interface ArtalkOptions {
el: string | HTMLElement
pageKey: string
pageTitle: string
server?: string
site?: string
emoticons?: string
darkMode?: boolean
}
interface CountWidgetOptions {
server?: string
site?: string
pvEl?: string
countEl?: string
statPageKeyAttr?: string
}
class ArtalkManager {
private static instance: ArtalkManager
private artalkInstance: any = null
private currentPageKey = ''
private constructor() {}
public static getInstance(): ArtalkManager {
if (!ArtalkManager.instance) {
ArtalkManager.instance = new ArtalkManager()
}
return ArtalkManager.instance
}
/* ---------- 完整评论 ---------- */
public async init(options: ArtalkOptions): Promise<void> {
if (this.currentPageKey === options.pageKey && this.artalkInstance)
return
this.destroy()
await this.waitForArtalk()
this.retryUntil(() => {
let el: HTMLElement | null
if (typeof options.el === 'string') {
el = document.getElementById(options.el.replace('#', ''))
}
else {
el = options.el
}
/// @ts-expect-error 导入ar
if (el && window.Artalk) {
// eslint-disable-next-line ts/ban-ts-comment
// @ts-expect-error
this.artalkInstance = window.Artalk.init(options)
this.currentPageKey = options.pageKey
return true
}
return false
})
}
/* ---------- 纯计数器 ---------- */
public async loadCountWidget(opts: CountWidgetOptions): Promise<void> {
await this.waitForArtalk()
this.retryUntil(() => {
// @ts-expect-error 加载计数
if (window.Artalk?.loadCountWidget) {
// eslint-disable-next-line ts/ban-ts-comment
// @ts-expect-error
window.Artalk.loadCountWidget(opts)
return true
}
return false
})
}
/* ---------- 工具方法 ---------- */
public destroy(): void {
if (this.artalkInstance?.destroy) {
// eslint-disable-next-line style/max-statements-per-line
try { this.artalkInstance.destroy() }
catch {}
}
this.artalkInstance = null
this.currentPageKey = ''
}
public setDarkMode(isDark: boolean): void {
this.artalkInstance?.setDarkMode?.(isDark)
}
private async loadScript(): Promise<void> {
// 1️⃣ 服务器端直接返回
if (typeof window === 'undefined')
return
// 2️⃣ 已经加载过
if ((window as any).Artalk)
return
// 3️⃣ 检查是否已有 script 标签正在加载
if (document.querySelector('script[src*="Artalk.js"]'))
return
return new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/artalk/2.9.1/Artalk.js' // 换成你自己的地址
script.async = true
script.crossOrigin = 'anonymous'
script.onload = () => resolve()
script.onerror = () => reject(new Error('Artalk 脚本加载失败'))
document.head.appendChild(script)
})
}
private async waitForArtalk(): Promise<void> {
// 服务器端直接返回
if (typeof window === 'undefined')
return
await this.loadScript()
return new Promise<void>((res) => {
const check = () =>
(window as any).Artalk ? res() : setTimeout(check, 200)
check()
})
}
private async retryUntil(fn: () => boolean, max = 100): Promise<void> {
let i = 0
while (i++ < max) {
if (fn())
return
await new Promise(r => setTimeout(r, 200))
}
throw new Error('Artalk 元素或方法未就绪')
}
}
export default ArtalkManager
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)
// 标签云样式计算
function getTagStyle(count: number) {
const maxCount = tagsList.value[0]?.count || 1
const minSize = 1
const maxSize = 2.5
// 线性插值
const size = minSize + (count / maxCount) * (maxSize - minSize)
// 随机颜色或根据数量计算颜色透明度
const opacity = 0.5 + (count / maxCount) * 0.5
return {
fontSize: `${size}em`,
opacity: opacity
}
}
</script>
<template>
<div class="tags-page proper-height">
<div class="tags-cloud" :class="{ centered: !currentTag }">
<NuxtLink
v-for="tag in tagsList"
:key="tag.name"
:to="{ query: { tag: tag.name } }"
class="tag-item"
:class="{ active: currentTag === tag.name }"
:style="getTagStyle(tag.count)"
>
{{ tag.name }}
<span class="tag-count">{{ tag.count }}</span>
</NuxtLink>
</div>
<div v-if="currentTag" class="tag-results">
<h2 class="result-title">
<Icon name="ph:hash-bold" />
{{ currentTag }}
</h2>
<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;
mask-image: linear-gradient(#FFF 50%, #FFF5);
}
.tags-cloud {
display: flex;
flex-wrap: wrap;
gap: 1em;
justify-content: center;
align-items: center;
align-content: center;
margin: 0;
padding: 2rem 1rem;
min-height: 15vh;
transition: min-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
&.centered {
min-height: 60vh;
}
}
.tag-item {
text-decoration: none;
color: var(--c-text-1);
transition: all 0.3s ease;
line-height: 1;
&:hover, &.active {
color: var(--c-primary);
transform: scale(1.1);
opacity: 1 !important;
text-shadow: 0 0 10px var(--c-primary-soft);
}
.tag-count {
font-size: 0.4em;
vertical-align: super;
opacity: 0.6;
}
}
.result-title {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 1.5rem;
font-size: 1.5rem;
color: var(--c-text-1);
.icon {
color: var(--c-primary);
}
}
.archive-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

评论加载中...