惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - Franky
N
Netflix TechBlog - Medium
Google Online Security Blog
Google Online Security Blog
月光博客
月光博客
量子位
酷 壳 – CoolShell
酷 壳 – CoolShell
V
V2EX
腾讯CDC
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
M
MIT News - Artificial intelligence
Vercel News
Vercel News
The GitHub Blog
The GitHub Blog
Hugging Face - Blog
Hugging Face - Blog
博客园 - 【当耐特】
Apple Machine Learning Research
Apple Machine Learning Research
aimingoo的专栏
aimingoo的专栏
博客园 - 三生石上(FineUI控件)
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
MongoDB | Blog
MongoDB | Blog
H
Help Net Security
The Cloudflare Blog
Blog — PlanetScale
Blog — PlanetScale
F
Full Disclosure
G
Google Developers Blog
罗磊的独立博客
Jina AI
Jina AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Y
Y Combinator Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
J
Java Code Geeks
A
About on SuperTechFans
IT之家
IT之家
大猫的无限游戏
大猫的无限游戏
S
SegmentFault 最新的问题
有赞技术团队
有赞技术团队
GbyAI
GbyAI
雷峰网
雷峰网
T
The Blog of Author Tim Ferriss
The Register - Security
The Register - Security
U
Unit 42
D
Docker
Martin Fowler
Martin Fowler
L
LINUX DO - 热门话题
NISL@THU
NISL@THU
阮一峰的网络日志
阮一峰的网络日志
C
Cybersecurity and Infrastructure Security Agency CISA
博客园_首页
Google DeepMind News
Google DeepMind News

栖童の小站

中兴微ZX296716机顶盒TTL救砖全攻略 | 栖童の小站 闲鱼副业之行:在机顶盒救砖中,窥见人性的温差 | 栖童の小站 CMCC RAX3000QY路由器TTL刷机与OpenWrt解锁全记录 | 栖童の小站 晨星9385芯片设备免拆包自修改教程 | 栖童の小站 小众云服务商深度测评:小兔互联、初七云、星辰云对比 | 栖童の小站 我的2025:在破除幻象、划定边界与坚守内心的一年 | 栖童の小站 闲鱼求职骗局实录:我是如何识破假冒京东HR | 栖童の小站 “大仙”是如何操控你的:亲历东北出马仙骗局与背后的恐惧营销心理学 | 栖童の小站 一次网站性能翻车实录:滥用SWPP插件导致的用户体验灾难与修复 | 栖童の小站 未成年网络暴力观察:从劝诫到被“人肉”的反思 | 栖童の小站 卸任版主后的身份枷锁:虚拟社交中的友谊与边界 | 栖童の小站 从Hexo到Nuxt:我的小站重构与品牌升级之路 | 栖童の小站 在爱恨之间:我的人际关系修复与挣扎 | 栖童の小站 信仰的见证:当基督徒的行为违背圣经 | 栖童の小站 版主生涯的回忆:在deepin论坛的日子 | 栖童の小站 从耕种到秋收 | 栖童の小站 当田园牧歌遭遇田埂上的贪婪 | 栖童の小站 芜湖散记:江畔的温柔与遗憾 | 栖童の小站 零成本自建网站统计:在Vercel上部署Umami完全指南 | 栖童の小站 童年的两面:简单的快乐与沉重的烙印 | 栖童の小站 家庭阴影与校园霸凌的自愈 | 栖童の小站 公共澡堂体验:记录一次北方乡下的专业搓澡 | 栖童の小站 如何打造高效的团队 | 栖童の小站 Linux系统Git使用指南:从本地仓库创建到远程仓库推送 | 栖童の小站 Hexo Butterfly主题进阶美化:添加FPS显示、节日弹窗与评论提示 | 栖童の小站 告别手动编译:利用GitHub Actions自动化部署你的Hexo博客 | 栖童の小站 Linux音频修复:解决前置耳机及麦克风插孔无声方案 | 栖童の小站 从零搭建Hexo静态博客:环境配置、主题安装到部署上线完全指南 | 栖童の小站 解决Debian包格式兼容:从zst到xz的手动转换与重打包教程 | 栖童の小站 Debian系统编译Linux内核deb包:从编译到打包安装全流程 | 栖童の小站 老爷机复活指南:Linux Mint Xfce 轻量系统安装与优化全流程 | 栖童の小站
Clarity主题深度定制指南 | 栖童の小站
栖童, sweetcandymini@foxmail.com · 2025-11-02 · via 栖童の小站

开篇

本教程还没有完善好,正在持续撰写中。

注意:下面教程中复用PageBanner组件的地方比较多,如果您对PageBanner需求不大,可直接修改文件把PageBanner的样式写入pages页面文件中,您不需要PageBanner可以手动移除。

/app/components/partial/下新建文件PageBanner.vue

vue
<script setup lang="ts">
defineProps<{
  image: String
  title: String
  description?: String
}>()
</script>

<template>
<div class="page-banner" :style="{ backgroundImage: `url(${image})` }">
  <div class="banner-content">
    <h1>{{ title }}</h1>
    <p v-if="description">{{ description }}</p>
  </div>
  <div class="banner-extra">
    <slot></slot>
  </div>
</div>
</template>

