提醒
部分教程来自于 栖童の小站,我在此基础上做了些调整,感谢大佬的详细教程。
前言
持续更新中
页面相关
添加说说页面
点击查看Ech0
在/app/pages/新建essay.vue
<script setup lang="ts">
import type { EchoItem, EchoPage, TalkItem } from '~/types/talk'
const title = '说说'
const description = '记录生活点滴,一些想法。'
const image = 'https://图片链接'
useSeoMeta({ title, description, ogImage: image })
const { author } = useAppConfig()
const PAGE_SIZE = 8
/** 说说源站,单条页面拼接为 `${ECHO_SITE}/echo/${id}` */
const ECHO_SITE = 'https://说说链接'
/** 源站接口已支持跨域,浏览器直连,避免静态打包后 server route 不可用 / 数据被固化 */
const ECHO_API = `${ECHO_SITE}/api/echo/page`
interface EchoResponse {
code: number
msg: string
data: EchoPage
}
const talks = ref<TalkItem[]>([])
const total = ref(0)
const page = ref(0)
const loading = ref(false)
const finished = ref(false)
function mapEchoToTalk(item: EchoItem): TalkItem {
const talk: TalkItem = {
text: item.content,
date: new Date(item.created_at * 1000).toISOString(),
tags: item.tags?.map(tag => tag.name),
link: `${ECHO_SITE}/echo/${item.id}`,
}
const ext = item.extension
if (ext?.type === 'VIDEO' && ext.payload?.videoId) {
const id = ext.payload.videoId
talk.video = /^https?:\/\//.test(id)
? { type: 'raw', id }
: { type: 'bilibili', id }
}
if (ext?.payload?.images?.length) {
talk.images = ext.payload.images
}
return talk
}
async function fetchEcho(targetPage: number): Promise<EchoPage> {
const res = await $fetch<EchoResponse>(ECHO_API, {
query: { page: targetPage, size: PAGE_SIZE },
})
return res.data
}
async function loadMore(): Promise<void> {
if (loading.value || finished.value)
return
loading.value = true
try {
const next = page.value + 1
const data = await fetchEcho(next)
const items = (data.items || []).map(mapEchoToTalk)
talks.value.push(...items)
total.value = data.total ?? talks.value.length
page.value = next
if (!items.length || talks.value.length >= total.value)
finished.value = true
}
finally {
loading.value = false
}
}
// 静态打包(pnpm generate)后无服务端执行环境,首屏数据也在浏览器挂载后实时拉取
const sentinel = useTemplateRef('sentinel')
let observer: IntersectionObserver | undefined
onMounted(() => {
loadMore()
if (!sentinel.value)
return
observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting)
loadMore()
}, { rootMargin: '200px' })
observer.observe(sentinel.value)
})
onBeforeUnmount(() => observer?.disconnect())
function replyTalk(content: string): void {
const input = document.querySelector('#twikoo .tk-input textarea')
if (!(input instanceof HTMLTextAreaElement)) return
if (content.trim()) {
const quotes = content.split('\n').map(str => `> ${str}`)
input.value = `${quotes}\n\n`
} else {
input.value = ''
}
input.dispatchEvent(new InputEvent('input'))
const length = input.value.length
input.setSelectionRange(length, length)
input.focus()
}
function getEssayDate(date?: string | Date) {
if (!date) {
return ''
}
const dateStr = typeof date === 'string' ? date : date.toISOString()
return toZdtLocaleString(dateStr, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).replace(/\//g, '-')
}
</script>
<template>
<template #aside>
<WidgetWelcomeVisitor />
<WidgetBlogStats />
<WidgetBlogTech />
<WidgetLatestComments />
<WidgetBlogLog />
<WidgetCommGroup />
</template>
<ZPageBanner :title :description :image>
<template #corner>
<span class="powered-by">Powered by ech0</span>
<UtilLink class="view-more" :to="ECHO_SITE">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><!-- Icon from KDesign Icons by Kingdee / KDesign - Apache-2.0 https://www.apache.org/licenses/LICENSE-2.0 --><path fill="currentColor" d="M12 17a3 3 0 012.995 2.824L15 20v7a3 3 0 01-2.824 2.995L12 30H5a3 3 0 01-2.995-2.824L2 27v-7a3 3 0 012.824-2.995L5 17zm15 0a3 3 0 012.995 2.824L30 20v7a3 3 0 01-2.824 2.995L27 30h-7a3 3 0 01-2.995-2.824L17 27v-7a3 3 0 012.824-2.995L20 17zm-15 2H5a1 1 0 00-.993.883L4 20v7a1 1 0 00.883.993L5 28h7a1 1 0 00.993-.883L13 27v-7a1 1 0 00-.883-.993zm15 0h-7a1 1 0 00-.993.883L19 20v7a1 1 0 00.883.993L20 28h7a1 1 0 00.993-.883L28 27v-7a1 1 0 00-.883-.993zM12 2a3 3 0 012.995 2.824L15 5v7a3 3 0 01-2.824 2.995L12 15H5a3 3 0 01-2.995-2.824L2 12V5a3 3 0 012.824-2.995L5 2zm15 0a3 3 0 012.995 2.824L30 5v7a3 3 0 01-2.824 2.995L27 15h-7a3 3 0 01-2.995-2.824L17 12V5a3 3 0 012.824-2.995L20 2zM12 4H5a1 1 0 00-.993.883L4 5v7a1 1 0 00.883.993L5 13h7a1 1 0 00.993-.883L13 12V5a1 1 0 00-.883-.993zm15 0h-7a1 1 0 00-.993.883L19 5v7a1 1 0 00.883.993L20 13h7a1 1 0 00.993-.883L28 12V5a1 1 0 00-.883-.993z"/></svg>
<span>查看更多</span>
</UtilLink>
</template>
</ZPageBanner>
<div class="talk-list">
<div class="talk-item" v-for="(talk, index) in talks" :key="`${talk.date}-${index}`">
<div class="talk-meta">
<NuxtImg class="avatar" :src="author.avatar" :alt="author.name" />
<div class="info">
<div class="nick">
{{ author.name }}
<Icon class="verified" name="i-material-symbols:verified" />
</div>
<div class="date">{{ getEssayDate(talk.date) }}</div>
</div>
</div>
<div class="talk-content">
<div class="text" v-if="talk.text" v-html="talk.text"></div>
<div class="images" v-if="talk.images">
<Pic class="image" v-for="image in talk.images" :src="image" />
</div>
<VideoEmbed class="video" v-if="talk.video" v-bind="talk.video" height="" />
</div>
<div class="talk-bottom">
<div class="tags">
<span class="tag" v-for="tag in talk.tags">
<Icon name="tabler:tag" />
<span>{{ tag }}</span>
</span>
<UtilLink
class="location"
v-if="talk.location"
v-tip="`搜索: ${talk.location}`"
:to="`https://bing.com/maps?q=${encodeURIComponent(talk.location)}`"
>
<Icon name="tabler:map-pin" />
<span>{{ talk.location }}</span>
</UtilLink>
</div>
<div class="actions">
<UtilLink
class="origin"
v-if="talk.link"
v-tip="'查看原文'"
:to="talk.link"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><!-- Icon from Majesticons by Gerrit Halfmann - MIT https://github.com/halfmage/majesticons/blob/main/LICENSE --><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4m-8-2l8-8m0 0v5m0-5h-5"/></svg>
</UtilLink>
<button class="comment-btn" v-tip="'评论'" @click="replyTalk(talk.text)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><!-- Icon from Mística Icons by Telefónica / Mística - MIT https://opensource.org/licenses/MIT --><path fill="currentColor" d="M6 9.25a.75.75 0 010 1.5H4a.25.25 0 00-.25.25v8.19l1.72-1.72.114-.094A.75.75 0 016 17.25h7a.25.25 0 00.25-.25v-2a.75.75 0 011.5 0v2A1.75 1.75 0 0113 18.75H6.31l-2.78 2.78A.75.75 0 012.25 21V11A1.75 1.75 0 014 9.25zm14-7A1.75 1.75 0 0121.75 4v10a.75.75 0 01-1.28.53l-2.78-2.78H11A1.75 1.75 0 019.25 10V4A1.75 1.75 0 0111 2.25zm-9 1.5a.25.25 0 00-.25.25v6a.25.25 0 00.25.25h7c.199 0 .39.08.53.22l1.72 1.72V4a.25.25 0 00-.25-.25z"/></svg>
</button>
</div>
</div>
</div>
<div ref="sentinel" class="talk-footer">
<p v-if="loading">加载中…</p>
<p v-else-if="!talks.length">暂无说说</p>
<p v-else-if="finished">没有更多了</p>
</div>
</div>
<PostComment />
</template>
<style scoped>
/* 横幅右下角角标 */
.powered-by {
opacity: .75;
}
.view-more {
display: inline-flex;
align-items: center;
gap: .25em;
color: inherit;
opacity: .75;
transition: opacity .2s;
&:hover {
opacity: 1;
}
svg {
display: block;
width: 1.1em;
height: 1.1em;
}
}
.talk-list {
animation: float-in .2s backwards;
margin: 1rem;
.talk-item {
animation: float-in .3s backwards;
animation-delay: var(--delay);
border-radius: 8px;
box-shadow: 0 0 0 1px var(--c-bg-soft);
display: flex;
flex-direction: column;
gap: .5rem;
margin-bottom: 1rem;
padding: 1rem;
.talk-meta {
align-items: center;
display: flex;
gap: 10px;
.avatar {
border-radius: 50%;
box-shadow: 2px 4px 1rem var(--ld-shadow);
width: 3em;
@supports (corner-shape: squircle) {
corner-shape: superellipse(1.2);
}
}
.nick {
align-items: center;
display: flex;
gap: 5px;
}
.date {
color: var(--c-text-3);
font-family: var(--font-monospace);
font-size: .8rem;
}
.verified {
color: var(--c-primary);
font-size: 16px;
}
}
.talk-content {
color: var(--c-text-2);
display: flex;
flex-direction: column;
gap: .5rem;
line-height: 1.6;
:deep(a[href]) {
margin: -.1em -.2em;
padding: .1em .2em;
background: linear-gradient(var(--c-primary-soft), var(--c-primary-soft)) no-repeat center bottom / 100% .1em;
color: var(--c-primary);
transition: all .2s;
&:hover {
border-radius: .3em;
background-size: 100% 100%;
}
}
.images {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, 1fr);
}
.image {
border-radius: 8px;
overflow: hidden;
padding-bottom: 100%;
position: relative;
:deep(img) {
height: 100%;
object-fit: cover;
position: absolute;
transition: transform .3s;
width: 100%;
&:hover {
transform: scale(1.05);
}
}
}
.video {
border-radius: 8px;
margin: 0;
}
}
.talk-bottom {
align-items: center;
color: var(--c-text-3);
display: flex;
justify-content: space-between;
.tags {
display: flex;
font-size: .7rem;
gap: 4px;
}
.tag, .location {
display: flex;
padding: 2px 4px;
border-radius: 4px;
background-color: var(--c-bg-2);
align-items: center;
cursor: pointer;
transition: all .2s;
&:hover {
opacity: .8;
}
}
.tag .tabler-tag + * {
margin-left: .15em;
}
.location {
color: var(--c-primary);
}
.actions {
align-items: center;
display: flex;
gap: 4px;
}
.origin {
align-items: center;
background-color: var(--c-bg-2);
border: 1px solid var(--c-bg-soft);
border-radius: 4px;
color: var(--c-text-3);
display: flex;
padding: 2px 6px;
transition: all .2s;
&:hover {
opacity: .8;
}
svg {
display: block;
height: 1.15em;
width: 1.15em;
}
}
.comment-btn {
align-items: center;
background-color: var(--c-bg-2);
border: 1px solid var(--c-bg-soft);
border-radius: 4px;
color: var(--c-text-3);
cursor: pointer;
display: flex;
padding: 2px 6px;
transition: all .2s;
&:hover {
opacity: .8;
}
svg {
display: block;
height: 1.15em;
width: 1.15em;
}
}
}
}
.talk-footer {
color: var(--c-text-3);
font-size: 1rem;
margin: 2rem 0;
text-align: center;
}
}
</style>
在/app/types/新建talk.ts
export type TalkItem = {
text?: string
date: string
images?: string[]
video?: {
type?: 'raw' | 'bilibili' | 'bilibili-nano' | 'youtube' | 'douyin' | 'douyin-wide' | 'tiktok'
id: string
ratio?: string | number
poster?: string
}
tags?: string[]
location?: string
/** 查看原文的链接(源站单条说说页面) */
link?: string
}
/** 说说接口返回的标签 */
export interface EchoTag {
id: string
name: string
usage_count?: number
created_at?: number
}
/** 说说接口返回的媒体扩展 */
export interface EchoExtension {
id?: string
echo_id?: string
type?: string
payload?: {
/** B 站 bvid 或原始视频直链 */
videoId?: string
images?: string[]
[key: string]: unknown
}
created_at?: number
updated_at?: number
}
/** 说说接口返回的单条数据 */
export interface EchoItem {
id: string
content?: string
username?: string
user_id?: string
layout?: string
private?: boolean
tags?: EchoTag[]
extension?: EchoExtension
fav_count?: number
/** Unix 秒级时间戳 */
created_at: number
}
/** 说说分页接口数据 */
export interface EchoPage {
total: number
items: EchoItem[]
}
添加标签页面
在/app/pages/新建tags.vue
<script setup lang="ts">
import type { ArticleProps } from '~/types/article'
const appConfig = useAppConfig()
const title = '标签'
const description = `${appConfig.title}的所有文章标签。`
useSeoMeta({ title, description })
const { data: listRaw } = await useAsyncData('posts:index', () => queryArticleIndex(), { default: () => [] })
const { listSorted, isAscending, sortOrder } = useArticleSort(listRaw)
// 选中的标签
const selectedTag = ref<string>('')
// 计算每个标签对应的文章
const articlesByTag = computed(() => {
const result: Record<string, ArticleProps[]> = {}
for (const article of listSorted.value) {
if (article.tags) {
for (const tag of article.tags) {
(result[tag] ||= []).push(article)
}
}
}
return result
})
// 排序后的标签列表(按文章数量降序)
const sortedTags = computed(() => {
return Object.keys(articlesByTag.value).sort((a, b) => {
return (articlesByTag.value[b]?.length || 0) - (articlesByTag.value[a]?.length || 0)
})
})
// 根据文章数量计算标签大小的函数
function getTagSize(count: number): string {
const counts = Object.values(articlesByTag.value).map(articles => articles.length)
const maxCount = Math.max(...counts)
const minCount = Math.min(...counts)
const range = maxCount - minCount
if (range === 0) return 'medium'
const ratio = (count - minCount) / range
if (ratio < 0.33) return 'small'
if (ratio < 0.66) return 'medium'
return 'large'
}
// 点击标签显示对应文章
function handleTagClick(tag: string) {
selectedTag.value = tag
// 滚动到页面顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 取消选中标签,返回标签云视图
function clearSelectedTag() {
selectedTag.value = ''
}
</script>
<template>
<template #aside>
<WidgetBlogStats />
<WidgetBlogLog />
</template>
<div class="tags proper-height">
<!-- 选中标签时显示 -->
<div v-if="selectedTag" class="tag-selected">
<div class="tag-selected-header">
<h1 class="tag-selected-title">
<span class="tag-hashtag">#</span> {{ selectedTag }}
</h1>
<button class="tag-clear-btn" @click="clearSelectedTag" aria-label="返回标签云">
<Icon name="tabler:x" />
</button>
</div>
<div class="tag-selected-info">
共 {{ articlesByTag[selectedTag]?.length }} 篇文章
</div>
<menu class="archive-list">
<UtilListTransition v-slot="{ items }" :items="articlesByTag[selectedTag]" :state="selectedTag">
<PostArchive
v-for="article, index in items"
:key="article.path"
:data-list-key="article.path"
v-bind="article"
:to="article.path"
:use-updated="sortOrder === 'updated'"
:style="getFixedDelay(index * 0.03)"
/>
</UtilListTransition>
</menu>
</div>
<!-- 标签云视图 -->
<div v-else class="tag-cloud">
<h1 class="tag-cloud-title">{{ title }}</h1>
<div class="tag-cloud-content">
<button
v-for="tag in sortedTags"
:key="tag"
class="tag-cloud-item gradient-card"
:class="getTagSize(articlesByTag[tag]?.length)"
@click="handleTagClick(tag)"
>
# {{ tag }}
<span class="tag-count">{{ articlesByTag[tag]?.length }}</span>
</button>
</div>
<div class="tag-cloud-stats">
共 {{ sortedTags.length }} 个标签
</div>
</div>
</div>
</template>
<style scoped>
.tags {
margin: 1rem;
padding: 2rem 0;
}
/* 标签云样式 */
.tag-cloud {
max-width: 800px;
margin: 0 auto;
}
.tag-cloud-title {
text-align: center;
font-size: 2.5rem;
margin-bottom: 2rem;
color: var(--c-text);
}
.tag-cloud-content {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
margin-bottom: 2rem;
}
.tag-cloud-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 2rem;
background-color: var(--c-bg-2);
color: var(--c-text);
cursor: pointer;
line-height: 1.4;
&.small {
font-size: 0.9rem;
}
&.medium {
font-size: 1.1rem;
}
&.large {
font-size: 1.3rem;
font-weight: 600;
}
}
.tag-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background-color: var(--c-bg-3);
color: var(--c-text-2);
font-size: 0.8rem;
font-weight: 500;
}
.tag-cloud-stats {
text-align: center;
color: var(--c-text-2);
font-size: 0.9rem;
margin-top: 2rem;
}
/* 选中标签时的样式 */
.tag-selected {
max-width: 800px;
margin: 0 auto;
}
.tag-selected-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.tag-selected-title {
font-size: 2.5rem;
font-family: var(--font-creative);
font-weight: 550;
color: var(--c-text);
margin: 0;
}
.tag-hashtag {
margin-inline-end: 0.1em;
padding: 0 2px;
border-radius: 0.2rem;
background-color: var(--c-primary);
color: white;
}
.tag-clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--c-bg-2);
color: var(--c-text-2);
border: none;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: var(--c-bg-3);
color: var(--c-text);
transform: rotate(90deg);
}
}
.tag-selected-info {
color: var(--c-text-2);
font-size: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--c-border);
}
.archive-list {
margin-top: 1.5rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.tags {
margin: 0.5rem;
padding: 1rem 0;
}
.tag-cloud-title,
.tag-selected-title {
font-size: 2rem;
}
.tag-cloud-item {
padding: 0.4rem 0.8rem;
gap: 0.4rem;
}
.tag-cloud-item.large {
font-size: 1.2rem;
}
}
</style>
友链朋友圈
适配六神的轻量友链朋友圈
在/scripts/新建generate-friend.ts
#!/usr/bin/env node
import fs from 'node:fs'
import { resolve } from 'node:path'
import { intro, log, outro } from '@clack/prompts'
import { entries, displayName } from './framework/utils'
const blacklist = ['xxx', 'xxx']
intro('👥 生成友链数据')
try {
const friends = entries
.filter(e => !e.error)
.map(e => {
const name = displayName(e)
return blacklist.includes(name) ? null : [name, e.link, e.avatar]
})
.filter(Boolean)
fs.mkdirSync(resolve('public'), { recursive: true })
const outputPath = resolve('public', 'friend.json')
const friendData = { friends }
fs.writeFileSync(outputPath, JSON.stringify(friendData, null, 2), 'utf-8')
log.success(`成功生成 ${friends.length} 个友链`)
log.info(`输出路径: ${outputPath}`)
outro('✅ 完成')
} catch (error) {
log.error('生成失败:', error instanceof Error ? error.message : String(error))
process.exit(1)
}
执行
pnpm tsx scripts/generate-friend.ts
会在public目录生成friend.json
在/app/pages/新建fcircle.vue
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
const title = '朋友圈'
const description = '发现更多有趣的博主。'
const image = 'https://图片链接'
useSeoMeta({ title, description, ogImage: image })
// 配置选项
const UserConfig = reactive({
api_url: 'https://轻量朋友圈/',
page_size: 20
})
// 状态管理
const allArticles = ref([])
const displayCount = ref(20)
const isLoading = ref(true)
const randomArticle = ref(null)
const showAvatarPopup = ref(false)
const selectedAuthor = ref('')
const selectedAuthorAvatar = ref('')
const selectedArticleLink = ref('')
const articlesByAuthor = ref({})
const lastUpdatedDate = ref('')
// 计算属性
const displayedArticles = computed(() => allArticles.value.slice(0, displayCount.value))
const hasMoreArticles = computed(() => allArticles.value.length > displayCount.value)
// 格式化日期
const formatDate = (dateString: string) => {
if (!dateString) return ''
return toZdtLocaleString(dateString, 'date').replace(/\//g, '-')
}
// 刷新随机文章
const refreshRandomArticle = () => {
if (allArticles.value.length > 0) {
const randomIndex = Math.floor(Math.random() * allArticles.value.length)
randomArticle.value = allArticles.value[randomIndex]
}
}
// 加载更多
const loadMore = () => {
displayCount.value += UserConfig.page_size
}
// 模态框相关
const showAvatarPosts = (author, avatar, articleLink) => {
selectedAuthor.value = author
selectedAuthorAvatar.value = avatar
selectedArticleLink.value = articleLink
showAvatarPopup.value = true
}
const closeAvatarPopup = () => {
showAvatarPopup.value = false
}
// 监听点击外部关闭弹窗
const handleClickOutside = (event) => {
const popup = document.getElementById('avatar-popup')
if (popup && !popup.contains(event.target) && showAvatarPopup.value) {
closeAvatarPopup()
}
}
// 获取数据
const fetchData = async () => {
try {
isLoading.value = true
const response = await fetch(`${UserConfig.api_url}all.json`)
const data = await response.json()
// 处理数据
allArticles.value = data.article_data.map(item => ({
id: item.link + Math.random(), // 确保唯一ID
title: item.title,
link: item.link,
author: item.author,
created: item.created,
avatar: item.avatar
}))
// 按作者分组
articlesByAuthor.value = allArticles.value.reduce((acc, article) => {
if (!acc[article.author]) acc[article.author] = []
acc[article.author].push(article)
return acc
}, {})
// 初始化随机文章
refreshRandomArticle()
// 设置最新更新日期
if (allArticles.value.length > 0) {
const sortedArticles = [...allArticles.value].sort((a, b) =>
new Date(b.created) - new Date(a.created)
)
lastUpdatedDate.value = formatDate(sortedArticles[0].created)
}
} catch (error) {
console.error('加载文章失败:', error)
} finally {
isLoading.value = false
}
}
// 生命周期钩子
onMounted(() => {
fetchData()
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<template #aside>
<WidgetBlogStats />
<WidgetBlogTech />
<WidgetBlogLog />
</template>
<ZPageBanner :title :description :image>
<div class="stats-bar">
<div class="stats-update-time">Updated at {{ lastUpdatedDate || '2025-07-17' }}</div>
<div class="stats-powered-by">Powered by FriendCircleLite</div>
</div>
</ZPageBanner>
<div class="page-fcircle">
<div class="fcircle">
<!-- 随机文章区域 -->
<div v-if="randomArticle" class="random-article">
<div class="random-title">随机文章</div>
<div class="article-item">
<a
:href="randomArticle.link"
target="_blank"
rel="noopener noreferrer"
class="article-container gradient-card"
>
<span class="article-author">{{ randomArticle.author }}</span>
<span class="article-title">{{ randomArticle.title }}</span>
<span class="article-date">{{ formatDate(randomArticle.created) }}</span>
</a>
</div>
<ZButton
class="refresh-btn gradient-card"
@click="refreshRandomArticle"
icon="uim:process"
/>
</div>
<!-- 文章列表区域 -->
<div class="articles-list">
<div
v-for="(article, index) in displayedArticles"
:key="article.id"
class="article-item new-item"
:style="{ '--delay': `${(index % UserConfig.page_size) * 0.05}s` }"
>
<div class="article-image" @click="showAvatarPosts(article.author, article.avatar, article.link)">
<NuxtImg
:src="article.avatar"
:alt="article.author"
loading="lazy"
/>
</div>
<a
:href="article.link"
target="_blank"
rel="noopener noreferrer"
class="article-container gradient-card"
>
<span class="article-author">{{ article.author }}</span>
<span class="article-title">{{ article.title }}</span>
<span class="article-date">{{ formatDate(article.created) }}</span>
</a>
</div>
</div>
<!-- 加载更多按钮 -->
<ZButton
v-show="hasMoreArticles"
class="load-more gradient-card"
@click="loadMore"
text="加载更多"
/>
<!-- 空状态 -->
<div v-if="!isLoading && allArticles.length === 0" class="error-container">
<Icon class="error-icon" name="tabler:file-alert" />
<p>暂无文章数据</p>
<p class="empty-hint">请稍后再试</p>
</div>
<!-- 作者模态框 - 时间线样式 -->
<Transition name="modal">
<div
v-if="showAvatarPopup && selectedAuthor && articlesByAuthor[selectedAuthor]"
id="avatar-popup"
class="modal"
@click="closeAvatarPopup"
>
<div class="modal-content" @click.stop>
<div class="modal-header">
<NuxtImg
:src="selectedAuthorAvatar"
:alt="selectedAuthor"
loading="lazy"
/>
<h3>{{ selectedAuthor }}</h3>
<a
:href="selectedArticleLink"
target="_blank"
rel="noopener noreferrer"
class="author-link"
>
<Icon name="lucide:external-link" />
</a>
</div>
<div class="modal-body">
<div class="timeline">
<div
v-for="(article, index) in articlesByAuthor[selectedAuthor].slice(0, 10)"
:key="article.id"
class="timeline-item"
:style="{ '--delay': (0.2 + index * 0.1) + 's' }"
>
<span class="date">{{ formatDate(article.created) }}</span>
<a
:href="article.link"
target="_blank"
rel="noopener noreferrer"
class="article-title"
@click="closeAvatarPopup"
>
{{ article.title }}
</a>
</div>
</div>
</div>
<div class="modal-avatar">
<NuxtImg
:src="selectedAuthorAvatar"
:alt="selectedAuthor"
loading="lazy"
/>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<style scoped>
.stats-bar {
align-items: flex-end;
color: #eee;
display: flex;
flex-direction: column;
font-family: var(--font-monospace);
font-size: .7rem;
gap: .1rem;
opacity: .7;
text-shadow: 0 4px 5px rgba(0, 0, 0, .5);
.stats-update-time {
opacity: 1;
}
.stats-powered-by {
opacity: .8;
}
}
.page-fcircle {
animation: float-in .2s backwards;
margin: 1rem;
.random-article {
align-items: center;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: space-between;
margin: 1rem 0;
.random-title {
font-size: 1.2rem;
white-space: nowrap;
}
.article-item {
flex: 1;
min-width: 0;
.article-container {
min-width: 0;
.article-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-author,
.article-date {
flex-shrink: 0;
}
}
}
.refresh-btn {
align-items: center;
background-color: unset;
border-radius: 8px;
box-shadow: none;
color: var(--c-text-2);
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 2.5rem;
justify-content: center;
transition: all .2s ease;
width: 2.5rem;
&:hover {
background-color: unset;
}
}
}
.articles-list {
display: flex;
flex-direction: column;
gap: .5rem;
}
}
.article-item {
align-items: center;
display: flex;
gap: 10px;
width: 100%;
&.new-item {
animation: float-in .2s var(--delay) backwards;
}
.article-image {
border-radius: 50%;
box-shadow: 0 0 0 1px var(--c-bg-soft);
display: flex;
flex-shrink: 0;
height: 2rem;
overflow: hidden;
width: 2rem;
img {
height: 100%;
object-fit: cover;
opacity: .8;
transition: all .2s;
width: 100%;
}
}
.article-container {
align-items: center;
border-radius: 8px;
box-shadow: 0 0 0 1px var(--c-bg-soft);
display: flex;
gap: 5px;
overflow: hidden;
padding: 10px;
width: 100%;
&:hover .article-title {
color: var(--c-text);
}
.article-author {
color: var(--c-text-3);
font-size: .85rem;
}
.article-title {
color: var(--c-text-2);
flex: 1;
font-size: .9375rem;
overflow: hidden;
text-overflow: ellipsis;
transition: color .2s;
white-space: nowrap;
}
.article-date {
color: var(--c-text-3);
font-family: var(--font-monospace);
font-size: .75rem;
}
}
}
.load-more {
background-color: var(--ld-bg-card);
border-radius: 8px;
box-shadow: .1em .2em .5rem var(--ld-shadow);
display: block;
font-size: .875rem;
height: 42px;
margin: 1rem auto;
padding: .75rem;
width: 200px;
&:hover {
color: var(--c-text);
}
}
/* 模态框样式 */
.modal {
align-items: center;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex;
inset: 0;
justify-content: center;
position: fixed;
z-index: 900;
.modal-content {
background-color: var(--c-bg-a50);
border-radius: 12px;
box-shadow: 0 0 0 1px var(--c-bg-soft);
max-height: 80vh;
max-width: 500px;
overflow-y: auto;
padding: 1.25rem;
position: relative;
width: 90%;
.modal-header {
align-items: center;
border-bottom: 1px solid var(--c-bg-soft);
display: flex;
gap: 15px;
margin-bottom: 20px;
padding-bottom: 15px;
img {
border-radius: 50%;
height: 50px;
object-fit: cover;
width: 50px;
}
h3 {
flex: 1;
font-size: 1.2rem;
margin: 0;
}
.author-link {
border-radius: 8px;
color: var(--c-text-2);
padding: 8px;
transition: all .3s;
&:hover {
background: var(--c-bg-soft);
color: var(--c-text);
}
}
}
.modal-body {
.timeline {
position: relative;
&:after {
background-color: var(--c-bg-soft);
bottom: 0;
content: "";
left: .25rem;
position: absolute;
top: .5rem;
transform: translate(-50%);
width: 2px;
}
.timeline-item {
animation: slideIn .3s ease-out both;
color: var(--c-text-2);
padding: 0 0 1rem 1.25rem;
position: relative;
&:before {
background-color: var(--c-text-2);
border-radius: 50%;
content: "";
height: .5rem;
left: .25rem;
position: absolute;
top: .5rem;
transform: translateY(-50%) translate(-50%);
transition: transform .3s ease, box-shadow .3s ease;
width: .5rem;
z-index: 1;
}
&:hover:before {
box-shadow: 0 0 8px var(--c-text-2);
transform: translateY(-50%) translate(-50%) scale(1.5);
}
.date {
color: var(--c-text-3);
display: block;
font-family: var(--font-monospace);
font-size: .875rem;
margin-bottom: .3rem;
}
.article-title {
color: var(--c-text-2);
line-height: 1.4;
transition: color .3s;
&:hover {
color: var(--c-text);
}
}
}
}
}
.modal-avatar {
border-radius: 50%;
bottom: 1.25rem;
filter: blur(5px);
height: 128px;
opacity: .6;
overflow: hidden;
pointer-events: none;
position: absolute;
right: 1.25rem;
width: 128px;
z-index: 1;
img {
height: 100%;
object-fit: cover;
width: 100%;
}
}
}
}
@keyframes slideIn {
0% {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 模态框过渡 */
.modal-enter-active,
.modal-enter-active .modal-content,
.modal-leave-active,
.modal-leave-active .modal-content {
transition: all .3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
transform: translateY(-20px);
}
.modal-enter-to,
.modal-leave-from {
opacity: 1;
}
.modal-enter-to .modal-content,
.modal-leave-from .modal-content {
transform: translateY(0);
}
.loading-container {
align-items: center;
color: var(--c-text-2);
display: flex;
flex-direction: column;
gap: 12px;
height: 500px;
justify-content: center;
.loading-spinner {
animation: spin 1s linear infinite;
border: 3px solid var(--c-bg-3);
border-radius: 50%;
border-top-color: var(--c-primary);
height: 40px;
width: 40px;
}
}
/* 错误容器 */
.error-container {
align-items: center;
color: var(--c-text-2);
display: flex;
flex-direction: column;
gap: 12px;
height: 500px;
justify-content: center;
.error-icon {
color: var(--c-danger);
font-size: 4rem;
}
}
@keyframes spin {
to {
transform: rotate(1turn);
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.random-article .random-title {
display: none;
}
.page-fcircle .article-item .article-container {
flex-wrap: wrap;
height: auto;
}
.page-fcircle .article-item .article-container .article-author {
flex-grow: 1;
}
.page-fcircle .article-item .article-container .article-title {
flex-basis: 100%;
order: 3;
white-space: normal;
}
}
</style>
添加 about 页面
在/app/pages/新建about.vue
<script setup lang="ts">
const { author } = useAppConfig()
const title = '关于我'
const description = '博主的个人介绍页面。'
useSeoMeta({ title, description, ogImage: author.avatar })
// 初始化统计数据
const statsData = ref({
today_uv: '加载中...',
today_pv: '加载中...',
yesterday_uv: '加载中...',
yesterday_pv: '加载中...',
last_month_pv: '加载中...',
last_year_pv: '加载中...'
})
// 获取Umami统计数据
onMounted(async () => {
try {
const response = await $fetch<any>('https://status.chario.cn/api/stats', {
method: 'GET',
headers: {
// 如果需要认证,请添加相应的headers
// 'Authorization': 'Bearer your-token-here'
}
})
if (response) {
statsData.value = {
today_uv: formatNumber(response.today_uv || 0),
today_pv: formatNumber(response.today_pv || 0),
yesterday_uv: formatNumber(response.yesterday_uv || 0),
yesterday_pv: formatNumber(response.yesterday_pv || 0),
last_month_pv: formatNumber(response.last_month_pv || 0),
last_year_pv: formatNumber(response.last_year_pv || 0)
}
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
})
// 格式化数字
function formatNumber(num: number) {
if (num >= 10000) {
return `${(num / 10000).toFixed(1)}万`
}
return num.toString()
}
</script>
<template>
<template #aside>
<WidgetWelcomeVisitor />
<WidgetBlogStats />
<WidgetBlogTech />
<WidgetLatestComments />
<WidgetBlogLog />
<WidgetCommGroup />
</template>
<div class="about-page">
<div class="about-content">
<!-- 页面标题 -->
<header class="about-header">
<div class="header-avatar">
<div class="avatar-frame">
<img :src="author.avatar" alt="作者头像" class="avatar-image">
</div>
</div>
<div class="header-text">
<h1>关于我</h1>
<p>总有些事情比永恒更重要!</p>
</div>
</header>
<!-- 卡片网格布局 -->
<div class="cards-grid">
<!-- 个人介绍卡片 -->
<div class="card intro-card">
<p>您好,很高兴认识您!👋</p>
<h2>我叫 {{ author.name }}</h2>
<p>是一名 打工人、独立开发者、知喜博客管理员、博主。</p>
<Icon name="tabler:user-circle" class="card-bg-icon" />
</div>
<!-- 信息卡片 - 出生和年龄 -->
<div class="card info-card age-card">
<div class="info-item special-info-item">
<span class="label">出生</span>
<span class="value">2005</span>
</div>
<div class="info-item special-info-item">
<span class="label">当前</span>
<span class="value">21岁 <Icon name="ri:graduation-cap-line" /></span>
</div>
<Icon name="tabler:calendar-week" class="card-bg-icon" />
</div>
<!-- 座右铭卡片 -->
<div class="card motto-card">
<span class="label">座右铭</span>
<p>看五年 想三年</p>
<p>认认真真做好一两年</p>
<Icon name="tabler:compass" class="card-bg-icon" />
</div>
<!-- 关注偏好卡片 -->
<div class="card tech-card">
<span class="label">关注偏好</span>
<h3>文学领域</h3>
<p>看书、写文章</p>
<Icon name="tabler:star-filled" class="card-bg-icon" />
</div>
<!-- 音乐偏好卡片 -->
<div class="card music-card">
<span class="label">音乐偏好</span>
<h3>民谣、说唱</h3>
<p>等我喜欢就听</p>
<Icon name="tabler:music" class="card-bg-icon" />
</div>
<!-- 性格卡片 -->
<div class="card info-card personality-card">
<span class="label">性格</span>
<div class="content-center">
<span class="value">提倡者</span>
<span class="value-small">INFJ-T</span>
</div>
<ProseA class="card-link" href="https://www.16personalities.com">在 16 Personalities 了解更多</ProseA>
<Icon name="tabler:brain" class="card-bg-icon" />
</div>
<!-- 特长卡片 -->
<div class="card specialty-card">
<span class="label">特长</span>
<p class="specialty-text">
Linux、绘画
</p>
<p class="specialty-text">
学习能力 <span class="highlight">MAX</span>
</p>
<Icon name="tabler:wand" class="card-bg-icon" />
</div>
<!-- 联系方式卡片 -->
<div class="card contact-card">
<span class="label">联系我</span>
<div class="contact-links">
<ZButton class="contact-link" v-tip="'邮箱'" icon="tabler:mail" :to="`mailto:${author.email}`" />
<ZButton class="contact-link" v-tip="'微信'" icon="tabler:brand-wechat" to="https://weixin.qq.com" />
<ZButton class="contact-link" v-tip="'哔哩哔哩'" icon="ri:bilibili-fill" to="https://bilibili.com" />
<ZButton class="contact-link" v-tip="'Telegram'" icon="tabler:brand-telegram" to="https://t.me" />
<ZButton class="contact-link" v-tip="'Discord'" icon="tabler:brand-discord" to="https://discord.com" />
<ZButton class="contact-link" v-tip="'X'" icon="tabler:brand-x" to="https://x.com" />
</div>
<Icon name="tabler:address-book" class="card-bg-icon" />
</div>
<!-- 网站统计卡片 -->
<div class="card stats-card">
<span class="label">网站统计</span>
<div class="stats-content">
<div class="stats-range-section">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{ statsData.today_pv }}</span>
<span class="stat-label">浏览量</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ statsData.today_uv }}</span>
<span class="stat-label">访客数</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ statsData.yesterday_pv }}</span>
<span class="stat-label">访问次数</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ statsData.yesterday_uv }}</span>
<span class="stat-label">分钟停留</span>
</div>
</div>
</div>
</div>
<Icon name="tabler:chart-histogram" class="card-bg-icon" />
</div>
</div>
</div>
</div>
<PostComment />
</template>
<style scoped>
/* 全局样式 */
.about-page {
padding: 2rem 1rem;
min-height: calc(100vh - var(--header-height));
animation: float-in .3s backwards;
}
.about-content {
max-width: 1000px;
margin: 0 auto;
}
/* 页面标题 */
.about-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 3rem;
padding: 1rem 0;
text-align: center;
.header-text {
h1 {
margin-bottom: .5rem;
font-size: 2.5rem;
font-weight: 800;
color: var(--c-text-1);
}
p {
margin: 0;
font-size: 1.2rem;
color: var(--c-text-2);
}
}
.header-avatar {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
}
.avatar-frame {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
width: 120px;
height: 120px;
border: 3px solid var(--c-border);
border-radius: 50%;
background-color: var(--c-bg-soft);
transition: all .3s ease;
@supports (corner-shape: squircle) {
corner-shape: superellipse(1.2);
}
}
.avatar-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
/* 卡片网格布局 */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
/* 通用卡片样式 */
.card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
min-height: 220px;
padding: 2rem 1.5rem;
border: 1px solid var(--c-border);
border-radius: 1.5rem;
background-color: var(--ld-bg-card);
text-align: center;
transition: all .3s ease;
box-shadow: none;
&:hover {
box-shadow: none;
transform: none;
}
.label {
position: absolute;
opacity: .8;
top: 1rem;
left: 1.5rem;
margin: 0;
font-size: .8rem;
color: var(--c-text-2);
}
.card-bg-icon {
position: absolute;
opacity: .1;
right: 1rem;
bottom: 1rem;
font-size: 5rem;
color: var(--c-text-1);
pointer-events: none;
}
}
/* 卡片类型样式 */
/* 个人介绍卡片 */
.intro-card {
grid-column: 1 / -1;
color: var(--c-text-1);
h2 {
margin: .5rem 0;
font-size: 3rem;
font-weight: bold;
}
}
/* 信息卡片基类 */
.info-card {
align-items: stretch;
justify-content: center;
padding: 2.5rem 1.5rem;
color: var(--c-text-1);
.info-item {
display: flex;
flex-direction: column;
flex-grow: 1;
align-items: flex-start;
justify-content: center;
position: relative;
width: 100%;
.label {
flex-shrink: 0;
position: static;
width: 100%;
margin-bottom: .5rem;
text-align: left;
}
}
.value {
display: block;
width: 100%;
font-size: 2.5rem;
font-weight: bold;
text-align: center;
}
.value-small {
display: block;
width: 100%;
font-size: 2rem;
font-weight: bold;
text-align: center;
}
.card-link {
position: absolute;
right: 1.5rem;
bottom: 1rem;
font-size: .8rem;
color: var(--c-text-2);
&:hover {
color: var(--c-primary);
}
}
}
/* 年龄卡片 */
.age-card {
padding: .4rem 1.5rem .5rem;
}
/* 座右铭卡片 */
.motto-card {
color: var(--c-text-1);
p {
margin: 0;
font-size: 2.5rem;
font-weight: bold;
line-height: 1.2;
}
}
/* 关注偏好卡片 */
.tech-card {
color: var(--c-text-1);
h3 {
margin: .5rem 0;
font-size: 3rem;
font-weight: bold;
}
p {
color: var(--c-text-2);
}
}
/* 音乐偏好卡片 */
.music-card {
color: var(--c-text-1);
h3 {
font-size: 2.5rem;
font-weight: bold;
}
p {
color: var(--c-text-2);
}
}
/* 特长卡片 */
.specialty-card {
font-size: 1.8rem;
font-weight: bold;
text-align: center;
color: var(--c-text-1);
.specialty-text {
margin: .2em 0;
}
.highlight {
display: inline-block;
font-size: 2.5rem;
line-height: 1;
color: var(--c-primary);
}
}
/* 联系方式卡片 */
.contact-card {
grid-column: 1 / -1;
color: var(--c-text-1);
.contact-links {
display: flex;
justify-content: center;
flex-wrap: wrap;
max-width: 600px;
margin: 0 auto;
}
.contact-link {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background-color: var(--c-bg-1);
border: 1px solid var(--c-border);
border-radius: 50%;
color: var(--c-text-1);
font-size: 1.4rem;
transition: all .2s ease;
padding: 0;
box-shadow: none;
&:hover {
background-color: var(--c-bg-soft);
color: var(--c-text);
}
}
}
/* 网站统计卡片 */
.stats-card {
grid-column: 1 / -1;
color: var(--c-text-1);
}
.stats-content {
width: 100%;
}
.stats-range-section {
margin-bottom: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 0;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: .5rem;
background-color: var(--c-bg-1);
border-radius: .8rem;
transition: transform .2s ease;
}
.stat-value {
margin-bottom: .25rem;
font-size: 2rem;
font-weight: bold;
color: var(--c-text-1);
}
.stat-label {
opacity: .9;
font-size: .9rem;
color: var(--c-text-2);
}
/* 动画效果 */
@keyframes float-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式布局 */
@media (max-width: 768px) {
.about-page {
padding: 1rem;
}
.about-header {
flex-direction: column;
text-align: center;
margin-bottom: 2rem;
}
.about-header .header-text {
margin-bottom: 1.5rem;
}
.avatar-frame {
width: 100px;
height: 100px;
}
.card {
padding: 1.5rem 1rem;
}
.cards-grid {
gap: 1rem;
}
.age-card {
padding: .4rem 1.5rem .5rem;
}
.contact-card .contact-link {
width: 45px;
height: 45px;
font-size: 1.2rem;
}
.stats-card {
padding: 3rem 1rem 1.5rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 暗黑模式支持 */
:deep(.dark-mode) {
.card {
border-color: var(--c-border);
background-color: var(--c-bg-2);
}
.label, .card-bg-icon, .tech-card p, .stat-label {
color: var(--c-text-2);
}
.info-card .card-link {
color: var(--c-text-2);
&:hover {
color: var(--c-primary);
}
}
.specialty-card .highlight {
color: var(--c-primary);
}
.contact-card .contact-link {
background-color: var(--c-bg-1);
border-color: var(--c-border);
color: var(--c-text-1);
&:hover {
background-color: var(--c-bg-soft);
color: var(--c-text);
transform: translateY(-2px);
border-color: var(--c-border);
}
}
.stat-item {
background-color: var(--c-bg-1);
}
.avatar-frame {
border-color: var(--c-border);
background-color: var(--c-bg-soft);
}
}
</style>
<style>
.dark .tippy-box {
background-color: var(--c-bg-2);
.tippy-svg-arrow {
fill: var(--c-bg-2);
}
}
</style>
添加留言板页面
在/app/pages/新建comments.vue
<script setup lang="ts">
const title = '留言板'
const description = '您可以在此畅所欲言,当然,请遵守互联网相关的法律法规'
const image = 'https://bu.dusays.com/2025/10/21/68f7083fcc594.webp'
useSeoMeta({ title, description, ogImage: image })
</script>
<template>
<template #aside>
<WidgetBlogStats />
<WidgetBlogTech />
<WidgetBlogLog />
</template>
<ZPageBanner :title :description :image />
<div class="page-comments">
<Quote>{{ description }}</Quote>
<Quote icon="tabler:files">
如果您有什么想说的、想问的,或者发现了本站的小问题🐞欢迎留言
</Quote>
<Quote icon="tabler:link">
需要添加友链?请移步<ProseA :href="'/link'" class="link-text">友链</ProseA>页面
</Quote>
</div>
<PostComment />
</template>
<style scoped>
.page-comments {
animation: float-in .2s backwards;
margin: 1rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
.quote {
max-width: 700px;
width: 100%;
color: var(--c-text-2);
font-size: 1.2rem;
font-weight: var(--font-weight-medium);
line-height: 1.5;
text-align: center;
}
}
.link-text {
color: inherit;
transition: background-size 0.3s ease;
&:hover {
color: var(--c-primary);
text-decoration: none;
background: linear-gradient(var(--c-primary-soft), var(--c-primary-soft)) no-repeat bottom/100% 30%;
}
}
</style>
侧边栏
如何添加自定义侧边栏
修改/app/composables/useWidgets.ts,以恋爱墙为例。
import {
LazyWidgetBlogLog,
LazyWidgetBlogStats,
LazyWidgetBlogTech,
LazyWidgetCommGroup,
LazyWidgetEmpty,
LazyWidgetGithubCard,
LazyWidgetToc,
LazyWidgetLoveWall, // 恋爱墙
} from '#components'
import { pascal } from 'radash'
// @keep-sorted
const rawWidgets = {
LazyWidgetBlogLog,
LazyWidgetBlogStats,
LazyWidgetBlogTech,
LazyWidgetCommGroup,
LazyWidgetEmpty,
LazyWidgetGithubCard,
LazyWidgetToc,
LazyWidgetLoveWall, // 恋爱墙
}
在你需要添加的page页面修改对应的vue,/app/pages/index.vue是文章首页.
// 修改下列参数,'WidgetBlogStats'在上方就是上方'WidgetLoveWall'则是最底下,可自行调整侧边栏顺序
<template #aside>
<WidgetBlogStats />
<WidgetBlogTech />
<WidgetBlogLog />
<WidgetCommGroup />
<WidgetLoveWall /> // 恋爱墙
</template>
添加访客欢迎显示
在/app/components/widget/新建WelcomeVisitor.vue
<script setup lang="ts">
import { computed, onMounted, ref, shallowRef } from 'vue'
// 类型定义
interface IPLocation {
ip: string
beginip: string
endip: string
region: string
isp: string
asn: string
llc: string
latitude: number
longitude: number
}
// 常量配置
const CACHE_KEY = 'visitor_region_info_cache'
const CACHE_DURATION = 10 * 60 * 1000
const API_URL = 'https://uapis.cn/api/v1/network/myip'
const REFERENCE_LNG = 120.3074357
const REFERENCE_LAT = 31.4933074
const EARTH_RADIUS = 6371
const DEFAULT_WELCOME_TEXT = '欢迎客官来到本站'
// 欢迎语映射数据
const provinceWelcomeMap: Record<string, string> = {
北京市: '北——京——欢迎你~~~',
天津市: '讲段相声吧',
河北: '燕赵大地,英雄辈出的河北,等你探索!',
山西: '展开坐具长三尺,已占山河五百余',
内蒙古自治区: '草原辽阔的内蒙古,等你来策马奔腾。',
辽宁: '我想吃烤鸡架!',
吉林: '状元阁就是东北烧烤之王',
黑龙江: '很喜欢哈尔滨大剧院',
上海市: '走在外滩,感受历史与现代的交融。',
江苏: '水乡泽国,江南佳丽地。',
浙江: '这里是浙江,充满江南的韵味!',
河南: '这里是河南,历史悠久文化灿烂。',
安徽: '安徽山水,黄山、九华山欢迎你。',
福建: '福建山水如画,美景无处不在。',
江西: '落霞与孤鹜齐飞,秋水共长天一色',
山东: '山东好客,欢迎来感受齐鲁文化!',
湖北: '湖北,长江中游的明珠,风景秀丽。',
湖南: '湖南,烟雨迷蒙的湘江流过这片土地。',
广东: '带你感受广东的热情与美食!',
广西壮族自治区: '广西山清水秀,民俗风情浓郁。',
海南: '阳光、沙滩、椰风海韵,欢迎来海南度假。',
四川: '来四川,品麻辣火锅,赏壮丽山河。',
贵州: '来贵州,品茅台,赏黄果树瀑布。',
云南: '云南风景独特,风情万种。',
西藏自治区: '西藏,神秘而纯净,等待你的探索。',
陕西: '陕西,历史与文化的交汇之地。',
甘肃: '甘肃,丝绸之路的重要节点。',
青海: '青海,湖泊与草原的美丽结合。',
宁夏回族自治区: '宁夏,塞上江南,黄河流经的美丽地方。',
新疆维吾尔自治区: '新疆的城市各具特色,等待你的探索。',
台湾: '我在这头,大陆在那头',
香港特别行政区: '永定贼有残留地鬼嚎,迎击光非岁玉',
澳门特别行政区: '性感荷官,在线发牌',
}
// 城市特定欢迎语映射
const cityWelcomeMap: Record<string, Record<string, string>> = {
北京市: {
北京市: '北——京——欢迎你~~~',
},
上海市: {
上海市: '走在外滩,感受历史与现代的交融。',
},
广东: {
广州市: '看小蛮腰,喝早茶了嘛~',
深圳市: '今天你逛商场了嘛~',
珠海市: '浪漫之城珠海,海风轻拂。',
东莞市: '东莞,制造业之都,经济活跃。',
佛山市: '佛山,武术之乡,陶瓷文化深厚。',
},
浙江: {
杭州市: '西湖美景,三月天~',
宁波市: '来宁波,感受大海的气息。',
温州市: '温州人杰地灵,商贸繁荣。',
绍兴市: '绍兴,酒乡文化,古韵悠长。',
湖州市: '湖州,太湖之滨,风景如画。',
},
四川: {
成都市: '宽窄巷子,成都慢生活。',
绵阳市: '享受科技城的宁静与创新。',
自贡市: '自贡的盐文化与灯会,独具魅力。',
德阳市: '德阳,历史悠久,文化底蕴深厚。',
乐山市: '乐山大佛,世界文化遗产。',
},
福建: {
厦门市: '鼓浪屿听海,厦门美食让人流连忘返。',
福州市: '有福之州,来此感受千年古城。',
泉州市: '泉州,海上丝绸之路的起点。',
漳州市: '漳州,古城文化与美食的结合。',
南平市: '南平,武夷山的自然风光。',
},
山东: {
青岛市: '来青岛喝啤酒,看大海吧!',
济南市: '泉城济南,四面荷花三面柳。',
烟台市: '烟台的葡萄酒与海鲜,令人陶醉。',
潍坊市: '潍坊,风筝之都,文化底蕴深厚。',
德州市: '德州,扒鸡闻名,文化悠久。',
},
江苏: {
南京市: '六朝古都南京,历史与现代的碰撞。',
苏州市: '来苏州,感受园林之美。',
无锡市: '无锡太湖美景,灵山大佛令人心旷神怡。',
常州市: '常州,文化与科技的交汇点。',
南通市: '南通,海门潮涌,文化底蕴深厚。',
},
河北: {
石家庄市: '燕赵大地,英雄辈出的河北,等你探索!',
},
河南: {
郑州市: '中原大地,郑州是交通枢纽与历史重镇。',
洛阳市: '千年古都洛阳,牡丹花开的城。',
开封市: '开封,古都文化与美食的汇聚地。',
新乡市: '新乡,历史悠久,文化底蕴深厚。',
焦作市: '焦作,云台山的自然风光。',
},
湖南: {
长沙市: '热辣长沙,吃小龙虾逛黄兴路步行街。',
岳阳市: '岳阳楼,洞庭湖的美景尽收眼底。',
株洲市: '株洲,火车制造业的发源地。',
湘潭市: '湘潭,伟人故里,文化底蕴深厚。',
},
湖北: {
武汉市: '来大武汉,过长江大桥,吃热干面!',
宜昌市: '三峡大坝,壮丽的自然奇观。',
荆州市: '荆州,历史文化名城,古韵悠长。',
襄阳市: '襄阳,古城文化与美食的结合。',
},
安徽: {
合肥市: '创新之城合肥,科教文化汇聚地。',
黄山市: '黄山,天下第一奇山,风景如画。',
芜湖市: '芜湖,长江之畔,文化底蕴深厚。',
马鞍山市: '马鞍山,文化与自然的完美结合。',
},
广西壮族自治区: {
桂林市: '桂林山水甲天下,风景如画。',
南宁市: '绿城南宁,宜居宜游。',
柳州市: '柳州的螺蛳粉,独具风味。',
防城港市: '防城港,海洋资源丰富,风景迷人。',
},
贵州: {
贵阳市: '贵阳,山城之美,民族风情浓郁。',
遵义市: '遵义,红色之城,历史悠久。',
安顺市: '安顺,黄果树瀑布的故乡,风景如画。',
毕节市: '毕节,拥有丰富的自然资源与人文景观。',
六盘水市: '六盘水,凉爽的夏天,避暑胜地。',
铜仁市: '铜仁,秀美的山水与独特的民族文化。',
凯里市: '凯里,苗族文化的发源地,风情独特。',
},
云南: {
昆明市: '春城昆明,四季如春,风景秀丽。',
大理市: '苍山洱海,大理古城,你来了就不想走。',
丽江市: '丽江古城,纳西文化的瑰宝。',
西双版纳傣族自治州: '西双版纳,热带雨林的奇妙之地。',
},
西藏自治区: {
拉萨市: '拉萨,西藏的首府,布达拉宫的故乡。',
日喀则市: '日喀则,历史悠久的文化名城。',
林芝市: '林芝,素有"西藏江南"之称,风景如画。',
昌都市: '昌都,历史悠久,文化底蕴深厚。',
山南市: '山南,藏文化的发源地之一。',
那曲市: '那曲,草原风光,牧民生活的地方。',
阿里地区: '阿里,神秘的西部,拥有壮丽的自然景观。',
},
新疆维吾尔自治区: {
乌鲁木齐市: '乌鲁木齐,天山脚下的城市,文化多元。',
喀什市: '喀什,古丝绸之路的重要节点,历史悠久。',
克拉玛依市: '克拉玛依,石油之城,经济发展迅速。',
吐鲁番市: '吐鲁番,火焰山的故乡,葡萄之乡。',
哈密市: '哈密,哈密瓜的发源地,风景如画。',
博乐市: '博乐,草原风光,民族文化交融。',
阿克苏市: '阿克苏,苹果之乡,风景秀丽。',
和田市: '和田,玉石之乡,历史文化深厚。',
},
内蒙古自治区: {
呼和浩特市: '呼和浩特,内蒙古的首府,历史悠久。',
包头市: '包头,钢铁之城,经济发展迅速。',
乌兰察布市: '乌兰察布,草原文化与现代城市的结合。',
赤峰市: '赤峰,拥有丰富的自然资源与人文景观。',
通辽市: '通辽,草原文化的发源地,风情独特。',
鄂尔多斯市: '鄂尔多斯,现代化城市与草原文化的交融。',
巴彦淖尔市: '巴彦淖尔,黄河之畔,风景如画。',
锡林郭勒盟: '锡林郭勒,草原辽阔,马背上的民族风情。',
},
陕西: {
西安市: '西安,古都文化与兵马俑的故乡。',
咸阳市: '咸阳,历史悠久,文化底蕴深厚。',
},
甘肃: {
兰州市: '兰州,黄河之滨,牛肉面闻名。',
天水市: '天水,历史悠久,文化底蕴深厚。',
},
青海: {
西宁市: '西宁,青海湖的门户,风景如画。',
},
吉林: {
长春市: '长春,汽车城,文化底蕴深厚。',
吉林市: '吉林市,松花江畔,风景如画。',
},
黑龙江: {
哈尔滨市: '哈尔滨,冰雪之城,俄罗斯风情浓厚。',
齐齐哈尔市: '齐齐哈尔,黑龙江的明珠,文化底蕴深厚。',
},
山西: {
太原市: '太原,山西会,历史悠久。',
},
辽宁: {
沈阳市: '沈阳,重工业基地,文化底蕴深厚。',
大连市: '大连,北方明珠,浪漫之都。',
},
海南: {
海口市: '海口,阳光沙滩,椰风海韵。',
三亚市: '三亚,碧海蓝天,度假胜地。',
},
}
// 国家欢迎语映射
const countryWelcomeMap: Record<string, string> = {
日本: 'よろしく,一起去看樱花吗',
美国: 'Let us live in peace!',
英国: '想同你一起夜乘伦敦眼',
俄罗斯: '干了这瓶伏特加!',
法国: 'C\'est La Vie',
德国: 'Die Zeit verging im Fluge.',
澳大利亚: '一起去大堡礁吧!',
加拿大: '拾起一片枫叶赠予你',
韩国: '一起去首尔购物吧!',
新加坡: '花园城市新加坡,欢迎你!',
印度: '探索神秘的咖喱之国!',
巴西: '一起感受桑巴的热情!',
意大利: '罗马假日,浪漫之旅!',
西班牙: '弗拉门戈的舞步等你来!',
荷兰: '风车与郁金香的国度!',
瑞士: '阿尔卑斯山的雪景等你欣赏!',
新西兰: '霍比屯的中土世界!',
泰国: '萨瓦迪卡,一起去海滩吧!',
马来西亚: '多元文化,美食天堂!',
印度尼西亚: '千岛之国,潜水胜地!',
菲律宾: '椰风海韵,度假天堂!',
越南: '河内风情,岘港之美!',
土耳其: '热气球之旅,卡帕多奇亚!',
阿联酋: '奢华迪拜,沙漠奇迹!',
沙特阿拉伯: '麦加朝圣,沙漠王国!',
埃及: '金字塔,法老的呼唤!',
南非: '彩虹之国,野生动物的天堂!',
肯尼亚: '动物大迁徙,东非草原!',
尼日利亚: '西非明珠,活力之国!',
墨西哥: '玛雅文明,亡灵之城!',
阿根廷: '探戈与足球的故乡!',
智利: '复活节岛,葡萄酒之乡!',
哥伦比亚: '咖啡之国,南美风情!',
秘鲁: '马丘比丘,印加帝国!',
希腊: '爱琴海畔,古典文明!',
葡萄牙: '大航海时代的荣光!',
比利时: '巧克力与啤酒的国度!',
奥地利: '音乐之都,维也纳之声!',
捷克: '布拉格广场上的钟声!',
波兰: '肖邦的故乡,琥珀之路!',
瑞典: '诺贝尔的故乡,北欧设计!',
挪威: '峡湾与极光的胜地!',
丹麦: '童话王国的魅力!',
芬兰: '千湖之国,极光之地!',
冰岛: '冰与火之国,温泉与瀑布!',
爱尔兰: '绿岛之歌,威士忌之乡!',
匈牙利: '多瑙河畔,温泉之国!',
罗马尼亚: '德古拉城堡,特兰西瓦尼亚!',
乌克兰: '黑海沿岸,葵花之乡!',
哈萨克斯坦: '丝绸之路,中亚明珠!',
巴基斯坦: '喜马拉雅的风光!',
孟加拉国: '恒河三角洲,孟加拉湾!',
斯里兰卡: '印度洋上的珍珠!',
尼泊尔: '徒步天堂,雪山之国!',
阿富汗: '兴都库什的呼唤!',
伊朗: '波斯帝国的辉煌!',
伊拉克: '两河流域,文明古国!',
以色列: '圣城耶路撒冷!',
约旦: '佩特拉古城,死海漂浮!',
黎巴嫩: '中东小巴黎,美食天堂!',
卡塔尔: '石油王国,沙漠明珠!',
科威特: '石油之都,海湾风情!',
巴林: '波斯湾的金融中心!',
阿曼: '阿拉伯半岛的绿色角落!',
厄瓜多尔: '赤道之国,亚马逊雨林!',
乌拉圭: '南美瑞士,足球强国!',
巴拉圭: '伊瓜苏瀑布的故乡!',
委内瑞拉: '石油之国,加拉加斯!',
玻利维亚: '天空之镜,乌尤尼盐沼!',
巴拿马: '运河之都,连接两洋!',
哥斯达黎加: '中美洲瑞士,生态旅游!',
古巴: '加勒比海的雪茄与朗姆!',
牙买加: '雷鬼音乐,蓝山咖啡!',
多米尼加: '加勒比海度假胜地!',
马达加斯加: '狐猴之岛,独一无二!',
坦桑尼亚: '塞伦盖蒂,动物王国!',
加纳: '黄金海岸,可可之国!',
喀麦隆: '非洲缩影,文化熔炉!',
摩洛哥: '摩洛哥风情,撒哈拉沙漠!',
突尼斯: '迦太基古城,地中海明珠!',
}
// 响应式数据
const userLocation = shallowRef<IPLocation | null>(null)
const welcomeText = ref(DEFAULT_WELCOME_TEXT)
const distance = ref<number | null>(null)
const loading = ref(true)
const errorMessage = ref<string | null>(null)
// 计算属性
const locationDescription = computed(() => {
if (!userLocation.value?.region)
return '未知位置'
const regionParts = userLocation.value.region.split(' ')
const country = regionParts[0] || ''
const province = regionParts[1] || ''
const city = regionParts[2] || ''
if (country === '中国') {
return province === city ? province : `${province} ${city}`
}
else {
return country
}
})
// 时间段问候语
const timeGreeting = computed(() => {
const hour = new Date().getHours()
if (hour >= 5 && hour < 11)
return '🌤️ 早上好,一日之计在于晨!'
if (hour >= 11 && hour < 13)
return '☀️ 中午好,记得午休喔~'
if (hour >= 13 && hour < 17)
return '🕞 下午好,饮茶先啦!'
if (hour >= 17 && hour < 19)
return '🚶♂️ 即将下班,记得按时吃饭~'
if (hour >= 19 && hour < 24)
return '🌙 晚上好,夜生活嗨起来!'
return '夜深了,早点休息,少熬夜哦~'
})
function getDistance(lng1: number, lat1: number, lng2: number, lat2: number): number {
const dLat = (lat2 - lat1) * Math.PI / 180
const dLng = (lng2 - lng1) * Math.PI / 180
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180)
* Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return Math.round(EARTH_RADIUS * c)
}
// 缓存管理
function getIpInfoFromCache(): IPLocation | null {
try {
const cached = localStorage.getItem(CACHE_KEY)
if (!cached)
return null
const { data, timestamp } = JSON.parse(cached)
if (Date.now() - timestamp > CACHE_DURATION) {
localStorage.removeItem(CACHE_KEY)
return null
}
return data
}
catch (error) {
console.error('读取缓存失败:', error)
return null
}
}
function setIpInfoToCache(data: IPLocation) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now(),
}))
}
catch (error) {
console.error('保存缓存失败:', error)
}
}
// 业务逻辑
// 根据用户位置设置欢迎语
function setWelcomeText(location: IPLocation) {
if (!location?.region)
return
const regionParts = location.region.split(' ')
const country = regionParts[0] || ''
const province = regionParts[1] || ''
const city = regionParts[2] || ''
if (country === '中国') {
// 优先使用城市特定欢迎语,否则使用份默认欢迎语
welcomeText.value = cityWelcomeMap[province]?.[city]
|| provinceWelcomeMap[province]
|| '带我去你的城市逛逛吧!'
}
else {
// 其他国家的欢迎语
welcomeText.value = countryWelcomeMap[country]
|| '带我去你的国家逛逛吧'
}
}
// 计算用户与博主位置的距离
function calculateDistance(data: IPLocation) {
try {
const { latitude, longitude } = data
// 检查经纬度是否有效
if (latitude && longitude) {
distance.value = getDistance(REFERENCE_LNG, REFERENCE_LAT, longitude, latitude)
}
else {
distance.value = null
}
}
catch (error) {
console.error('计算距离失败:', error)
distance.value = null
}
}
// 获取用户IP位置信息
async function fetchIPLocation() {
// 重置状态
loading.value = true
errorMessage.value = null
try {
// 1. 尝试从缓存获取数据
const cachedData = getIpInfoFromCache()
if (cachedData) {
userLocation.value = cachedData
setWelcomeText(cachedData)
calculateDistance(cachedData)
return
}
// 2. 从API获取新数据
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 3000)
const response = await fetch(API_URL, { signal: controller.signal })
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP错误! 状态码: ${response.status}`)
}
const data: IPLocation = await response.json()
// 处理响应数据
if (data.ip && data.region) {
// 保存数据到缓存
setIpInfoToCache(data)
// 更新组件状态
userLocation.value = data
setWelcomeText(data)
calculateDistance(data)
}
}
catch (error) {
// 错误处理
if (error instanceof Error) {
if (error.name === 'AbortError') {
errorMessage.value = '请求超时,请稍后重试'
}
else if (error.message.includes('Network')) {
errorMessage.value = '网络连接失败'
}
else {
errorMessage.value = error.message
}
}
else {
errorMessage.value = '获取IP位置信息失败'
}
console.error('获取IP位置信息失败:', error)
welcomeText.value = DEFAULT_WELCOME_TEXT
}
finally {
loading.value = false
}
}
// 组件挂载时初始化
onMounted(() => {
fetchIPLocation()
})
</script>
<template>
<BlogWidget card title="访客信息">
<div v-if="loading" class="loading">
<div class="loading-spinner" />
<span>获取位置信息中...</span>
</div>
<div v-else-if="errorMessage" class="error">
<div class="error-icon">
😕
</div>
<p>{{ errorMessage }}</p>
<ZButton text="重试" class="retry-btn" @click="fetchIPLocation" />
</div>
<div v-else class="welcome-message">
<div class="message-content">
<p>欢迎来自 <b>{{ locationDescription }}</b> 的小友💖</p>
<p v-if="distance">
当前位置距博主约 <b>{{ distance }}</b> 公里!
</p>
<p><b>{{ timeGreeting }}</b></p>
<p>Tip:<b>{{ welcomeText }}</b></p>
</div>
</div>
</BlogWidget>
</template>
<style scoped>
.widget-title {
color: var(--c-primary);
}
.loading, .error {
padding: 1rem;
text-align: center;
color: var(--c-text-2);
}
.welcome-message {
padding: 0.5rem;
.message-content {
line-height: 1;
p {
margin-bottom: 0.3rem;
&:last-child {
margin-bottom: 0;
}
b {
color: var(--c-primary);
}
}
}
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(0 0 0 / 10%);
border-top: 3px solid var(--c-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
}
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
.error-icon {
margin-bottom: 0.5rem;
font-size: 2rem;
}
}
.retry-btn {
margin-top: 0.5rem;
&:hover,
&:active {
color: var(--c-primary);
}
}
</style>
最新评论
在/app/components/widget/新建LatestComments.vue
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
// Twikoo配置
const TWIKOO_URL = 'https://twikoo地址'
const ADMIN_MAIL_MD5 = 'twikoo评论管理邮箱的SHA256值'
//// 评论数据状态
const latestComments = ref({
comments: [],
loading: true,
error: false,
lastFetchTime: 0
})
const comments = computed(() => latestComments.value.comments)
const loading = computed(() => latestComments.value.loading)
const error = computed(() => latestComments.value.error)
// 清理评论内容,移除HTML标签
function cleanContent(content: string): string {
if (!content) return ''
const replacements: [RegExp, string][] = [
[/<pre><code>[\s\S]*?<\/code><\/pre>/g, '[代码块]'],
[/<code>([^<]{4,})<\/code>/g, '[代码]'],
[/<code>([^<]{1,3})<\/code>/g, '$1'],
[/<img(?![^>]*class="tk-owo-emotion")[^>]*>/g, '[图片]'],
[/<a[^>]*?>[\s\S]*?<\/a>/g, '[链接]'],
[/<(?!img[^>]*class="tk-owo-emotion")[^>]+>/g, '']
]
return replacements.reduce((text, [regex, replacement]) =>
text.replace(regex, replacement), content
).trim()
}
// 获取最新评论
async function fetchLatestComments() {
const now = Date.now()
// 10分钟内不重复请求
if (now - latestComments.value.lastFetchTime < 10 * 60 * 1000) {
return
}
try {
latestComments.value.loading = true
latestComments.value.error = false
const response = await fetch(TWIKOO_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
event: 'GET_RECENT_COMMENTS',
includeReply: true,
pageSize: 10
}),
timeout: 5000
})
const data = await response.json()
if (!data.data) {
throw new Error('No data')
}
// 处理评论数据
latestComments.value.comments = data.data
.filter((comment: any) => comment.url !== '/')
.slice(0, 5)
.map((comment: any) => ({
content: cleanContent(comment.comment),
author: comment.nick,
date: toZonedTemporal(new Date(comment.created).toISOString()).toString(),
avatar: comment.avatar,
isAdmin: comment.mailMd5 === ADMIN_MAIL_MD5,
url: comment.url,
id: comment.id
}))
latestComments.value.lastFetchTime = now
} catch (err) {
console.error('Failed to fetch comments:', err)
latestComments.value.error = true
} finally {
latestComments.value.loading = false
}
}
// 导航到评论
function navigateToComment(comment: any) {
window.open(`${comment.url}#${comment.id}`, '_self')
}
// 生命周期钩子
let interval: number | null = null
onMounted(() => {
fetchLatestComments()
// 每10分钟刷新一次
interval = window.setInterval(fetchLatestComments, 10 * 60 * 1000)
})
onUnmounted(() => {
if (interval) {
clearInterval(interval)
}
})
</script>
<template>
<BlogWidget card title="最新评论">
<div class="comments-container">
<transition name="fade" mode="out-in">
<template v-if="loading">
<div class="loading">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
</template>
<template v-else-if="error">
<div class="error">
<span class="error-icon">⚠️</span>
<span>评论加载失败</span>
</div>
</template>
<template v-else>
<ul class="comments-list">
<li
v-for="comment in comments"
:key="comment.id"
class="comment-item"
@click="navigateToComment(comment)"
>
<div class="comment-meta">
<div class="author-info">
<img :src="comment.avatar" :alt="comment.author" class="avatar" />
<span class="author">{{ comment.author }}</span>
<Icon v-if="comment.isAdmin" class="admin-badge" name="i-material-symbols:verified" />
</div>
<UtilDate class="date" :date="comment.date" month="2-digit" day="numeric" />
</div>
<p
class="comment-content"
:innerHTML="comment.content"
v-tip="{ content: '点击查看完整评论', placement: 'top-end' }"
></p>
</li>
</ul>
</template>
</transition>
</div>
</BlogWidget>
</template>
<style scoped>
.comments-container {
min-height: 343px;
position: relative;
}
.loading,
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
color: var(--c-text-2);
gap: 8px;
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--c-bg-3);
border-top-color: var(--c-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 0.5rem;
}
.error-icon {
font-size: 3rem;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.comments-list {
list-style: none;
padding: 0;
margin: 0;
}
.comment-item {
border-bottom: 1px solid var(--c-border);
cursor: pointer;
padding: 0.5rem 0;
&:last-child {
border-bottom: none;
}
}
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8em;
color: var(--c-text-2);
margin-bottom: 0.3rem;
}
.author-info {
display: flex;
align-items: center;
gap: 6px;
}
.avatar {
width: 16px;
height: 16px;
border-radius: 50%;
object-fit: cover;
}
.author {
color: var(--c-text-1);
font-weight: 500;
}
.admin-badge {
color: var(--c-primary);
font-size: 1.1em;
}
.date {
color: var(--c-text-3);
}
.comment-content {
background-color: var(--c-bg-1);
border-radius: 4px;
color: var(--c-text-2);
font-size: 0.9em;
overflow: hidden;
padding: 6px 10px !important;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s;
margin: 0;
&:hover {
color: var(--c-text-1);
}
img.tk-owo-emotion {
height: 16px;
margin: 0 5px;
vertical-align: text-bottom;
width: 16px;
}
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
修改TOC
<script setup lang="ts">
import type { TocLink } from '@nuxt/content'
const [DefineTemplate, ReuseTemplate] = createReusableTemplate({
props: {
tocTree: { type: Array as PropType<TocLink[]> },
},
})
const { toc } = useArticle()
const scrollableEl = useTemplateRef('toc')
const { tocOffsets, activeHeadingId } = useToc(toc, computed(() => scrollableEl.value?.body))
function hasHeading(tocTree: TocLink, heading?: string): boolean {
return tocTree.id === heading || !!tocTree.children?.some(child => hasHeading(child, heading))
}
</script>
<template>
<BlogWidget
ref="toc"
title="文章目录"
shrink
:style="{ minHeight: `clamp(4rem, ${tocOffsets.length}rem, 20rem)` }"
>
<template #action>
<div class="toc-actions">
<!-- use <a> for anchor -->
<a href="#main-content" aria-label="返回开头">
<Icon name="tabler:arrow-up-circle" />
</a>
<a href="#twikoo" aria-label="评论区">
<Icon name="tabler:message-circle" />
</a>
</div>
</template>
<!-- 放在顶层会导致 Transition 失效 -->
<DefineTemplate v-slot="{ tocTree }">
<ol>
<li
v-for="(entry, index) in tocTree"
:key="index"
:class="{
'has-active': hasHeading(entry, activeHeadingId),
'active': entry.id === activeHeadingId,
}"
>
<!-- 使用 <a> 确保键盘焦点切换 -->
<a :href="`#${entry?.id}`" :title="entry.text">{{ entry.text }}</a>
<ReuseTemplate v-if="entry.children" :toc-tree="entry.children" />
</li>
</ol>
</DefineTemplate>
<ReuseTemplate
v-if="toc?.links.length"
class="toc"
:toc-tree="toc.links"
/>
<p v-else class="no-toc">
暂无目录信息
</p>
</BlogWidget>
</template>
<style scoped>
.toc {
position: relative;
&::before {
content: "";
position: absolute;
inset: 0.3rem;
width: 3px;
border-radius: 1rem;
background-color: var(--c-bg-soft);
}
}
ol {
padding-inline-start: 0.8rem;
}
li {
opacity: 0.6;
font-size: 0.94em;
color: var(--c-text);
transition: opacity 0.2s;
&:hover {
opacity: 0.94;
}
&.has-active, &.active {
opacity: 1;
font-size: 1em;
}
&.active::before {
content: "";
position: absolute;
inset-inline-start: 0.3rem;
margin: 0.2rem 0;
padding: 0.6rem 1.5px;
border-radius: 1rem;
background-color: var(--c-primary);
}
> a {
display: block;
overflow: hidden;
padding: 0.2em 0.5em;
border-radius: 0.5em;
white-space: nowrap;
text-overflow: ellipsis;
transition: all 0.2s;
&:hover {
background-color: var(--c-bg-soft);
}
}
}
.no-toc {
padding: 1em;
text-align: center;
color: var(--c-text-3);
}
.toc-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.4rem;
margin-left: auto;
}
.toc-actions a {
position: relative;
border: 0;
background: transparent;
color: var(--c-text);
width: 2.2rem;
height: 2.2rem;
padding: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
box-shadow: inset 0 0 0 1px var(--c-border);
transition: all 0.2s;
}
.toc-actions a:hover {
color: var(--c-primary);
box-shadow: inset 0 0 0 1px var(--c-primary);
}
.toc-actions a svg {
position: relative;
z-index: 1;
}
</style>
文章footer
添加分享功能
修改/app/components/post/PostFooter.vue
<script setup lang="ts">
import type { ArticleProps } from '~/types/article'
defineOptions({ inheritAttrs: false })
const props = defineProps<ArticleProps>()
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
title: string
}>({ inheritAttrs: false })
const appConfig = useAppConfig()
const title = `${props.title} | ${appConfig.title}`
const href = new URL(props.path!, appConfig.url).href
const { copy, copied } = useCopy(href)
</script>
<template>
<div class="post-footer">
<DefineTemplate v-slot="{ $slots, title }">
<section>
<div class="title text-creative">
{{ title }}
</div>
<div class="content">
<component :is="$slots.default" />
</div>
</section>
</DefineTemplate>
<ReuseTemplate v-if="references" title="参考链接">
<ul>
<li v-for="{ title, link }, i in references" :key="i">
<ProseA :href="link || ''">
{{ title ?? link }}
</ProseA>
</li>
</ul>
</ReuseTemplate>
<ReuseTemplate :title="meta?.slots?.copyright?.props?.title as string || '许可协议'">
<ContentRenderer v-if="meta?.slots?.copyright" :value="meta?.slots?.copyright" />
<p v-else>
本文采用 <ProseA :href="appConfig.copyright.url">
{{ appConfig.copyright.name }}
</ProseA>
许可协议,转载请注明出处。
</p>
</ReuseTemplate>
<section class="share">
<div class="title text-creative">
分享文章
</div>
<div class="content">
<ZButton
class="share-button"
icon="ri:qq-line"
v-tip="'QQ'"
:to="`https://connect.qq.com/widget/shareqq/index.html?title=${encodeURIComponent(title)}&url=${encodeURIComponent(href)}`"
/>
<ZButton
class="share-button"
icon="ri:weibo-fill"
v-tip="'微博'"
:to="`https://service.weibo.com/share/share.php?title=${encodeURIComponent(title)}&url=${encodeURIComponent(href)}`"
/>
<ZButton
class="share-button"
icon="tabler:mail"
v-tip="'邮件'"
:to="`mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(href)}`"
/>
<ZButton
class="share-button"
icon="tabler:link"
v-tip="{
content: copied ? '已复制链接' : '复制链接',
hideOnClick: false
}"
@click="copy()"
/>
</div>
</section>
</div>
</template>
<style scoped>
.post-footer {
margin: 2rem 0.5rem;
border: 1px solid var(--c-border);
border-radius: 1rem;
background-color: var(--c-bg-2);
}
section {
padding: 1rem;
& + section {
border-top: 1px solid var(--c-border);
}
}
.title {
font-weight: bold;
color: var(--c-text);
}
.content {
margin-top: 0.5em;
font-size: 0.9rem;
li {
margin: 0.5em 0;
}
}
.share-button {
display: inline-flex;
aspect-ratio: 1;
border-radius: 50%;
border: 1px solid var(--c-border);
box-shadow: none;
}
</style>
添加打赏功能
在/app/components/post/新建Donation.vue
<script setup lang="ts">
const { donation } = useAppConfig()
</script>
<template>
<div class="donation" v-if="donation?.enable">
<Tooltip :delay="200" interactive hide-on-click="toggle" max-width="">
<ZButton class="donate-button" icon="tabler:heart-filled" text="赞赏作者" />
<template #content>
<div class="donation-content">
<div class="donation-list" v-if="Object.keys(donation.items).length">
<figure class="donation-item" v-for="(image, label) in donation.items">
<UtilImg class="image" width="160" height="160" :src="image" />
<figcaption class="label">{{ label }}</figcaption>
</figure>
</div>
<p class="donation-message" v-if="donation.message">{{ donation.message }}</p>
</div>
</template>
</Tooltip>
</div>
</template>
<style scoped>
.donation {
display: flex;
justify-content: center;
.donate-button {
padding: .6rem .8rem;
border: 1px solid var(--c-border);
box-shadow: none;
}
.donation-content {
text-align: center;
padding: .5rem .6rem;
.donation-list {
display: flex;
padding: .5rem 0;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.donation-item {
display: flex;
flex-direction: column;
align-items: center;
gap: .5rem;
.image {
border-radius: .5rem;
}
.label {
color: var(--c-text-2);
}
}
.donation-message {
color: var(--c-text-1);
}
}
:deep([data-tippy-root]) {
max-width: calc(100% - 1rem);
.tippy-box {
border: 1px solid var(--c-border);
background-color: var(--c-bg-2);
}
.tippy-svg-arrow {
fill: var(--c-bg-2);
}
}
}
</style>
修改/app/pages/[...slug].vue
<PostFooter v-bind="post" /> <PostDonation /> //添加项 <PostSurround /> <PostComment />
修改blog.config.ts
/** 禁止搜索引擎收录的路径 */
robotsNotIndex: ['/preview', '/previews/*'],
},
/** 赞赏配置 */
donation: {
enable: true,
message: '感谢您的支持,这将激励我创作更多优质内容!',
items: {
"微信支付": 'https://example.com/wechat-pay.png',
"支付宝": 'https://example.com/alipay.png',
"xxx": 'https://example.com/xxx.png'
}
},
/** 博客 Atom 订阅源 */
指定页面关闭评论、赞赏、分享、功能
按照如下代码修改/app/pages/[...slug].vue
<PostFooter v-if="post.postfooter" v-bind="post" /> <PostDonation v-if="post.donation" /> <PostSurround /> <PostComment v-if="post.comment" />
在content.config.ts中添加如下代码
const articleSchema = z.object({
draft: z.boolean().default(false),
permalink: z.string().optional(),
postfooter: z.boolean().default(true),
donation: z.boolean().default(true),
comment: z.boolean().default(true),
使用示例
--- title: '文章标题' date: '2025-01-01' postfooter: false#关闭分享按钮(连带着版权一起隐藏) donation: false#关闭赞赏 comment: false#关闭评论 ---
Twikoo评论样式美化
修改/app/components/post/Comment.vue。
<script setup lang="tsx">
import type { TippyComponent } from 'vue-tippy'
const appConfig = useAppConfig()
const commentEl = useTemplateRef('comment')
const popoverEl = useTemplateRef<TippyComponent>('popover')
const popoverJumpTo = ref('')
const popoverInputEl = useTemplateRef('popover-input')
const showUndo = ref(false)
const popoverBind = ref<TippyComponent['$props']>({})
/** 评论区链接守卫 */
useEventListener(commentEl, 'click', (e) => {
if (!(e.target instanceof Element))
return
if (e.target.matches('.tk-avatar-img'))
e.stopPropagation()
const popoverTarget = e.target.closest('a[target="_blank"]')
if (!(popoverTarget instanceof HTMLAnchorElement))
return
e.preventDefault()
popoverEl.value?.hide()
popoverJumpTo.value = safelyDecodeUriComponent(popoverTarget.href)
popoverBind.value = {
getReferenceClientRect: () => popoverTarget.getBoundingClientRect(),
triggerTarget: popoverTarget,
}
nextTick(checkUndoable)
popoverEl.value?.show()
}, { capture: true })
function checkUndoable() {
showUndo.value = popoverInputEl.value?.textContent !== popoverJumpTo.value
}
function undo() {
if (!popoverInputEl.value)
return
popoverInputEl.value.textContent = popoverJumpTo.value
checkUndoable()
}
function confirmOpen() {
window.open(popoverInputEl.value?.textContent, '_blank')
}
onMounted(() => {
window.twikoo?.init?.({
envId: appConfig.twikoo?.envId,
// twikoo 会把挂载后的元素变为 #twikoo
el: '#twikoo',
})
})
</script>
<template>
<section ref="comment" class="z-comment">
<h3 class="text-creative">
评论区
</h3>
<!-- interactive 默认会把气泡移动到 triggerTarget 的父元素上 -->
<Tooltip
ref="popover"
v-bind="popoverBind"
:append-to="() => commentEl!"
interactive
:aria="{ expanded: false }"
trigger="focusin"
>
<template #content>
<div class="popover-confirm">
<span
ref="popover-input"
class="input"
contenteditable="plaintext-only"
spellcheck="false"
@input="checkUndoable"
@keydown.enter.prevent="confirmOpen"
v-text="popoverJumpTo"
/>
<button
v-if="showUndo"
aria-label="恢复原始内容"
@click="undo()"
>
<Icon name="tabler:arrow-back-up" />
</button>
<ZButton
primary
text="访问"
@click="confirmOpen"
/>
</div>
</template>
</Tooltip>
<div id="twikoo">
<div class="comment-loading">
<div class="loading-spinner"></div>
<p>评论加载中...</p>
</div>
</div>
</section>
</template>
<style scoped>
.z-comment {
margin: 3rem 1rem;
> h3 {
margin-top: 3rem;
font-size: 1.25rem;
}
}
:deep() > [data-tippy-root] > .tippy-box {
padding: 0;
}
.popover-confirm {
display: flex;
align-items: center;
overflow-wrap: anywhere;
> .input {
min-width: 0;
padding: 0.3em 0.6em;
outline: none;
}
> button {
flex-shrink: 0;
align-self: stretch;
padding: 0.3em;
border-radius: 0 0.5em 0.5em 0;
}
}
:deep(#twikoo) {
margin: 2em 0;
.tk-admin-container {
position: fixed;
z-index: calc(var(--z-index-popover) + 1);
}
.tk-submit {
display: flex;
flex-direction: column;
.tk-avatar,
.tk-submit-action-icon.__markdown {
display: none;
}
.tk-preview-container {
margin: 0 0 .5rem 0;
}
.tk-row.actions {
justify-content: flex-end;
margin: 0 0 .5rem;
order: 3;
}
.tk-textarea {
order: 1;
margin-bottom: .5rem;
font-family: var(--font-monospace);
.tk-input__inner {
background-color: var(--c-bg-2);
border: 2px solid var(--c-border);
border-radius: 12px;
padding: .8rem;
transition: all .2s;
&:focus {
background-color: var(--c-bg);
background-position-y: 350px;
border-color: var(--c-primary);
}
}
}
.tk-meta-input {
order: 2;
position: relative;
.tk-input {
background: var(--c-bg-2);
border: 2px solid var(--c-border);
border-radius: 10px;
transition: all .2s;
&:focus-within {
background: var(--c-bg);
border-color: var(--c-primary);
&::before, &::after {
animation: fadeInTip .3s ease;
display: block;
}
}
&::before {
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: 8px;
color: var(--c-text-1);
display: none;
font-size: .9rem;
left: 50%;
padding: .8rem 1rem;
position: absolute;
top: -60px;
transform: translate(-50%);
white-space: nowrap;
z-index: 100;
}
&::after {
border: 8px solid transparent;
border-top: 8px solid var(--c-bg);
content: "";
display: none;
left: 50%;
position: absolute;
top: -12px;
transform: translate(-50%);
}
}
.tk-input:first-child::before { content: "输入QQ号会自动获取昵称和头像🐧"; }
.tk-input:nth-child(2)::before { content: "收到回复将会发送到您的邮箱📧"; }
.tk-input:nth-child(3)::before { content: "可以通过昵称访问您的网站🔗"; }
.tk-input__inner {
border: none !important;
}
.tk-input-group__prepend {
background: var(--c-bg-1);
border: none;
border-radius: 8px 0 0 8px;
color: var(--c-text-2);
transition: all .2s;
}
}
}
.OwO .OwO-body {
animation: fadeInPanel .3s ease .1s 1 normal both;
background: var(--c-bg);
border-radius: 8px;
transform: translateZ(0);
}
.tk-avatar {
border-radius: 50%;
overflow: hidden;
@supports (corner-shape: squircle) {
corner-shape: superellipse(1.2);
}
}
.tk-avatar.tk-clickable {
cursor: auto;
}
.tk-time {
color: var(--c-text-3);
}
/* 防止 a 被 overflow hidden */
.tk-content {
margin: -0.2em;
padding: 0.2em;
font-size: .95rem;
line-height: 1.6;
.tk-owo-emotion {
width: auto;
height: 1.4em;
vertical-align: text-bottom;
}
p > code, > code {
background: var(--c-bg-2);
border: 1px solid var(--c-border);
border-radius: 6px;
padding: .2em .4em;
}
.code-toolbar, > span > pre {
background: var(--c-bg-2);
border: 2px solid var(--c-border);
border-radius: 8px;
overflow: auto;
padding: .4rem;
position: relative;
&::before {
display: none;
}
pre {
margin-top: .75rem;
code {
display: block;
padding-top: .75rem;
}
}
}
}
.tk-comments-title, .tk-nick {
font-family: var(--font-creative);
margin-bottom: 0;
}
.tk-nick-link {
color: var(--c-primary);
}
.tk-extras, .tk-footer {
font-size: 0.7em;
color: var(--c-text-3);
}
/* 屏蔽点赞和踩赞按钮,保留回复按钮*/
.tk-action .tk-action-link:not(:last-child) {
display: none !important;
}
.tk-comment .tk-main {
.tk-meta {
margin-bottom: .3rem;
}
.tk-extras {
color: var(--c-text-2);
font-size: .85rem;
margin-top: .5rem;
}
}
.tk-replies:not(.tk-replies-expand) {
mask-image: linear-gradient(to top, transparent, #FFF 4em);
}
.tk-expand {
border-radius: 0.5em;
background-color: var(--c-bg-2);
color: var(--c-text-1);
padding: 0.375rem 1rem;
transition: background-color 0.1s;
&:hover {
background-color: var(--c-bg-3);
}
}
.tk-button:not(.tk-button--primary) {
border-radius: 8px;
background-color: var(--c-bg-2);
color: var(--c-text-1);
border: 1px solid var(--c-border);
&:hover {
background-color: var(--c-bg-3);
border-color: var(--c-primary);
}
}
.tk-button--primary {
border-radius: 8px;
background-color: var(--c-primary);
color: white;
border: 1px solid var(--c-primary);
&:hover {
background-color: var(--c-primary-soft);
border-color: var(--c-primary-soft);
}
}
.tippy-svg-arrow > svg {
fill: inherit;
width: auto;
height: auto;
}
}
:deep(:where(.tk-preview-container,.tk-content)) {
pre {
overflow: auto;
border-radius: 0.5em;
font-size: 0.85em;
}
a {
margin: -0.1em -0.2em;
padding: 0.1em 0.2em;
background: linear-gradient(var(--c-primary-soft), var(--c-primary-soft)) no-repeat center bottom / 100% 0.1em;
color: var(--c-primary);
transition: all 0.2s;
&:hover {
border-radius: 0.3em;
background-size: 100% 100%;
}
}
p {
margin: 0.2em 0;
}
img {
border-radius: 0.5em;
}
menu, ol, ul {
margin: 0.5em 0;
padding-inline-start: 1.5em;
font-size: 0.9rem;
list-style: revert;
> li {
margin: 0.2em 0;
&::marker {
color: var(--c-primary);
}
}
}
blockquote {
margin: 0.5rem 0 0.8rem;
padding: 0.2em 0.8rem;
border-inline-start: 4px solid var(--c-border);
border-radius: 8px;
background-color: var(--c-bg-2);
font-size: 0.9em;
}
}
.comment-loading {
color: var(--c-text-2);
padding: 2rem;
text-align: center;
.loading-spinner {
animation: spin 1s linear infinite;
border: 3px solid var(--c-bg-3);
border-top-color: var(--c-primary);
border-radius: 50%;
height: 40px;
margin: 0 auto 1rem;
width: 40px;
}
p {
font-size: .9rem;
}
}
@keyframes spin {
0% { transform: rotate(0); }
to { transform: rotate(1turn); }
}
@keyframes fadeInTip {
from { opacity: 0; transform: translate(-50%, 10px); }
to { opacity: 1; transform: translate(-50%); }
}
@keyframes fadeInPanel {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>




评论加载中...