<style lang="scss" scoped>
.page-banner {
  background-position: 50%;
  background-size: cover;
  border-radius: 8px;
  margin: 1rem;
  max-height: 320px;
  min-height: 256px;
  overflow: hidden;
  position: relative;

  .banner-content {
    color: #eee;
    display: flex;
    flex-direction: column;
    top: 0;
    bottom: 0;
    left: 0;
    justify-content: space-between;
    padding: 1rem;
    position: absolute;
    text-shadow: 0 4px 5px rgba(#000, .5);

    p {
      opacity: .9;
    }
  }

  .banner-extra {
    align-items: flex-end;
    display: flex;
    bottom: 0;
    right: 0;
    justify-content: flex-end;
    margin: 1rem;
    position: absolute;
  }
}
</style>

页面相关

添加本地essay页面

/app/pages/新建essay.vue

vue
<script setup lang="ts">
import talks from '~/talks'

const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-tech', 'blog-log', 'comm-group'])

const title = '说说'
const description = '记录生活点滴,一些想法。'
const image = 'https://图片链接'
useSeoMeta({ title, description, ogImage: image })

const { author } = useAppConfig()

const recentTalks = [...talks]
  .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
  .slice(0, 30)

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>
<ZPageBanner :title :description :image />

<div class="talk-list">
  <div class="talk-item" v-for="talk in recentTalks" :key="talk.date">
    <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>
      <button class="comment-btn" v-tip="'评论'" @click="replyTalk(talk.text)">
        <Icon name="tabler:brand-hipchat" />
      </button>
    </div>
  </div>

  <div class="talk-footer">
    <p>仅显示最近 30 条记录</p>
  </div>
</div>

<PostComment />
</template>

<style lang="scss" scoped>
.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);
      }
    }
  }

  .talk-footer {
    color: var(--c-text-3);
    font-size: 1rem;
    margin: 2rem 0;
    text-align: center;
  }
}
</style>

/app/types/新建talk.ts

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
}

/app/新建talks.ts

这个文件是更新说说内容的地方

ts
import type { TalkItem } from '~/types/talk'

export default [
  {
    text: '这是一个包含<b>原始视频</b>的动态内容示例。<br>现在支持使用&lt;br&gt;进行换行和使用&lt;b&gt;标签实现加粗。',
    date: '2025-09-24 00:00',
    video: {
      id: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
      poster: 'https://lf-package-cn.feishucdn.com/obj/atsx-throne/hire-fe-prod/portal/i18n/static/image/video-poster.d9fdf4be.jpeg'
    },
    tags: ['游戏'],
    location: '天津'
  },
  {
    text: '这是一个包含B站视频的示例。',
    date: '2025-09-23 23:00',
    video: {
      type: 'bilibili',
      id: 'BV1Yr421p7rW'
    },
    tags: ['网站'],
    location: '天津'
  },
  {
    text: '这是一个同时包含<b>视频</b>和<b>图片</b>的示例。<br>支持多种媒体格式的展示。',
    date: '1885-07-22 20:00',
    images: [
      'https://图片链接',
      'https://图片链接',
      'https://图片链接'
    ],
    video: {
      type: 'bilibili',
      id: 'BV1xx411c7mD'
    },
    tags: ['旅行'],
    location: '成都'
  }
] satisfies TalkItem[]

添加tags页面

/app/pages/新建tags.vue

vue
<script setup lang="ts">
import { orderBy } from 'es-toolkit/array'

const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-log'])

const appConfig = useAppConfig()
const title = '标签'
const description = `${appConfig.title}的所有文章标签。`
useSeoMeta({ title, description })

const { data: listRaw } = await useAsyncData('posts:index', () => useArticleIndexOptions(), { default: () => [] })

// 选中的标签
const selectedTag = ref<string>('')

// 计算每个标签对应的文章
const articlesByTag = computed(() => {
	const result: Record<string, any[]> = {}
	const articles = orderBy(listRaw.value, ['date'], ['desc'])
	for (const article of articles) {
		if (article.tags) {
			for (const tag of article.tags) {
				if (!result[tag]) {
					result[tag] = []
				}
				result[tag].push(article)
			}
		}
	}
	return result
})

// 排序后的标签列表(按文章数量降序)
const sortedTags = computed(() => {
	return Object.keys(articlesByTag.value).sort((a, b) => {
		const aCount = articlesByTag.value[a]?.length || 0
		const bCount = articlesByTag.value[b]?.length || 0
		return bCount - aCount
	})
})

// 根据文章数量计算标签大小的函数
function getTagSize(count: number): string {
	const maxCount = Math.max(...Object.values(articlesByTag.value).map(articles => articles.length))
	const minCount = Math.min(...Object.values(articlesByTag.value).map(articles => articles.length))
	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>
<div class="tags">
	<!-- 选中标签时显示 -->
	<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:circle-x" />
			</button>
		</div>
		<div class="tag-selected-info">
			共 {{ articlesByTag[selectedTag]?.length }} 篇文章
		</div>
		
		<menu class="archive-list">
			<TransitionGroup appear name="float-in">
				<PostArchive
					v-for="article, index in articlesByTag[selectedTag]"
					:key="article.path"
					v-bind="article"
					:to="article.path"
					:style="{ '--delay': `${index * 0.03}s` }"
				/>
			</TransitionGroup>
		</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 lang="scss" 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

ts
#!/usr/bin/env ts-node

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const avatarHandlers = [
  { re: /getGithubAvatar\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*\{[^}]+\})?\s*\)/g, fn: (n: string) => `"https://wsrv.nl/?url=github.com/${n}.png?size=92"` },
  { re: /getGithubIcon\s*\(\s*['"]([^'"]+)['"]\s*\)/g, fn: (n: string) => `"https://wsrv.nl/?url=github.com/${n}.png?size=32&mask=circle"` },
  { re: /getOicqAvatar\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*[^)]+)?\s*\)/g, fn: (q: string) => `"https://q1.qlogo.cn/g?b=qq&nk=${q}&s=140"` },
  { re: /getFavicon\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*\{[^}]+\})?\s*\)/g, fn: (d: string) => `"https://unavatar.webp.se/google/${d}?w=32"` }
];

export function generateFcircleJson() {
  const blacklist = ["xxx", "xxx", "xxx", "xxx"];
  const feedsPath = path.resolve(__dirname, '../app/feeds.ts');
  const outputPath = path.resolve(__dirname, '../public/friend.json');
  
  try {
    let arrayContent = fs.readFileSync(feedsPath, 'utf-8');
    const start = arrayContent.indexOf('export default [');
    const end = arrayContent.lastIndexOf(']');
    arrayContent = arrayContent.substring(start + 15, end + 1);
    
    for (const { re, fn } of avatarHandlers) {
      arrayContent = arrayContent.replace(re, (_, m) => fn(m));
    }
    arrayContent = arrayContent.replace(/OicqAvatarSize\.Size\d+/g, '140').replace(/\s+satisfies\s+[^\s\n;]+/g, '');
    
    const feedGroups: any[] = eval(`(${arrayContent})`);
    const friends = feedGroups.flatMap(g => 
      g.entries.filter((e: any) => !e.error).map((e: any) => {
        const name = e.title || e.sitenick || e.author;
        return blacklist.includes(name) ? null : [name, e.link, e.avatar];
      }).filter(Boolean)
    );
    
    const publicDir = path.resolve(__dirname, '../public');
    if (!fs.existsSync(publicDir)) fs.mkdirSync(publicDir, { recursive: true });
    
    const friendData = { friends };
    fs.writeFileSync(outputPath, JSON.stringify(friendData, null, 2), 'utf-8');
    console.log(`成功生成 ${friends.length} 个友链: ${outputPath}`);
    return friendData;
  } catch (error) {
    console.error('错误:', error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}

if (import.meta.url === new URL(process.argv[1], import.meta.url).href) {
  generateFcircleJson();
}

执行

bash
pnpm tsx scripts/generate-friend.ts

会在public目录生成friend.json

/app/pages/新建fcircle.vue

vue
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'

const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-tech', 'blog-log', 'comm-group'])

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>
  <ZPageBanner :title :description :image>
    <div class="fcircle-stats">
      <div class="fcircle-stats__update-time">Updated at {{ lastUpdatedDate || '2025-07-17' }}</div>
      <div class="fcircle-stats__powered-by">Powered by FriendCircleLite</div>
    </div>
  </ZPageBanner>

  <div class="page-fcircle">
    <div class="fcircle">
      <!-- 随机文章区域 -->
      <div v-if="randomArticle" class="fcircle__random-article">
        <div class="fcircle__random-title">随机文章</div>
        <div class="article-item">
          <a 
            :href="randomArticle.link"
            target="_blank"
            rel="noopener noreferrer"
            class="article-item__container gradient-card"
          >
            <span class="article-item__author">{{ randomArticle.author }}</span>
            <span class="article-item__title">{{ randomArticle.title }}</span>
            <span class="article-item__date">{{ formatDate(randomArticle.created) }}</span>
          </a>
        </div>
        <ZButton 
          class="btn-refresh gradient-card" 
          @click="refreshRandomArticle"
          icon="uim:process"
        />
      </div>

      <!-- 文章列表区域 -->
      <div class="fcircle__articles">
        <div
          v-for="(article, index) in displayedArticles"
          :key="article.id"
          class="article-item article-item--new"
          :style="{ '--delay': `${(index % UserConfig.page_size) * 0.05}s` }"
        >
          <div class="article-item__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-item__container gradient-card"
          >
            <span class="article-item__author">{{ article.author }}</span>
            <span class="article-item__title">{{ article.title }}</span>
            <span class="article-item__date">{{ formatDate(article.created) }}</span>
          </a>
        </div>
      </div>

      <!-- 加载更多按钮 -->
      <ZButton 
        v-show="hasMoreArticles" 
        class="btn-load-more gradient-card"
        @click="loadMore"
        text="加载更多"
      />

      <!-- 空状态 -->
      <div v-if="!isLoading && allArticles.length === 0" class="error-container">
        <Icon class="error-container__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"
                class="modal__avatar-img"
              />
              <h3>{{ selectedAuthor }}</h3>
              <a 
                :href="selectedArticleLink" 
                target="_blank"
                rel="noopener noreferrer"
                class="modal__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="timeline__date">{{ formatDate(article.created) }}</span>
                  <a 
                    :href="article.link"
                    target="_blank"
                    rel="noopener noreferrer"
                    class="timeline__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 lang="scss" scoped>
/* 动画定义 */
@keyframes pulse-fade {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

@keyframes slide-in-up {
  0% { opacity: 0; transform: translateY(20px); }
  100% { opacity: 1; transform: translateY(0); }
}

/* 主要样式 */
.page-fcircle {
  animation: float-in .2s backwards;
  margin: 1rem;
}

.fcircle-stats {
  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);
  
  .fcircle-stats__update-time { opacity: 1; }
  .fcircle-stats__powered-by { opacity: .8; }
}

.fcircle {
  .fcircle__random-article {
    align-items: center;
    display: flex;
    flex-direction: row;
    gap: 10px;
    justify-content: space-between;
    margin: 1rem 0;
    
    .fcircle__random-title {
      font-size: 1.2rem;
      white-space: nowrap;
    }
    
    .article-item {
      flex: 1;
      min-width: 0;
      
      .article-item__container {
        min-width: 0;
        
        .article-item__title {
          overflow: hidden;
          text-overflow: ellipsis;
          white-space: nowrap;
        }
      }
    }
  }
  
  .fcircle__articles {
    display: flex;
    flex-direction: column;
    gap: .5rem;
  }
}

/* 文章项样式 */
.article-item {
  align-items: center;
  display: flex;
  gap: 10px;
  width: 100%;
  
  &.article-item--new { animation: float-in .2s var(--delay) backwards; }
  
  .article-item__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-item__container {
    align-items: center;
    border-radius: 8px;
    box-shadow: 0 0 0 1px var(--c-bg-soft);
    display: flex;
    gap: 5px;
    height: 2.5rem;
    overflow: hidden;
    padding: 10px;
    width: 100%;
    
    &:hover .article-item__title { color: var(--c-text); }
    
    .article-item__author {
      color: var(--c-text-3);
      font-size: .85rem;
      flex-shrink: 0;
      display: flex;
      align-items: center;
    }
    
    .article-item__title {
      color: var(--c-text-2);
      flex: 1;
      font-size: .9375rem;
      overflow: hidden;
      text-overflow: ellipsis;
      transition: color .2s;
      white-space: nowrap;
      display: flex;
      align-items: center;
    }
    
    .article-item__date {
      color: var(--c-text-3);
      font-family: var(--font-monospace);
      font-size: .75rem;
      flex-shrink: 0;
      display: flex;
      align-items: center;
    }
  }
}

/* 按钮样式 */
.btn-refresh {
  align-items: center;
  background-color: unset;
  border-radius: 8px;
  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;
  box-shadow: none; 
  
  &:hover { 
    background-color: unset;
  }
}

.btn-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;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  justify-content: center;
  position: fixed;
  z-index: 100;
  
  .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; }
      
      .modal__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: float-in .3s var(--delay) backwards;
          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);
          }
          
          .timeline__date {
            color: var(--c-text-3);
            display: block;
            font-family: var(--font-monospace);
            font-size: .875rem;
            margin-bottom: .3rem;
          }
          
          .timeline__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%; }
    }
  }
}

/* 模态框过渡 */
.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);
}

/* 错误容器 */
.error-container {
  align-items: center;
  color: var(--c-text-2);
  display: flex;
  flex-direction: column;
  gap: 12px;
  height: 400px;
  justify-content: center;
  
  .error-container__icon { font-size: 4rem; }
}

/* 移动端适配 */
@media (max-width: 768px) {
  .fcircle__random-article .fcircle__random-title { display: none; }
  
  .page-fcircle .article-item .article-item__container {
    flex-wrap: wrap;
    height: auto;
  }
  
  .page-fcircle .article-item .article-item__container .article-item__author {
    flex-grow: 1;
  }
  
  .page-fcircle .article-item .article-item__container .article-item__title {
    flex-basis: 100%;
    order: 3;
    white-space: normal;
  }
}
</style>

添加about页面

/app/pages/新建about.vue

vue
<script setup lang="ts">
const layoutStore = useLayoutStore()
layoutStore.setAside(['blog-stats', 'blog-tech', 'blog-log', 'comm-group'])

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://umami链接/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>
<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">2010</span>
        </div>
        <div class="info-item special-info-item">
          <span class="label">当前</span>
          <span class="value">15岁 <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>爱好1、爱好2</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">INFP-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">
          特长1、特长2
        </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>
</template>

<style lang="scss" 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 lang="scss">
.dark .tippy-box {
  background-color: var(--c-bg-2);

  .tippy-svg-arrow {
    fill: var(--c-bg-2);
  }
}
</style>

添加恋爱记录页面

/app/pages/新建love.vue

vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { Temporal } from 'temporal-polyfill';
import blogConfig from '~~/blog.config';

const appConfig = useAppConfig();

// 设置SEO元数据
useSeoMeta({
  title: '恋爱记录',
  description: `${appConfig.title}的恋爱记录页面`,
});

// 配置数据
const CONFIG = {
  firstMeetDate: '2026-01-15',
  loveStartDate: '2026-01-15 11:34:00',
  myBirthdayDate: '2026-01-15',
  herBirthdayDate: '2026-01-15'
};

// 响应式状态
const timerState = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 });
const loveAnniversary = ref({ years: 0, months: 0, days: 0, description: '' });
const herBirthday = ref({ daysLeft: 0, age: 0, description: '' });
const myBirthday = ref({ daysLeft: 0, age: 0, description: '' });

// 获取当前时区时间
const getNowZoned = () => Temporal.Now.zonedDateTimeISO(blogConfig.timeZone);

// 解析日期为 ZonedDateTime
const parseZonedDate = (dateStr: string) => {
  try {
    return Temporal.ZonedDateTime.from(dateStr);
  } catch {
    try {
      return Temporal.Instant.from(dateStr).toZonedDateTimeISO(blogConfig.timeZone);
    } catch {
      return Temporal.PlainDateTime.from(dateStr).toZonedDateTime(blogConfig.timeZone);
    }
  }
};

// 计算日期之间的差异
const calculateDateDiff = (start: Temporal.ZonedDateTime, end: Temporal.ZonedDateTime) => {
  const duration = start.until(end, { largestUnit: 'year', smallestUnit: 'day' });
  return {
    years: duration.years,
    months: duration.months,
    days: duration.days
  };
};

// 计算生日信息
const calculateBirthdayInfo = (birthDateStr: string) => {
  const now = getNowZoned();
  const birthDate = parseZonedDate(birthDateStr);
  const currentYear = now.year;

  // 计算年龄
  const birthdayThisYear = birthDate.with({ year: currentYear });
  const age = currentYear - birthDate.year - (birthdayThisYear.dayOfYear > now.dayOfYear ? 1 : 0);

  // 计算下一个生日
  let nextBirthday = birthdayThisYear;
  if (nextBirthday.dayOfYear < now.dayOfYear) {
    nextBirthday = birthDate.with({ year: currentYear + 1 });
  }

  const daysLeft = Math.floor(nextBirthday.since(now, { largestUnit: 'day' }).days);

  return {
    daysLeft,
    age,
    description: daysLeft === 0 ? '今天过生日!🎉' : `距离生日还有${daysLeft}天`
  };
};

// 更新时间数据
const updateTime = () => {
  const now = getNowZoned();
  const loveStart = parseZonedDate(CONFIG.loveStartDate);
  const duration = loveStart.until(now, { largestUnit: 'day', smallestUnit: 'second' });

  // 更新计时器
  timerState.value = {
    days: duration.days,
    hours: duration.hours,
    minutes: duration.minutes,
    seconds: duration.seconds
  };

  // 更新纪念日
  const anniversaryDiff = calculateDateDiff(loveStart, now);
  loveAnniversary.value = {
    ...anniversaryDiff,
    description: `${anniversaryDiff.years > 0 ? `${anniversaryDiff.years}年` : ''}${anniversaryDiff.months > 0 ? `${anniversaryDiff.months}个月` : ''}${anniversaryDiff.days}天`
  };

  // 更新生日信息
  herBirthday.value = calculateBirthdayInfo(CONFIG.herBirthdayDate);
  myBirthday.value = calculateBirthdayInfo(CONFIG.myBirthdayDate);
};

// 定时器管理
let timer: number | null = null;

onMounted(() => {
  updateTime();
  timer = window.setInterval(updateTime, 1000);
});

onUnmounted(() => timer && clearInterval(timer));
</script>

<template>
  <div class="love-page">
    <!-- 头像区域 -->
    <div class="avatar-section central">
      <div class="avatar-item">
        <div class="avatar-wrapper">
          <img class="avatar-frame" src="/img/头像挂件.png" />
          <img class="avatar-img" src="http://q2.qlogo.cn/headimg_dl?dst_uin=您的QQ&amp;spec=640" alt="我的头像" />
        </div>
        <span class="avatar-label">名字</span>
      </div>

      <div class="love-heart">
        <Icon name="tabler:heart-filled" size="48" style="color: #ff4757;" />
      </div>

      <div class="avatar-item">
        <div class="avatar-wrapper">
          <img class="avatar-frame" src="/img/头像挂件.png" />
          <img class="avatar-img" src="http://q2.qlogo.cn/headimg_dl?dst_uin=她的QQ&amp;spec=640" alt="她的头像" />
        </div>
        <span class="avatar-label">她的名字</span>
      </div>
    </div>

    <!-- 倒计时显示 -->
    <div class="timer-section">
      <div class="timer-title">我们已经在一起</div>
      <div class="timer-display">
        <span class="timer-number">{{ timerState.days }}</span>
        <span class="timer-label">天</span>
        <span class="timer-number">{{ String(timerState.hours).padStart(2, '0') }}</span>
        <span class="timer-label">时</span>
        <span class="timer-number">{{ String(timerState.minutes).padStart(2, '0') }}</span>
        <span class="timer-label">分</span>
        <span class="timer-number">{{ String(timerState.seconds).padStart(2, '0') }}</span>
        <span class="timer-label">秒</span>
      </div>
    </div>

    <!-- 信息卡片区域 -->
    <div class="card-grid central">
      <!-- 第一次见面卡片 -->
      <div class="info-card">
        <div class="card-header">
          <Icon name="mingcute:love-line" size="24" style="margin-right: 10px; fill: var(--c-text-2);" />
          <span class="card-title">第一次见面</span>
        </div>
        <div class="card-content">
          <b class="card-date">{{ CONFIG.firstMeetDate }}</b>
          <div class="card-desc">初次相遇的那一天,我们的故事就此开始...</div>
        </div>
      </div>

      <!-- 恋爱纪念日卡片 -->
      <div class="info-card">
        <div class="card-header">
          <Icon name="tabler:stopwatch" size="24" style="margin-right: 10px; fill: var(--c-text-2);" />
          <span class="card-title">恋爱纪念日</span>
        </div>
        <div class="card-content">
          <b class="card-date">{{ CONFIG.loveStartDate.split(' ')[0] }}</b>
          <div class="card-desc">已在一起 {{ loveAnniversary.description }}</div>
        </div>
      </div>

      <!-- 我的生日卡片 -->
      <div class="info-card">
        <div class="card-header">
          <Icon name="tabler:gift" size="24" style="margin-right: 10px; fill: var(--c-text-2);" />
          <span class="card-title">我的生日</span>
        </div>
        <div class="card-content">
          <b class="card-date">{{ CONFIG.myBirthdayDate }}</b>
          <div class="card-desc">{{ myBirthday.description }}</div>
          <div class="card-desc">今年 {{ myBirthday.age }} 岁</div>
        </div>
      </div>

      <!-- 她的生日卡片 -->
      <div class="info-card">
        <div class="card-header">
          <Icon name="tabler:gift" size="24" style="margin-right: 10px; fill: var(--c-text-2);" />
          <span class="card-title">她的生日</span>
        </div>
        <div class="card-content">
          <b class="card-date">{{ CONFIG.herBirthdayDate }}</b>
          <div class="card-desc">{{ herBirthday.description }}</div>
          <div class="card-desc">今年 {{ herBirthday.age }} 岁</div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.love-page {
  font-family: inherit;
  min-height: 100vh;
  padding: 2rem 0;
  background-color: var(--c-bg-1);
}

/* 头像区域 */
.avatar-section {
  display: flex;
  justify-content: space-around;
  align-items: center;
  gap: 1rem;
  max-width: 1200px;
  margin: 0 auto 2rem;
  width: calc(100% - 2rem);
}

.avatar-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

.avatar-wrapper {
  position: relative;
  width: 130px;
  height: 130px;
}

.avatar-frame {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  position: relative;
  z-index: 2;
  pointer-events: none;
  display: block;
  transform: scale(1.6);
  top: 2px;
  left: 2px;
}

.avatar-img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  border: none;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
  object-fit: cover;
}

.avatar-label {
  margin-top: 0.8rem;
  font-size: 1.1rem;
  font-weight: 600;
  color: var(--c-text-1);
  background-color: var(--c-bg-soft);
  padding: 0.4rem 1.2rem;
  border-radius: 25px;
  display: inline-block;
}

/* 爱心动画 */
@keyframes heartbeat { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 #ff4757; } 70% { box-shadow: 0 0 0 10px transparent; } 100% { box-shadow: 0 0 0 0 rgba(255, 71, 87, 0); } }

.love-heart {
  width: 80px;
  height: 80px;
  display: flex;
  align-items: center;
  justify-content: center;
  animation: heartbeat 1.5s ease-in-out infinite;
  position: relative;
}

.love-heart::after {
  content: '';
  position: absolute;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  animation: pulse 2s infinite;
}

/* 计时器 */
.timer-section {
  text-align: center;
  margin-bottom: 3rem;
  padding: 0 1rem;
}

.timer-title {
  font-size: 2rem;
  font-weight: 700;
  color: var(--c-text);
  margin-bottom: 1rem;
  letter-spacing: 0.05em;
}

.timer-display {
  text-align: center;
  font-size: 1.25rem;
  line-height: 1.6;
}

.timer-label {
  font-size: 1.5rem;
  color: var(--c-text-2);
  margin-right: 0.5rem;
}

.timer-number {
  font-size: 2.5rem;
  margin-right: 0.5rem;
  font-weight: 800;
  color: var(--c-text-1);
  display: inline-block;
}

/* 卡片网格 */
.card-grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 1.5rem;
  padding: 0 1rem;
  max-width: 1200px;
  margin: 0 auto 2rem;
  align-items: stretch;
}

/* 卡片样式 */
.info-card {
  background-color: var(--c-bg);
  color: var(--c-text-1);
  border-radius: 18px;
  padding: 1.2rem 1.5rem;
  width: calc(50% - 0.75rem);
  min-width: 280px;
  max-width: 550px;
  border: 1px solid var(--c-border);
  position: relative;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.card-header {
  display: flex;
  align-items: center;
}

.card-header svg {
  fill: var(--c-text-2);
  width: 32px;
  height: 32px;
  flex-shrink: 0;
  margin-right: 10px;
}

.card-title {
  font-size: 1.6rem;
  font-weight: 700;
  color: var(--c-text-1);
  margin: 0;
}

.card-content {
  display: flex;
  flex-direction: column;
  font-size: 1.1rem;
  padding-left: 36px;
}

.card-date {
  font-weight: 700;
  color: var(--c-text-1);
  font-size: 1.2rem;
  letter-spacing: 0.05em;
}

.card-desc {
  color: var(--c-text-3);
  font-size: 0.9rem;
  margin-top: 0.3rem;
}

/* 响应式 */
@media (max-width: 767px) {
  .love-page { padding: 1rem 0; }
  
  .avatar-section { width: calc(100% - 2rem); }

  .avatar-wrapper { width: 100px; height: 100px; }
  .avatar-label { font-size: 1rem; padding: 0.3rem 1rem; }
  .love-heart { width: 60px; height: 60px; }
  .card-grid { padding: 0 1rem; gap: 1rem; }
  
  .info-card { padding: 1rem 1.2rem; width: calc(50% - 0.5rem); min-width: auto; }

  .card-header svg { width: 28px; height: 28px; }
  .card-title { font-size: 1.3rem; }
  .card-content { font-size: 1rem; padding-left: 32px; }
  .card-date { font-size: 1.1rem; }
  .timer-title { font-size: 1.5rem; }
  .timer-number { font-size: 2rem; }
  .timer-label { font-size: 1.2rem; }
}

@media (max-width: 380px) {
  .avatar-section { gap: 0.5rem; padding: clamp(5px, 2vw, 8px); }

  .avatar-wrapper { width: clamp(60px, 25vw, 80px); height: clamp(60px, 25vw, 80px); }

  .avatar-label {
    font-size: clamp(0.65rem, 3vw, 0.9rem);
    padding: clamp(1px, 1vw, 0.2rem) clamp(8px, 3vw, 0.8rem);
    margin-top: clamp(0.5rem, 3vw, 20px);
  }

  .love-heart { width: clamp(30px, 12vw, 40px); height: clamp(30px, 12vw, 40px); }

  .card-grid { padding: clamp(4px, 2vw, 6px); gap: clamp(4px, 2vw, 6px); }

  .info-card {
    padding: clamp(6px, 3vw, 10px) clamp(8px, 4vw, 12px);
    min-width: clamp(110px, 45vw, 125px);
    width: calc(50% - clamp(4px, 1vw, 6px));
  }

  .card-header svg { width: clamp(12px, 6vw, 16px); height: clamp(12px, 6vw, 16px); }
  .card-title { font-size: clamp(0.7rem, 4vw, 0.8rem); }
  .card-content { font-size: clamp(0.5rem, 2.5vw, 0.6rem); padding-left: clamp(14px, 7vw, 18px); }
  .timer-title { font-size: clamp(12px, 5vw, 16px); }
  .timer-display { font-size: clamp(12px, 5vw, 16px); }
  .timer-number, .timer-label { font-size: clamp(16px, 8vw, 20px); }
}
</style>

添加番剧页面

侧边栏

如何添加自定义侧边栏

修改/app/composables/useWidgets.ts,以恋爱墙为例。

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是文章首页

vue
// 修改下列参数,'blog-stats'在前面就是上方'love-wall'则是最底下,可自行调整侧边栏顺序
layoutStore.setAside(['blog-stats', 'blog-tech', 'comm-group', 'love-wall'])

恋爱墙

/app/components/widget/新建LoveWall.vue

vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { Temporal } from 'temporal-polyfill'
import blogConfig from '~~/blog.config'

const laqTime = ref<HTMLElement | null>(null)
let intervalId: number | null = null

const timeIntervals = [
  { label: '年', threshold: 60 * 60 * 24 * 365.2422 },
  { label: '天', threshold: 60 * 60 * 24 },
  { label: '时', threshold: 60 * 60 },
  { label: '分', threshold: 60 },
  { label: '秒', threshold: 1 },
]

// 恋爱开始时间字符串:2023年3月23日 01:30 (UTC)
const LOVE_START_TIME_STR = '2023-03-23T01:30:00+00:00'

function setTime() {
  if (!laqTime.value) return

  try {
    const loveStartTime = Temporal.Instant.from(LOVE_START_TIME_STR).toZonedDateTimeISO(blogConfig.timeZone)
    const now = Temporal.Now.zonedDateTimeISO(blogConfig.timeZone)
    const diff = now.since(loveStartTime, { largestUnit: 'second' })
    let secondsRemaining = diff.seconds

    const timeValues: number[] = []
    for (const interval of timeIntervals) {
      const count = Math.floor(secondsRemaining / interval.threshold)
      timeValues.push(count)
      secondsRemaining -= count * interval.threshold
    }

    const currentTimeHtml = `${timeValues[0]} 年 ${timeValues[1]} 天 ${timeValues[2]} 时 ${timeValues[3]} 分 ${timeValues[4]} 秒`

    laqTime.value.innerHTML = currentTimeHtml
  }
  catch (e) {
    console.error('LoveWall time calculation error:', e)
  }
}

onMounted(() => {
  laqTime.value = document.getElementById('laq_time') as HTMLElement
  setTime()
  intervalId = window.setInterval(setTime, 1000)
})

onUnmounted(() => {
  if (intervalId) {
    clearInterval(intervalId)
  }
})
</script>

<template>
<BlogWidget card title="恋爱墙">
  <div style="text-align: center;">
    <img src="https://图片"
      style="width: 50px;height: 50px;vertical-align: -20px;border-radius: 50%;margin-right: 5px;margin-bottom: 5px;-webkit-box-shadow: 1px 1px 1px rgba(0,0,0,.1), 1px 1px 1px rgba(0,0,0,0.1), 1px 1px 1px rgba(0,0,0,0.1);box-shadow: 1px 1px 1px rgba(0,0,0,.1), 1px 1px 1px rgba(0,0,0,0.1), 1px 1px 1px rgba(0,0,0,0.1);border: 2px solid #fff;" />
    <Icon name="tabler:heart-filled" size="20" style="margin-left: 5px;margin-right: 5px;" class="my-face" />
    <img src="https://图片"
      style="width: 50px;height: 50px;vertical-align: -20px;border-radius: 50%;margin-left: 5px;margin-bottom: 5px;-webkit-box-shadow: 1px 1px 1px rgba(0,0,0,.1), 1px 1px 1px rgba(0,0,0,0.1), 1px 1px 1px rgba(0,0,0,0.1);box-shadow: 1px 1px 1px rgba(0,0,0,.1), 1px 1px 1px rgba(0,0,0,0.1), 1px 1px 1px rgba(0,0,0,0.1);border: 2px solid #fff;" /><br />
    <span id="laq_time"></span>
  </div>
</BlogWidget>
</template>

添加地址显示

/app/components/widget/新建WelcomeVisitor.vue

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 lang="scss" 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

vue
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Temporal } from 'temporal-polyfill'
import blogConfig from '~~/blog.config'

// Twikoo配置
const TWIKOO_URL = 'https://twikoo-url'
const ADMIN_MAIL_MD5 = '博主邮箱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: Temporal.Instant.fromEpochMilliseconds(comment.created).toZonedDateTimeISO(blogConfig.timeZone).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 lang="scss" 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>

添加分享功能

修改/app/components/post/PostFooter.vue

vue
<script setup lang="ts">
import type ArticleProps from '~/types/article'

defineOptions({ inheritAttrs: false })

const props = defineProps<ArticleProps>()
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">
	<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">
		<div class="title text-creative">
			许可协议
		</div>

		<div class="content">
			<p>
				本文采用 <ProseA :href="appConfig.copyright.url">
					{{ appConfig.copyright.name }}
				</ProseA>
				许可协议,转载请注明出处。
			</p>
		</div>
	</section>

	<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 lang="scss" 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

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 lang="scss" 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

vue
<PostFooter v-bind="post" />
<PostDonation /> //添加项
<PostSurround />
<PostComment />

修改blog.config.ts

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 订阅源 */

Twikoo评论样式美化

修改/app/components/post/Comment.vue,替换所有代码。

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.matches('.tk-avatar-img')) {
		e.stopPropagation()
		return
	}

	const link = e.target.closest('a[target="_blank"]')
	if (!(link instanceof HTMLAnchorElement))
		return

	e.preventDefault()
	popoverEl.value?.hide()

	popoverJumpTo.value = safelyDecodeUriComponent(link.href)
	popoverBind.value = {
		getReferenceClientRect: () => link.getBoundingClientRect(),
		triggerTarget: link,
	}

	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 lang="scss" scoped>
.z-comment {
	margin: 2rem auto;
	padding: 0 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;
	}
}

.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; }
}

:deep(#twikoo) {
	.tk-admin-container { position: fixed; z-index: calc(var(--z-index-popover) + 1); }
	.tk-avatar { border-radius: 50% !important; overflow: hidden; }

	@supports (corner-shape: squircle) {
		.tk-avatar {
			border-radius: 50%;
			corner-shape: superellipse(1.2);
		}
	}

	.tk-submit {
		display: flex;
		flex-direction: column;

		.tk-avatar,
		a.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-input {
			order: 1;
			margin-bottom: .5rem;
			font-family: var(--font-monospace);

			.el-textarea__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;

			.el-input-group {
				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%);
				}
			}

			.el-input-group:first-child:before { content: "输入QQ号会自动获取昵称和头像🐧"; }
			.el-input-group:nth-child(2):before { content: "收到回复将会发送到您的邮箱📧"; }
			.el-input-group:nth-child(3):before { content: "可以通过昵称访问您的网站🔗"; }

			.el-input__inner { border: none !important; }

			.el-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-content {
		font-size: .95rem;
		line-height: 1.6;
		margin-top: 0;

		.tk-owo-emotion { width: auto; height: 1.4em; vertical-align: text-bottom; }

		a {
			background: linear-gradient(var(--c-primary-soft), var(--c-primary-soft)) no-repeat bottom/100% .1em;
			color: var(--c-primary);
			margin: 0 -.1em;
			padding: 0 .1em;
			transition: all .2s;

			&:hover { background-size: 100% 100%; border-radius: .3em; }
		}

		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-replies:not(.tk-replies-expand) {
		mask-image: linear-gradient(to top, transparent, #FFF 4em);
	}

	.tk-expand {
		border-radius: .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-nick-link { color: var(--c-primary); }

	.tk-comment .tk-main {
		/* removed margin-top to align spacing */
		.tk-meta { margin-bottom: .3rem; }

		.tk-extras {
			color: var(--c-text-2);
			font-size: .85rem;
			margin-top: .5rem;
		}

		.tk-action .tk-action-link:nth-child(1),
		.tk-action .tk-action-link:nth-child(2) { display: none; }
	}

	.tk-preview, .tk-cancel {
		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-send {
		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);
		}
	}

	.tk-time { color: var(--c-text-3); }
	.tk-extras, .tk-footer { font-size: 0.7em; color: var(--c-text-3); }
}

: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 {
		background: var(--c-bg-2);
		border-left: 4px solid var(--c-border);
		border-radius: 8px;
		color: var(--c-text-2);
		margin: 0.5rem 0 0.8rem;
		padding: .8rem;
		transition: all .2s;
		font-size: 0.9em;
	}
}

@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>