匿名用户

这个人很神秘,什么信息也没有

关闭
Administrator
2026-10-21
点 赞
0
热 度
0
评 论
0

Clarity主题深度定制指南

  1. 首页
  2. 技术
  3. Clarity主题深度定制指南

文章摘要

智阅GPT

部分教程来自于 栖童の小站,我在此基础上做了些调整,感谢大佬的详细教程。

前言

持续更新中

页面相关

添加说说页面

::tab

tabs: ["本地说说", "远程说说(适配柳神二改的Ech0)"]
center: true
active: 2 # 默认显示第二个选项卡,可选

#tab1
在 /app/pages/新建 essay.vue

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

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>
<template #aside>
	<WidgetBlogStats />
	<WidgetBlogTech />
	<WidgetBlogLog />
</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 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

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

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

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

export default [
  {
    text: '这是一个包含<b>原始视频</b>的动态内容示例。<br>现在支持使用<br>进行换行和使用<b>标签实现加粗。',
    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[]

#tab2
::div{.text-center}

点击查看Ech0

在 /app/pages/新建 essay.vue

<script setup lang="ts">
import type { EchoItem, EchoPage, TalkItem } from '~/types/talk'

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

const { author } = useAppConfig()

const PAGE_SIZE = 8
/** 说说源站,单条页面拼接为 `${ECHO_SITE}/echo/${id}` */
const ECHO_SITE = 'https://说说链接'
/** 源站接口已支持跨域,浏览器直连,避免静态打包后 server route 不可用 / 数据被固化 */
const ECHO_API = `${ECHO_SITE}/api/echo/page`

interface EchoResponse {
  code: number
  msg: string
  data: EchoPage
}

const talks = ref<TalkItem[]>([])
const total = ref(0)
const page = ref(0)
const loading = ref(false)
const finished = ref(false)

function mapEchoToTalk(item: EchoItem): TalkItem {
  const talk: TalkItem = {
    text: item.content,
    date: new Date(item.created_at * 1000).toISOString(),
    tags: item.tags?.map(tag => tag.name),
    link: `${ECHO_SITE}/echo/${item.id}`,
  }

  const ext = item.extension
  if (ext?.type === 'VIDEO' && ext.payload?.videoId) {
    const id = ext.payload.videoId
    talk.video = /^https?:\/\//.test(id)
      ? { type: 'raw', id }
      : { type: 'bilibili', id }
  }
  if (ext?.payload?.images?.length) {
    talk.images = ext.payload.images
  }

  return talk
}

async function fetchEcho(targetPage: number): Promise<EchoPage> {
  const res = await $fetch<EchoResponse>(ECHO_API, {
    query: { page: targetPage, size: PAGE_SIZE },
  })
  return res.data
}

async function loadMore(): Promise<void> {
  if (loading.value || finished.value)
    return
  loading.value = true
  try {
    const next = page.value + 1
    const data = await fetchEcho(next)
    const items = (data.items || []).map(mapEchoToTalk)
    talks.value.push(...items)
    total.value = data.total ?? talks.value.length
    page.value = next
    if (!items.length || talks.value.length >= total.value)
      finished.value = true
  }
  finally {
    loading.value = false
  }
}

// 静态打包(pnpm generate)后无服务端执行环境,首屏数据也在浏览器挂载后实时拉取
const sentinel = useTemplateRef('sentinel')
let observer: IntersectionObserver | undefined
onMounted(() => {
  loadMore()
  if (!sentinel.value)
    return
  observer = new IntersectionObserver((entries) => {
    if (entries[0]?.isIntersecting)
      loadMore()
  }, { rootMargin: '200px' })
  observer.observe(sentinel.value)
})
onBeforeUnmount(() => observer?.disconnect())

function replyTalk(content: string): void {
  const input = document.querySelector('#twikoo .tk-input textarea')
  if (!(input instanceof HTMLTextAreaElement)) return

  if (content.trim()) {
    const quotes = content.split('\n').map(str => `> ${str}`)
    input.value = `${quotes}\n\n`
  } else {
    input.value = ''
  }
  input.dispatchEvent(new InputEvent('input'))

  const length = input.value.length
  input.setSelectionRange(length, length)
  input.focus()
}

function getEssayDate(date?: string | Date) {
  if (!date) {
    return ''
  }
  
  const dateStr = typeof date === 'string' ? date : date.toISOString()
  return toZdtLocaleString(dateStr, {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  }).replace(/\//g, '-')
}
</script>

<template>
<template #aside>
	<WidgetWelcomeVisitor />
	<WidgetBlogStats />
	<WidgetBlogTech />
	<WidgetLatestComments />
	<WidgetBlogLog />
	<WidgetCommGroup />
</template>

<ZPageBanner :title :description :image>
  <template #corner>
    <span class="powered-by">Powered by ech0</span>
    <UtilLink class="view-more" :to="ECHO_SITE">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><!-- Icon from KDesign Icons by Kingdee / KDesign - Apache-2.0 https://www.apache.org/licenses/LICENSE-2.0 --><path fill="currentColor" d="M12 17a3 3 0 012.995 2.824L15 20v7a3 3 0 01-2.824 2.995L12 30H5a3 3 0 01-2.995-2.824L2 27v-7a3 3 0 012.824-2.995L5 17zm15 0a3 3 0 012.995 2.824L30 20v7a3 3 0 01-2.824 2.995L27 30h-7a3 3 0 01-2.995-2.824L17 27v-7a3 3 0 012.824-2.995L20 17zm-15 2H5a1 1 0 00-.993.883L4 20v7a1 1 0 00.883.993L5 28h7a1 1 0 00.993-.883L13 27v-7a1 1 0 00-.883-.993zm15 0h-7a1 1 0 00-.993.883L19 20v7a1 1 0 00.883.993L20 28h7a1 1 0 00.993-.883L28 27v-7a1 1 0 00-.883-.993zM12 2a3 3 0 012.995 2.824L15 5v7a3 3 0 01-2.824 2.995L12 15H5a3 3 0 01-2.995-2.824L2 12V5a3 3 0 012.824-2.995L5 2zm15 0a3 3 0 012.995 2.824L30 5v7a3 3 0 01-2.824 2.995L27 15h-7a3 3 0 01-2.995-2.824L17 12V5a3 3 0 012.824-2.995L20 2zM12 4H5a1 1 0 00-.993.883L4 5v7a1 1 0 00.883.993L5 13h7a1 1 0 00.993-.883L13 12V5a1 1 0 00-.883-.993zm15 0h-7a1 1 0 00-.993.883L19 5v7a1 1 0 00.883.993L20 13h7a1 1 0 00.993-.883L28 12V5a1 1 0 00-.883-.993z"/></svg>
      <span>查看更多</span>
    </UtilLink>
  </template>
</ZPageBanner>

<div class="talk-list">
  <div class="talk-item" v-for="(talk, index) in talks" :key="`${talk.date}-${index}`">
    <div class="talk-meta">
      <NuxtImg class="avatar" :src="author.avatar" :alt="author.name" />
      <div class="info">
        <div class="nick">
          {{ author.name }}
          <Icon class="verified" name="i-material-symbols:verified" />
        </div>
        <div class="date">{{ getEssayDate(talk.date) }}</div>
      </div>
    </div>

    <div class="talk-content">
      <div class="text" v-if="talk.text" v-html="talk.text"></div>
      <div class="images" v-if="talk.images">
        <Pic class="image" v-for="image in talk.images" :src="image" />
      </div>
      <VideoEmbed class="video" v-if="talk.video" v-bind="talk.video" height="" />
    </div>

    <div class="talk-bottom">
      <div class="tags">
        <span class="tag" v-for="tag in talk.tags">
          <Icon name="tabler:tag" />
          <span>{{ tag }}</span>
        </span>
        <UtilLink
          class="location"
          v-if="talk.location"
          v-tip="`搜索: ${talk.location}`"
          :to="`https://bing.com/maps?q=${encodeURIComponent(talk.location)}`"
        >
          <Icon name="tabler:map-pin" />
          <span>{{ talk.location }}</span>
        </UtilLink>
      </div>
      <div class="actions">
        <UtilLink
          class="origin"
          v-if="talk.link"
          v-tip="'查看原文'"
          :to="talk.link"
        >
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><!-- Icon from Majesticons by Gerrit Halfmann - MIT https://github.com/halfmage/majesticons/blob/main/LICENSE --><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4m-8-2l8-8m0 0v5m0-5h-5"/></svg>
        </UtilLink>
        <button class="comment-btn" v-tip="'评论'" @click="replyTalk(talk.text)">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><!-- Icon from Mística Icons by Telefónica / Mística - MIT https://opensource.org/licenses/MIT --><path fill="currentColor" d="M6 9.25a.75.75 0 010 1.5H4a.25.25 0 00-.25.25v8.19l1.72-1.72.114-.094A.75.75 0 016 17.25h7a.25.25 0 00.25-.25v-2a.75.75 0 011.5 0v2A1.75 1.75 0 0113 18.75H6.31l-2.78 2.78A.75.75 0 012.25 21V11A1.75 1.75 0 014 9.25zm14-7A1.75 1.75 0 0121.75 4v10a.75.75 0 01-1.28.53l-2.78-2.78H11A1.75 1.75 0 019.25 10V4A1.75 1.75 0 0111 2.25zm-9 1.5a.25.25 0 00-.25.25v6a.25.25 0 00.25.25h7c.199 0 .39.08.53.22l1.72 1.72V4a.25.25 0 00-.25-.25z"/></svg>
        </button>
      </div>
    </div>
  </div>

  <div ref="sentinel" class="talk-footer">
    <p v-if="loading">加载中…</p>
    <p v-else-if="!talks.length">暂无说说</p>
    <p v-else-if="finished">没有更多了</p>
  </div>
</div>

<PostComment />
</template>

<style scoped>
/* 横幅右下角角标 */
.powered-by {
  opacity: .75;
}

.view-more {
  display: inline-flex;
  align-items: center;
  gap: .25em;
  color: inherit;
  opacity: .75;
  transition: opacity .2s;

  &:hover {
    opacity: 1;
  }

  svg {
    display: block;
    width: 1.1em;
    height: 1.1em;
  }
}

.talk-list {
  animation: float-in .2s backwards;
  margin: 1rem;

  .talk-item {
    animation: float-in .3s backwards;
    animation-delay: var(--delay);
    border-radius: 8px;
    box-shadow: 0 0 0 1px var(--c-bg-soft);
    display: flex;
    flex-direction: column;
    gap: .5rem;
    margin-bottom: 1rem;
    padding: 1rem;

    .talk-meta {
      align-items: center;
      display: flex;
      gap: 10px;

      .avatar {
        border-radius: 50%;
        box-shadow: 2px 4px 1rem var(--ld-shadow);
        width: 3em;

        @supports (corner-shape: squircle) {
          corner-shape: superellipse(1.2);
         }
      }

      .nick {
        align-items: center;
        display: flex;
        gap: 5px;
      }

      .date {
        color: var(--c-text-3);
        font-family: var(--font-monospace);
        font-size: .8rem;
      }

      .verified {
        color: var(--c-primary);
        font-size: 16px;
      }
    }

    .talk-content {
      color: var(--c-text-2);
      display: flex;
      flex-direction: column;
      gap: .5rem;
      line-height: 1.6;

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

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

      .images {
        display: grid;
        gap: 8px;
        grid-template-columns: repeat(3, 1fr);
      }

      .image {
        border-radius: 8px;
        overflow: hidden;
        padding-bottom: 100%;
        position: relative;

        :deep(img) {
          height: 100%;
          object-fit: cover;
          position: absolute;
          transition: transform .3s;
          width: 100%;

          &:hover {
            transform: scale(1.05);
          }
        }
      }

      .video {
        border-radius: 8px;
        margin: 0;
      }
    }

    .talk-bottom {
      align-items: center;
      color: var(--c-text-3);
      display: flex;
      justify-content: space-between;

      .tags {
        display: flex;
        font-size: .7rem;
        gap: 4px;
      }

      .tag, .location {
        display: flex;
        padding: 2px 4px;
        border-radius: 4px;
        background-color: var(--c-bg-2);
        align-items: center;
        cursor: pointer;
        transition: all .2s;

        &:hover {
          opacity: .8;
        }
      }

      .tag .tabler-tag + * {
        margin-left: .15em;
      }

      .location {
        color: var(--c-primary);
      }

      .actions {
        align-items: center;
        display: flex;
        gap: 4px;
      }

      .origin {
        align-items: center;
        background-color: var(--c-bg-2);
        border: 1px solid var(--c-bg-soft);
        border-radius: 4px;
        color: var(--c-text-3);
        display: flex;
        padding: 2px 6px;
        transition: all .2s;

        &:hover {
          opacity: .8;
        }

        svg {
          display: block;
          height: 1.15em;
          width: 1.15em;
        }
      }

      .comment-btn {
        align-items: center;
        background-color: var(--c-bg-2);
        border: 1px solid var(--c-bg-soft);
        border-radius: 4px;
        color: var(--c-text-3);
        cursor: pointer;
        display: flex;
        padding: 2px 6px;
        transition: all .2s;

        &:hover {
          opacity: .8;
        }

        svg {
          display: block;
          height: 1.15em;
          width: 1.15em;
        }
      }
    }
  }

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

在 /app/types/新建 talk.ts

export type TalkItem = {
   text?: string
   date: string
   images?: string[]
   video?: {
      type?: 'raw' | 'bilibili' | 'bilibili-nano' | 'youtube' | 'douyin' | 'douyin-wide' | 'tiktok'
      id: string
      ratio?: string | number
      poster?: string
   }
   tags?: string[]
   location?: string
   /** 查看原文的链接(源站单条说说页面) */
   link?: string
}

/** 说说接口返回的标签 */
export interface EchoTag {
   id: string
   name: string
   usage_count?: number
   created_at?: number
}

/** 说说接口返回的媒体扩展 */
export interface EchoExtension {
   id?: string
   echo_id?: string
   type?: string
   payload?: {
      /** B 站 bvid 或原始视频直链 */
      videoId?: string
      images?: string[]
      [key: string]: unknown
   }
   created_at?: number
   updated_at?: number
}

/** 说说接口返回的单条数据 */
export interface EchoItem {
   id: string
   content?: string
   username?: string
   user_id?: string
   layout?: string
   private?: boolean
   tags?: EchoTag[]
   extension?: EchoExtension
   fav_count?: number
   /** Unix 秒级时间戳 */
   created_at: number
}

/** 说说分页接口数据 */
export interface EchoPage {
   total: number
   items: EchoItem[]
}

::

添加标签页面

在 /app/pages/新建 tags.vue

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

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

const { data: listRaw } = await useAsyncData('posts:index', () => queryArticleIndex(), { default: () => [] })
const { listSorted, isAscending, sortOrder } = useArticleSort(listRaw)

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

// 计算每个标签对应的文章
const articlesByTag = computed(() => {
	const result: Record<string, ArticleProps[]> = {}
	for (const article of listSorted.value) {
		if (article.tags) {
			for (const tag of article.tags) {
				(result[tag] ||= []).push(article)
			}
		}
	}
	return result
})

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

// 根据文章数量计算标签大小的函数
function getTagSize(count: number): string {
	const counts = Object.values(articlesByTag.value).map(articles => articles.length)
	const maxCount = Math.max(...counts)
	const minCount = Math.min(...counts)
	const range = maxCount - minCount
	if (range === 0) return 'medium'

	const ratio = (count - minCount) / range
	if (ratio < 0.33) return 'small'
	if (ratio < 0.66) return 'medium'
	return 'large'
}

// 点击标签显示对应文章
function handleTagClick(tag: string) {
	selectedTag.value = tag
	// 滚动到页面顶部
	window.scrollTo({ top: 0, behavior: 'smooth' })
}

// 取消选中标签,返回标签云视图
function clearSelectedTag() {
	selectedTag.value = ''
}
</script>

<template>
<template #aside>
	<WidgetBlogStats />
	<WidgetBlogLog />
</template>
<div class="tags proper-height">
	<!-- 选中标签时显示 -->
	<div v-if="selectedTag" class="tag-selected">
		<div class="tag-selected-header">
			<h1 class="tag-selected-title">
				<span class="tag-hashtag">#</span> {{ selectedTag }}
			</h1>
			<button class="tag-clear-btn" @click="clearSelectedTag" aria-label="返回标签云">
				<Icon name="tabler:x" />
			</button>
		</div>
		<div class="tag-selected-info">
			共 {{ articlesByTag[selectedTag]?.length }} 篇文章
		</div>

		<menu class="archive-list">
			<UtilListTransition v-slot="{ items }" :items="articlesByTag[selectedTag]" :state="selectedTag">
				<PostArchive
					v-for="article, index in items"
					:key="article.path"
					:data-list-key="article.path"
					v-bind="article"
					:to="article.path"
					:use-updated="sortOrder === 'updated'"
					:style="getFixedDelay(index * 0.03)"
				/>
			</UtilListTransition>
		</menu>
	</div>

	<!-- 标签云视图 -->
	<div v-else class="tag-cloud">
		<h1 class="tag-cloud-title">{{ title }}</h1>
		<div class="tag-cloud-content">
			<button
				v-for="tag in sortedTags"
				:key="tag"
				class="tag-cloud-item gradient-card"
				:class="getTagSize(articlesByTag[tag]?.length)"
				@click="handleTagClick(tag)"
			>
				# {{ tag }}
                <span class="tag-count">{{ articlesByTag[tag]?.length }}</span>
			</button>
		</div>

		<div class="tag-cloud-stats">
			共 {{ sortedTags.length }} 个标签
		</div>
	</div>
</div>
</template>

<style scoped>
.tags {
	margin: 1rem;
	padding: 2rem 0;
}

/* 标签云样式 */
.tag-cloud {
	max-width: 800px;
	margin: 0 auto;
}

.tag-cloud-title {
	text-align: center;
	font-size: 2.5rem;
	margin-bottom: 2rem;
	color: var(--c-text);
}

.tag-cloud-content {
	display: flex;
	flex-wrap: wrap;
	justify-content: center;
	gap: 1rem;
	margin-bottom: 2rem;
}

.tag-cloud-item {
	display: inline-flex;
	align-items: center;
	gap: 0.5rem;
	padding: 0.5rem 1rem;
	border-radius: 2rem;
	background-color: var(--c-bg-2);
	color: var(--c-text);
	cursor: pointer;
	line-height: 1.4;

	&.small {
		font-size: 0.9rem;
	}

	&.medium {
		font-size: 1.1rem;
	}

	&.large {
		font-size: 1.3rem;
		font-weight: 600;
	}
}

.tag-count {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	min-width: 20px;
	height: 20px;
	padding: 0 6px;
	border-radius: 10px;
	background-color: var(--c-bg-3);
	color: var(--c-text-2);
	font-size: 0.8rem;
	font-weight: 500;
}

.tag-cloud-stats {
	text-align: center;
	color: var(--c-text-2);
	font-size: 0.9rem;
	margin-top: 2rem;
}

/* 选中标签时的样式 */
.tag-selected {
	max-width: 800px;
	margin: 0 auto;
}

.tag-selected-header {
	display: flex;
	align-items: center;
	justify-content: space-between;
	margin-bottom: 1rem;
}

.tag-selected-title {
	font-size: 2.5rem;
	font-family: var(--font-creative);
	font-weight: 550;
	color: var(--c-text);
	margin: 0;
}

.tag-hashtag {
	margin-inline-end: 0.1em;
	padding: 0 2px;
	border-radius: 0.2rem;
	background-color: var(--c-primary);
	color: white;
}

.tag-clear-btn {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 40px;
	height: 40px;
	border-radius: 50%;
	background-color: var(--c-bg-2);
	color: var(--c-text-2);
	border: none;
	cursor: pointer;
	transition: all 0.2s ease;

	&:hover {
		background-color: var(--c-bg-3);
		color: var(--c-text);
		transform: rotate(90deg);
	}
}

.tag-selected-info {
	color: var(--c-text-2);
	font-size: 1rem;
	margin-bottom: 2rem;
	padding-bottom: 1rem;
	border-bottom: 1px solid var(--c-border);
}

.archive-list {
	margin-top: 1.5rem;
}

/* 响应式设计 */
@media (max-width: 768px) {
	.tags {
		margin: 0.5rem;
		padding: 1rem 0;
	}

	.tag-cloud-title,
	.tag-selected-title {
		font-size: 2rem;
	}

	.tag-cloud-item {
		padding: 0.4rem 0.8rem;
		gap: 0.4rem;
	}

	.tag-cloud-item.large {
		font-size: 1.2rem;
	}
}
</style>

友链朋友圈

::tab

tabs: ["方案1", "方案2"]
center: true

#tab1
适配六神的轻量友链朋友圈

在 /scripts/新建 generate-friend.ts

#!/usr/bin/env node

import fs from 'node:fs'
import { resolve } from 'node:path'
import { intro, log, outro } from '@clack/prompts'
import { entries, displayName } from './framework/utils'

const blacklist = ['xxx', 'xxx']

intro('👥 生成友链数据')

try {
	const friends = entries
		.filter(e => !e.error)
		.map(e => {
			const name = displayName(e)
			return blacklist.includes(name) ? null : [name, e.link, e.avatar]
		})
		.filter(Boolean)

	fs.mkdirSync(resolve('public'), { recursive: true })
	const outputPath = resolve('public', 'friend.json')

	const friendData = { friends }
	fs.writeFileSync(outputPath, JSON.stringify(friendData, null, 2), 'utf-8')

	log.success(`成功生成 ${friends.length} 个友链`) 
	log.info(`输出路径: ${outputPath}`)

	outro('✅ 完成')
} catch (error) {
	log.error('生成失败:', error instanceof Error ? error.message : String(error))
	process.exit(1)
}

执行

pnpm tsx scripts/generate-friend.ts

会在 public目录生成 friend.json
在 /app/pages/新建 fcircle.vue

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

const title = '朋友圈'
const description = '发现更多有趣的博主。'
const image = 'https://图片链接'
useSeoMeta({ title, description, ogImage: image })

// 配置选项
const UserConfig = reactive({
  api_url: 'https://轻量朋友圈/',
  page_size: 20
})

// 状态管理
const allArticles = ref([])
const displayCount = ref(20)
const isLoading = ref(true)
const randomArticle = ref(null)
const showAvatarPopup = ref(false)
const selectedAuthor = ref('')
const selectedAuthorAvatar = ref('')
const selectedArticleLink = ref('')
const articlesByAuthor = ref({})
const lastUpdatedDate = ref('')

// 计算属性
const displayedArticles = computed(() => allArticles.value.slice(0, displayCount.value))
const hasMoreArticles = computed(() => allArticles.value.length > displayCount.value)

// 格式化日期
const formatDate = (dateString: string) => {
  if (!dateString) return ''
  
  return toZdtLocaleString(dateString, 'date').replace(/\//g, '-')
}

// 刷新随机文章
const refreshRandomArticle = () => {
  if (allArticles.value.length > 0) {
    const randomIndex = Math.floor(Math.random() * allArticles.value.length)
    randomArticle.value = allArticles.value[randomIndex]
  }
}

// 加载更多
const loadMore = () => {
  displayCount.value += UserConfig.page_size
}

// 模态框相关
const showAvatarPosts = (author, avatar, articleLink) => {
  selectedAuthor.value = author
  selectedAuthorAvatar.value = avatar
  selectedArticleLink.value = articleLink
  showAvatarPopup.value = true
}

const closeAvatarPopup = () => {
  showAvatarPopup.value = false
}

// 监听点击外部关闭弹窗
const handleClickOutside = (event) => {
  const popup = document.getElementById('avatar-popup')
  if (popup && !popup.contains(event.target) && showAvatarPopup.value) {
    closeAvatarPopup()
  }
}

// 获取数据
const fetchData = async () => {
  try {
    isLoading.value = true
    const response = await fetch(`${UserConfig.api_url}all.json`)
    const data = await response.json()
  
    // 处理数据
    allArticles.value = data.article_data.map(item => ({
      id: item.link + Math.random(), // 确保唯一ID
      title: item.title,
      link: item.link,
      author: item.author,
      created: item.created,
      avatar: item.avatar
    }))
  
    // 按作者分组
    articlesByAuthor.value = allArticles.value.reduce((acc, article) => {
      if (!acc[article.author]) acc[article.author] = []
      acc[article.author].push(article)
      return acc
    }, {})
  
    // 初始化随机文章
    refreshRandomArticle()
  
    // 设置最新更新日期
    if (allArticles.value.length > 0) {
      const sortedArticles = [...allArticles.value].sort((a, b) => 
        new Date(b.created) - new Date(a.created)
      )
      lastUpdatedDate.value = formatDate(sortedArticles[0].created)
    }
  } catch (error) {
    console.error('加载文章失败:', error)
  } finally {
    isLoading.value = false
  }
}

// 生命周期钩子
onMounted(() => {
  fetchData()
})

onUnmounted(() => {
  document.removeEventListener('click', handleClickOutside)
})
</script>

<template>
<template #aside>
	<WidgetBlogStats />
	<WidgetBlogTech />
	<WidgetBlogLog />
</template>

  <ZPageBanner :title :description :image>
    <div class="stats-bar">
      <div class="stats-update-time">Updated at {{ lastUpdatedDate || '2025-07-17' }}</div>
      <div class="stats-powered-by">Powered by FriendCircleLite</div>
    </div>
  </ZPageBanner>

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

      <!-- 文章列表区域 -->
      <div class="articles-list">
        <div
          v-for="(article, index) in displayedArticles"
          :key="article.id"
          class="article-item new-item"
          :style="{ '--delay': `${(index % UserConfig.page_size) * 0.05}s` }"
        >
          <div class="article-image" @click="showAvatarPosts(article.author, article.avatar, article.link)">
            <NuxtImg 
              :src="article.avatar" 
              :alt="article.author"
              loading="lazy"
            />
          </div>
          <a
            :href="article.link"
            target="_blank"
            rel="noopener noreferrer"
            class="article-container gradient-card"
          >
            <span class="article-author">{{ article.author }}</span>
            <span class="article-title">{{ article.title }}</span>
            <span class="article-date">{{ formatDate(article.created) }}</span>
          </a>
        </div>
      </div>

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

      <!-- 空状态 -->
      <div v-if="!isLoading && allArticles.length === 0" class="error-container">
        <Icon class="error-icon" name="tabler:file-alert" />
        <p>暂无文章数据</p>
        <p class="empty-hint">请稍后再试</p>
      </div>

      <!-- 作者模态框 - 时间线样式 -->
      <Transition name="modal">
        <div 
          v-if="showAvatarPopup && selectedAuthor && articlesByAuthor[selectedAuthor]"
          id="avatar-popup"
          class="modal"
          @click="closeAvatarPopup"
        >
          <div class="modal-content" @click.stop>
            <div class="modal-header">
              <NuxtImg 
                :src="selectedAuthorAvatar" 
                :alt="selectedAuthor"
                loading="lazy"
              />
              <h3>{{ selectedAuthor }}</h3>
              <a 
                :href="selectedArticleLink" 
                target="_blank"
                rel="noopener noreferrer"
                class="author-link"
              >
                <Icon name="lucide:external-link" />
              </a>
            </div>
            <div class="modal-body">
              <div class="timeline">
                <div 
                  v-for="(article, index) in articlesByAuthor[selectedAuthor].slice(0, 10)"
                  :key="article.id"
                  class="timeline-item"
                  :style="{ '--delay': (0.2 + index * 0.1) + 's' }"
                >
                  <span class="date">{{ formatDate(article.created) }}</span>
                  <a 
                    :href="article.link"
                    target="_blank"
                    rel="noopener noreferrer"
                    class="article-title"
                    @click="closeAvatarPopup"
                  >
                    {{ article.title }}
                  </a>
                </div>
              </div>
            </div>
            <div class="modal-avatar">
              <NuxtImg 
                :src="selectedAuthorAvatar" 
                :alt="selectedAuthor"
                loading="lazy"
              />
            </div>
          </div>
        </div>
      </Transition>
    </div>
  </div>
</template>

<style scoped>
.stats-bar {
	align-items: flex-end;
	color: #eee;
	display: flex;
	flex-direction: column;
	font-family: var(--font-monospace);
	font-size: .7rem;
	gap: .1rem;
	opacity: .7;
	text-shadow: 0 4px 5px rgba(0, 0, 0, .5);

	.stats-update-time {
		opacity: 1;
	}

	.stats-powered-by {
		opacity: .8;
	}
}

.page-fcircle {
	animation: float-in .2s backwards;
	margin: 1rem;

	.random-article {
		align-items: center;
		display: flex;
		flex-direction: row;
		gap: 10px;
		justify-content: space-between;
		margin: 1rem 0;

		.random-title {
			font-size: 1.2rem;
			white-space: nowrap;
		}

		.article-item {
			flex: 1;
			min-width: 0;

			.article-container {
				min-width: 0;

				.article-title {
					overflow: hidden;
					text-overflow: ellipsis;
					white-space: nowrap;
				}

				.article-author,
				.article-date {
					flex-shrink: 0;
				}
			}
		}

		.refresh-btn {
			align-items: center;
			background-color: unset;
			border-radius: 8px;
			box-shadow: none;
			color: var(--c-text-2);
			cursor: pointer;
			display: flex;
			flex-shrink: 0;
			height: 2.5rem;
			justify-content: center;
			transition: all .2s ease;
			width: 2.5rem;

			&:hover {
				background-color: unset;
			}
		}
	}

	.articles-list {
		display: flex;
		flex-direction: column;
		gap: .5rem;
	}
}

.article-item {
	align-items: center;
	display: flex;
	gap: 10px;
	width: 100%;

	&.new-item {
		animation: float-in .2s var(--delay) backwards;
	}

	.article-image {
		border-radius: 50%;
		box-shadow: 0 0 0 1px var(--c-bg-soft);
		display: flex;
		flex-shrink: 0;
		height: 2rem;
		overflow: hidden;
		width: 2rem;

		img {
			height: 100%;
			object-fit: cover;
			opacity: .8;
			transition: all .2s;
			width: 100%;
		}
	}

	.article-container {
		align-items: center;
		border-radius: 8px;
		box-shadow: 0 0 0 1px var(--c-bg-soft);
		display: flex;
		gap: 5px;
		overflow: hidden;
		padding: 10px;
		width: 100%;

		&:hover .article-title {
			color: var(--c-text);
		}

		.article-author {
			color: var(--c-text-3);
			font-size: .85rem;
		}

		.article-title {
			color: var(--c-text-2);
			flex: 1;
			font-size: .9375rem;
			overflow: hidden;
			text-overflow: ellipsis;
			transition: color .2s;
			white-space: nowrap;
		}

		.article-date {
			color: var(--c-text-3);
			font-family: var(--font-monospace);
			font-size: .75rem;
		}
	}
}

.load-more {
	background-color: var(--ld-bg-card);
	border-radius: 8px;
	box-shadow: .1em .2em .5rem var(--ld-shadow);
	display: block;
	font-size: .875rem;
	height: 42px;
	margin: 1rem auto;
	padding: .75rem;
	width: 200px;

	&:hover {
		color: var(--c-text);
	}
}

/* 模态框样式 */
.modal {
	align-items: center;
	backdrop-filter: blur(20px);
	-webkit-backdrop-filter: blur(20px);
	display: flex;
	inset: 0;
	justify-content: center;
	position: fixed;
	z-index: 900;

	.modal-content {
		background-color: var(--c-bg-a50);
		border-radius: 12px;
		box-shadow: 0 0 0 1px var(--c-bg-soft);
		max-height: 80vh;
		max-width: 500px;
		overflow-y: auto;
		padding: 1.25rem;
		position: relative;
		width: 90%;

		.modal-header {
			align-items: center;
			border-bottom: 1px solid var(--c-bg-soft);
			display: flex;
			gap: 15px;
			margin-bottom: 20px;
			padding-bottom: 15px;

			img {
				border-radius: 50%;
				height: 50px;
				object-fit: cover;
				width: 50px;
			}

			h3 {
				flex: 1;
				font-size: 1.2rem;
				margin: 0;
			}

			.author-link {
				border-radius: 8px;
				color: var(--c-text-2);
				padding: 8px;
				transition: all .3s;

				&:hover {
					background: var(--c-bg-soft);
					color: var(--c-text);
				}
			}
		}

		.modal-body {
			.timeline {
				position: relative;

				&:after {
					background-color: var(--c-bg-soft);
					bottom: 0;
					content: "";
					left: .25rem;
					position: absolute;
					top: .5rem;
					transform: translate(-50%);
					width: 2px;
				}

				.timeline-item {
					animation: slideIn .3s ease-out both;
					color: var(--c-text-2);
					padding: 0 0 1rem 1.25rem;
					position: relative;

					&:before {
						background-color: var(--c-text-2);
						border-radius: 50%;
						content: "";
						height: .5rem;
						left: .25rem;
						position: absolute;
						top: .5rem;
						transform: translateY(-50%) translate(-50%);
						transition: transform .3s ease, box-shadow .3s ease;
						width: .5rem;
						z-index: 1;
					}

					&:hover:before {
						box-shadow: 0 0 8px var(--c-text-2);
						transform: translateY(-50%) translate(-50%) scale(1.5);
					}

					.date {
						color: var(--c-text-3);
						display: block;
						font-family: var(--font-monospace);
						font-size: .875rem;
						margin-bottom: .3rem;
					}

					.article-title {
						color: var(--c-text-2);
						line-height: 1.4;
						transition: color .3s;

						&:hover {
							color: var(--c-text);
						}
					}
				}
			}
		}

		.modal-avatar {
			border-radius: 50%;
			bottom: 1.25rem;
			filter: blur(5px);
			height: 128px;
			opacity: .6;
			overflow: hidden;
			pointer-events: none;
			position: absolute;
			right: 1.25rem;
			width: 128px;
			z-index: 1;

			img {
				height: 100%;
				object-fit: cover;
				width: 100%;
			}
		}
	}
}

@keyframes slideIn {
	0% {
		opacity: 0;
		transform: translateY(20px);
	}
	to {
		opacity: 1;
		transform: translateY(0);
	}
}

/* 模态框过渡 */
.modal-enter-active,
.modal-enter-active .modal-content,
.modal-leave-active,
.modal-leave-active .modal-content {
	transition: all .3s ease;
}

.modal-enter-from,
.modal-leave-to {
	opacity: 0;
}

.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
	transform: translateY(-20px);
}

.modal-enter-to,
.modal-leave-from {
	opacity: 1;
}

.modal-enter-to .modal-content,
.modal-leave-from .modal-content {
	transform: translateY(0);
}

.loading-container {
	align-items: center;
	color: var(--c-text-2);
	display: flex;
	flex-direction: column;
	gap: 12px;
	height: 500px;
	justify-content: center;

	.loading-spinner {
		animation: spin 1s linear infinite;
		border: 3px solid var(--c-bg-3);
		border-radius: 50%;
		border-top-color: var(--c-primary);
		height: 40px;
		width: 40px;
	}
}

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

	.error-icon {
		color: var(--c-danger);
		font-size: 4rem;
	}
}

@keyframes spin {
	to {
		transform: rotate(1turn);
	}
}

/* 移动端适配 */
@media (max-width: 768px) {
	.random-article .random-title {
		display: none;
	}

	.page-fcircle .article-item .article-container {
		flex-wrap: wrap;
		height: auto;
	}

	.page-fcircle .article-item .article-container .article-author {
		flex-grow: 1;
	}

	.page-fcircle .article-item .article-container .article-title {
		flex-basis: 100%;
		order: 3;
		white-space: normal;
	}
}
</style>

#tab2
适配我自己写的友链订阅程序
在 /app/pages/新建 fcircle.vue

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

const title = '朋友圈'
const description = '发现更多有趣的博主。'
const image = 'https://图片链接'
useSeoMeta({ title, description, ogImage: image })

// 配置选项
const UserConfig = reactive({
  api_url: 'https://友链订阅地址/api/articles.php',
  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)
    const data = await response.json()

    // 处理数据
    allArticles.value = data.data.articles.map(item => ({
      id: item.link + Math.random(), // 确保唯一ID
      title: item.title,
      link: item.link,
      author: item.blogger,
      created: item.pub_time.replace(' ', 'T'),
      avatar: item.site_avatar
    }))

    // 按作者分组
    articlesByAuthor.value = allArticles.value.reduce((acc, article) => {
      if (!acc[article.author]) acc[article.author] = []
      acc[article.author].push(article)
      return acc
    }, {})

    // 初始化随机文章
    refreshRandomArticle()

    // 设置最新更新日期(接口顶层直接返回更新时间)
    if (data.data.updated) {
      lastUpdatedDate.value = formatDate(data.data.updated.replace(' ', 'T'))
    }
    else if (allArticles.value.length > 0) {
      const sortedArticles = [...allArticles.value].sort((a, b) => 
        new Date(b.created) - new Date(a.created)
      )
      lastUpdatedDate.value = formatDate(sortedArticles[0].created)
    }
  } catch (error) {
    console.error('加载文章失败:', error)
  } finally {
    isLoading.value = false
  }
}

// 生命周期钩子
onMounted(() => {
  fetchData()
})

onUnmounted(() => {
  document.removeEventListener('click', handleClickOutside)
})
</script>

<template>
<template #aside>
	<WidgetWelcomeVisitor />
	<WidgetBlogStats />
	<WidgetBlogTech />
	<WidgetLatestComments />
	<WidgetBlogLog />
	<WidgetCommGroup />
</template>

  <ZPageBanner :title :description :image>
    <div class="stats-bar">
      <div class="stats-update-time">Updated at {{ lastUpdatedDate || '2025-07-17' }}</div>
      <div class="stats-powered-by">Powered by FriendCircleLite</div>
    </div>
  </ZPageBanner>

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

      <!-- 文章列表区域 -->
      <div class="articles-list">
        <div
          v-for="(article, index) in displayedArticles"
          :key="article.id"
          class="article-item new-item"
          :style="{ '--delay': `${(index % UserConfig.page_size) * 0.05}s` }"
        >
          <div class="article-image" @click="showAvatarPosts(article.author, article.avatar, article.link)">
            <NuxtImg 
              :src="article.avatar" 
              :alt="article.author"
              loading="lazy"
            />
          </div>
          <a
            :href="article.link"
            target="_blank"
            rel="noopener noreferrer"
            class="article-container gradient-card"
          >
            <span class="article-author">{{ article.author }}</span>
            <span class="article-title">{{ article.title }}</span>
            <span class="article-date">{{ formatDate(article.created) }}</span>
          </a>
        </div>
      </div>

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

      <!-- 空状态 -->
      <div v-if="!isLoading && allArticles.length === 0" class="error-container">
        <Icon class="error-icon" name="tabler:file-alert" />
        <p>暂无文章数据</p>
        <p class="empty-hint">请稍后再试</p>
      </div>

      <!-- 作者模态框 - 时间线样式 -->
      <Transition name="modal">
        <div 
          v-if="showAvatarPopup && selectedAuthor && articlesByAuthor[selectedAuthor]"
          id="avatar-popup"
          class="modal"
          @click="closeAvatarPopup"
        >
          <div class="modal-content" @click.stop>
            <div class="modal-header">
              <NuxtImg 
                :src="selectedAuthorAvatar" 
                :alt="selectedAuthor"
                loading="lazy"
              />
              <h3>{{ selectedAuthor }}</h3>
              <a 
                :href="selectedArticleLink" 
                target="_blank"
                rel="noopener noreferrer"
                class="author-link"
              >
                <Icon name="lucide:external-link" />
              </a>
            </div>
            <div class="modal-body">
              <div class="timeline">
                <div 
                  v-for="(article, index) in articlesByAuthor[selectedAuthor].slice(0, 10)"
                  :key="article.id"
                  class="timeline-item"
                  :style="{ '--delay': (0.2 + index * 0.1) + 's' }"
                >
                  <span class="date">{{ formatDate(article.created) }}</span>
                  <a 
                    :href="article.link"
                    target="_blank"
                    rel="noopener noreferrer"
                    class="article-title"
                    @click="closeAvatarPopup"
                  >
                    {{ article.title }}
                  </a>
                </div>
              </div>
            </div>
            <div class="modal-avatar">
              <NuxtImg 
                :src="selectedAuthorAvatar" 
                :alt="selectedAuthor"
                loading="lazy"
              />
            </div>
          </div>
        </div>
      </Transition>
    </div>
  </div>
</template>

<style scoped>
.stats-bar {
	align-items: flex-end;
	color: #eee;
	display: flex;
	flex-direction: column;
	font-family: var(--font-monospace);
	font-size: .7rem;
	gap: .1rem;
	opacity: .7;
	text-shadow: 0 4px 5px rgba(0, 0, 0, .5);

	.stats-update-time {
		opacity: 1;
	}

	.stats-powered-by {
		opacity: .8;
	}
}

.page-fcircle {
	animation: float-in .2s backwards;
	margin: 1rem;

	.random-article {
		align-items: center;
		display: flex;
		flex-direction: row;
		gap: 10px;
		justify-content: space-between;
		margin: 1rem 0;

		.random-title {
			font-size: 1.2rem;
			white-space: nowrap;
		}

		.article-item {
			flex: 1;
			min-width: 0;

			.article-container {
				min-width: 0;

				.article-title {
					overflow: hidden;
					text-overflow: ellipsis;
					white-space: nowrap;
				}

				.article-author,
				.article-date {
					flex-shrink: 0;
				}
			}
		}

		.refresh-btn {
			align-items: center;
			background-color: unset;
			border-radius: 8px;
			box-shadow: none;
			color: var(--c-text-2);
			cursor: pointer;
			display: flex;
			flex-shrink: 0;
			height: 2.5rem;
			justify-content: center;
			transition: all .2s ease;
			width: 2.5rem;

			&:hover {
				background-color: unset;
			}
		}
	}

	.articles-list {
		display: flex;
		flex-direction: column;
		gap: .5rem;
	}
}

.article-item {
	align-items: center;
	display: flex;
	gap: 10px;
	width: 100%;

	&.new-item {
		animation: float-in .2s var(--delay) backwards;
	}

	.article-image {
		border-radius: 50%;
		box-shadow: 0 0 0 1px var(--c-bg-soft);
		display: flex;
		flex-shrink: 0;
		height: 2rem;
		overflow: hidden;
		width: 2rem;

		img {
			height: 100%;
			object-fit: cover;
			opacity: .8;
			transition: all .2s;
			width: 100%;
		}
	}

	.article-container {
		align-items: center;
		border-radius: 8px;
		box-shadow: 0 0 0 1px var(--c-bg-soft);
		display: flex;
		gap: 5px;
		overflow: hidden;
		padding: 10px;
		width: 100%;

		&:hover .article-title {
			color: var(--c-text);
		}

		.article-author {
			color: var(--c-text-3);
			font-size: .85rem;
		}

		.article-title {
			color: var(--c-text-2);
			flex: 1;
			font-size: .9375rem;
			overflow: hidden;
			text-overflow: ellipsis;
			transition: color .2s;
			white-space: nowrap;
		}

		.article-date {
			color: var(--c-text-3);
			font-family: var(--font-monospace);
			font-size: .75rem;
		}
	}
}

.load-more {
	background-color: var(--ld-bg-card);
	border-radius: 8px;
	box-shadow: .1em .2em .5rem var(--ld-shadow);
	display: block;
	font-size: .875rem;
	height: 42px;
	margin: 1rem auto;
	padding: .75rem;
	width: 200px;

	&:hover {
		color: var(--c-text);
	}
}

/* 模态框样式 */
.modal {
	align-items: center;
	backdrop-filter: blur(20px);
	-webkit-backdrop-filter: blur(20px);
	display: flex;
	inset: 0;
	justify-content: center;
	position: fixed;
	z-index: 900;

	.modal-content {
		background-color: var(--c-bg-a50);
		border-radius: 12px;
		box-shadow: 0 0 0 1px var(--c-bg-soft);
		max-height: 80vh;
		max-width: 500px;
		overflow-y: auto;
		padding: 1.25rem;
		position: relative;
		width: 90%;

		.modal-header {
			align-items: center;
			border-bottom: 1px solid var(--c-bg-soft);
			display: flex;
			gap: 15px;
			margin-bottom: 20px;
			padding-bottom: 15px;

			img {
				border-radius: 50%;
				height: 50px;
				object-fit: cover;
				width: 50px;
			}

			h3 {
				flex: 1;
				font-size: 1.2rem;
				margin: 0;
			}

			.author-link {
				border-radius: 8px;
				color: var(--c-text-2);
				padding: 8px;
				transition: all .3s;

				&:hover {
					background: var(--c-bg-soft);
					color: var(--c-text);
				}
			}
		}

		.modal-body {
			.timeline {
				position: relative;

				&:after {
					background-color: var(--c-bg-soft);
					bottom: 0;
					content: "";
					left: .25rem;
					position: absolute;
					top: .5rem;
					transform: translate(-50%);
					width: 2px;
				}

				.timeline-item {
					animation: slideIn .3s ease-out both;
					color: var(--c-text-2);
					padding: 0 0 1rem 1.25rem;
					position: relative;

					&:before {
						background-color: var(--c-text-2);
						border-radius: 50%;
						content: "";
						height: .5rem;
						left: .25rem;
						position: absolute;
						top: .5rem;
						transform: translateY(-50%) translate(-50%);
						transition: transform .3s ease, box-shadow .3s ease;
						width: .5rem;
						z-index: 1;
					}

					&:hover:before {
						box-shadow: 0 0 8px var(--c-text-2);
						transform: translateY(-50%) translate(-50%) scale(1.5);
					}

					.date {
						color: var(--c-text-3);
						display: block;
						font-family: var(--font-monospace);
						font-size: .875rem;
						margin-bottom: .3rem;
					}

					.article-title {
						color: var(--c-text-2);
						line-height: 1.4;
						transition: color .3s;

						&:hover {
							color: var(--c-text);
						}
					}
				}
			}
		}

		.modal-avatar {
			border-radius: 50%;
			bottom: 1.25rem;
			filter: blur(5px);
			height: 128px;
			opacity: .6;
			overflow: hidden;
			pointer-events: none;
			position: absolute;
			right: 1.25rem;
			width: 128px;
			z-index: 1;

			img {
				height: 100%;
				object-fit: cover;
				width: 100%;
			}
		}
	}
}

@keyframes slideIn {
	0% {
		opacity: 0;
		transform: translateY(20px);
	}
	to {
		opacity: 1;
		transform: translateY(0);
	}
}

/* 模态框过渡 */
.modal-enter-active,
.modal-enter-active .modal-content,
.modal-leave-active,
.modal-leave-active .modal-content {
	transition: all .3s ease;
}

.modal-enter-from,
.modal-leave-to {
	opacity: 0;
}

.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
	transform: translateY(-20px);
}

.modal-enter-to,
.modal-leave-from {
	opacity: 1;
}

.modal-enter-to .modal-content,
.modal-leave-from .modal-content {
	transform: translateY(0);
}

.loading-container {
	align-items: center;
	color: var(--c-text-2);
	display: flex;
	flex-direction: column;
	gap: 12px;
	height: 500px;
	justify-content: center;

	.loading-spinner {
		animation: spin 1s linear infinite;
		border: 3px solid var(--c-bg-3);
		border-radius: 50%;
		border-top-color: var(--c-primary);
		height: 40px;
		width: 40px;
	}
}

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

	.error-icon {
		color: var(--c-danger);
		font-size: 4rem;
	}
}

@keyframes spin {
	to {
		transform: rotate(1turn);
	}
}

/* 移动端适配 */
@media (max-width: 768px) {
	.random-article .random-title {
		display: none;
	}

	.page-fcircle .article-item .article-container {
		flex-wrap: wrap;
		height: auto;
	}

	.page-fcircle .article-item .article-container .article-author {
		flex-grow: 1;
	}

	.page-fcircle .article-item .article-container .article-title {
		flex-basis: 100%;
		order: 3;
		white-space: normal;
	}
}
</style>

::

添加 about 页面

在 /app/pages/新建 about.vue

<script setup lang="ts">
const { author } = useAppConfig()
const title = '关于我'
const description = '博主的个人介绍页面。'
useSeoMeta({ title, description, ogImage: author.avatar })

// 初始化统计数据
const statsData = ref({
  today_uv: '加载中...',
  today_pv: '加载中...',
  yesterday_uv: '加载中...',
  yesterday_pv: '加载中...',
  last_month_pv: '加载中...',
  last_year_pv: '加载中...'
})

// 获取Umami统计数据
onMounted(async () => {
  try {
    const response = await $fetch<any>('https://status.chario.cn/api/stats', {
      method: 'GET',
      headers: {
        // 如果需要认证,请添加相应的headers
        // 'Authorization': 'Bearer your-token-here'
      }
    })

    if (response) {
      statsData.value = {
        today_uv: formatNumber(response.today_uv || 0),
        today_pv: formatNumber(response.today_pv || 0),
        yesterday_uv: formatNumber(response.yesterday_uv || 0),
        yesterday_pv: formatNumber(response.yesterday_pv || 0),
        last_month_pv: formatNumber(response.last_month_pv || 0),
        last_year_pv: formatNumber(response.last_year_pv || 0)
      }
    }
  } catch (error) {
    console.error('获取统计数据失败:', error)
  }
})

// 格式化数字
function formatNumber(num: number) {
  if (num >= 10000) {
    return `${(num / 10000).toFixed(1)}万`
  }
  return num.toString()
}
</script>

<template>
<template #aside>
	<WidgetWelcomeVisitor />
	<WidgetBlogStats />
	<WidgetBlogTech />
	<WidgetLatestComments />
	<WidgetBlogLog />
	<WidgetCommGroup />
</template>

<div class="about-page">
  <div class="about-content">
    <!-- 页面标题 -->
    <header class="about-header">
      <div class="header-avatar">
        <div class="avatar-frame">
          <img :src="author.avatar" alt="作者头像" class="avatar-image">
        </div>
      </div>
      <div class="header-text">
        <h1>关于我</h1>
        <p>总有些事情比永恒更重要!</p>
      </div>
    </header>

    <!-- 卡片网格布局 -->
    <div class="cards-grid">
      <!-- 个人介绍卡片 -->
      <div class="card intro-card">
        <p>您好,很高兴认识您!👋</p>
        <h2>我叫 {{ author.name }}</h2>
        <p>是一名 打工人、独立开发者、知喜博客管理员、博主。</p>
        <Icon name="tabler:user-circle" class="card-bg-icon" />
      </div>

      <!-- 信息卡片 - 出生和年龄 -->
      <div class="card info-card age-card">
        <div class="info-item special-info-item">
          <span class="label">出生</span>
          <span class="value">2005</span>
        </div>
        <div class="info-item special-info-item">
          <span class="label">当前</span>
          <span class="value">21岁 <Icon name="ri:graduation-cap-line" /></span>
        </div>
        <Icon name="tabler:calendar-week" class="card-bg-icon" />
      </div>

      <!-- 座右铭卡片 -->
      <div class="card motto-card">
        <span class="label">座右铭</span>
        <p>看五年 想三年</p>
        <p>认认真真做好一两年</p>
        <Icon name="tabler:compass" class="card-bg-icon" />
      </div>

      <!-- 关注偏好卡片 -->
      <div class="card tech-card">
        <span class="label">关注偏好</span>
        <h3>文学领域</h3>
        <p>看书、写文章</p>
        <Icon name="tabler:star-filled" class="card-bg-icon" />
      </div>

      <!-- 音乐偏好卡片 -->
      <div class="card music-card">
        <span class="label">音乐偏好</span>
        <h3>民谣、说唱</h3>
        <p>等我喜欢就听</p>
        <Icon name="tabler:music" class="card-bg-icon" />
      </div>

      <!-- 性格卡片 -->
      <div class="card info-card personality-card">
        <span class="label">性格</span>
        <div class="content-center">
          <span class="value">提倡者</span>
          <span class="value-small">INFJ-T</span>
        </div>
        <ProseA class="card-link" href="https://www.16personalities.com">在 16 Personalities 了解更多</ProseA>
        <Icon name="tabler:brain" class="card-bg-icon" />
      </div>

      <!-- 特长卡片 -->
      <div class="card specialty-card">
        <span class="label">特长</span>
        <p class="specialty-text">
          Linux、绘画
        </p>
        <p class="specialty-text">
          学习能力 <span class="highlight">MAX</span>
        </p>
        <Icon name="tabler:wand" class="card-bg-icon" />
      </div>

      <!-- 联系方式卡片 -->
      <div class="card contact-card">
        <span class="label">联系我</span>
        <div class="contact-links">
          <ZButton class="contact-link" v-tip="'邮箱'" icon="tabler:mail" :to="`mailto:${author.email}`" />
          <ZButton class="contact-link" v-tip="'微信'" icon="tabler:brand-wechat" to="https://weixin.qq.com" />
          <ZButton class="contact-link" v-tip="'哔哩哔哩'" icon="ri:bilibili-fill" to="https://bilibili.com" />
          <ZButton class="contact-link" v-tip="'Telegram'" icon="tabler:brand-telegram" to="https://t.me" />
          <ZButton class="contact-link" v-tip="'Discord'" icon="tabler:brand-discord" to="https://discord.com" />
          <ZButton class="contact-link" v-tip="'X'" icon="tabler:brand-x" to="https://x.com" />
        </div>
        <Icon name="tabler:address-book" class="card-bg-icon" />
      </div>

      <!-- 网站统计卡片 -->
      <div class="card stats-card">
        <span class="label">网站统计</span>
        <div class="stats-content">
          <div class="stats-range-section">
            <div class="stats-grid">
              <div class="stat-item">
                <span class="stat-value">{{ statsData.today_pv }}</span>
                <span class="stat-label">浏览量</span>
              </div>
              <div class="stat-item">
                <span class="stat-value">{{ statsData.today_uv }}</span>
                <span class="stat-label">访客数</span>
              </div>
              <div class="stat-item">
                <span class="stat-value">{{ statsData.yesterday_pv }}</span>
                <span class="stat-label">访问次数</span>
              </div>
              <div class="stat-item">
                <span class="stat-value">{{ statsData.yesterday_uv }}</span>
                <span class="stat-label">分钟停留</span>
              </div>
            </div>
          </div>
        </div>
        <Icon name="tabler:chart-histogram" class="card-bg-icon" />
      </div>
    </div>
  </div>
</div>

<PostComment />
</template>

<style scoped>
/* 全局样式 */
.about-page {
  padding: 2rem 1rem;
  min-height: calc(100vh - var(--header-height));
  animation: float-in .3s backwards;
}

.about-content {
  max-width: 1000px;
  margin: 0 auto;
}

/* 页面标题 */
.about-header {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin-bottom: 3rem;
  padding: 1rem 0;
  text-align: center;

  .header-text {
    h1 {
      margin-bottom: .5rem;
      font-size: 2.5rem;
      font-weight: 800;
      color: var(--c-text-1);
    }

    p {
      margin: 0;
      font-size: 1.2rem;
      color: var(--c-text-2);
    }
  }

  .header-avatar {
    display: flex;
    align-items: center;
    justify-content: center;
    margin-bottom: 1.5rem; 
  }

  .avatar-frame {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    width: 120px;
    height: 120px;
    border: 3px solid var(--c-border);
    border-radius: 50%;
    background-color: var(--c-bg-soft);
    transition: all .3s ease;

    @supports (corner-shape: squircle) {
      corner-shape: superellipse(1.2);
    }
  }

  .avatar-image {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
}

/* 卡片网格布局 */
.cards-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1.5rem;
}

/* 通用卡片样式 */
.card {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
  min-height: 220px;
  padding: 2rem 1.5rem;
  border: 1px solid var(--c-border);
  border-radius: 1.5rem;
  background-color: var(--ld-bg-card);
  text-align: center;
  transition: all .3s ease;
  box-shadow: none;

  &:hover {
    box-shadow: none;
    transform: none;
  }

  .label {
    position: absolute;
    opacity: .8;
    top: 1rem;
    left: 1.5rem;
    margin: 0;
    font-size: .8rem;
    color: var(--c-text-2);
  }

  .card-bg-icon {
    position: absolute;
    opacity: .1;
    right: 1rem;
    bottom: 1rem;
    font-size: 5rem;
    color: var(--c-text-1);
    pointer-events: none;
  }
}

/* 卡片类型样式 */

/* 个人介绍卡片 */
.intro-card {
  grid-column: 1 / -1;
  color: var(--c-text-1);

  h2 {
    margin: .5rem 0;
    font-size: 3rem;
    font-weight: bold;
  }
}

/* 信息卡片基类 */
.info-card {
  align-items: stretch;
  justify-content: center;
  padding: 2.5rem 1.5rem;
  color: var(--c-text-1);

  .info-item {
    display: flex;
    flex-direction: column;
    flex-grow: 1;
    align-items: flex-start;
    justify-content: center;
    position: relative;
    width: 100%;

    .label {
      flex-shrink: 0;
      position: static;
      width: 100%;
      margin-bottom: .5rem;
      text-align: left;
    }
  }

  .value {
    display: block;
    width: 100%;
    font-size: 2.5rem;
    font-weight: bold;
    text-align: center;
  }

  .value-small {
    display: block;
    width: 100%;
    font-size: 2rem;
    font-weight: bold;
    text-align: center;
  }

  .card-link {
    position: absolute;
    right: 1.5rem;
    bottom: 1rem;
    font-size: .8rem;
    color: var(--c-text-2);

    &:hover {
      color: var(--c-primary);
    }
  }
}

/* 年龄卡片 */
.age-card {
  padding: .4rem 1.5rem .5rem;
}

/* 座右铭卡片 */
.motto-card {
  color: var(--c-text-1);

  p {
    margin: 0;
    font-size: 2.5rem;
    font-weight: bold;
    line-height: 1.2;
  }
}

/* 关注偏好卡片 */
.tech-card {
  color: var(--c-text-1);

  h3 {
    margin: .5rem 0;
    font-size: 3rem;
    font-weight: bold;
  }

  p {
    color: var(--c-text-2);
  }
}

/* 音乐偏好卡片 */
.music-card {
  color: var(--c-text-1);

  h3 {
    font-size: 2.5rem;
    font-weight: bold;
  }

  p {
    color: var(--c-text-2);
  }
}

/* 特长卡片 */
.specialty-card {
  font-size: 1.8rem;
  font-weight: bold;
  text-align: center;
  color: var(--c-text-1);

  .specialty-text {
    margin: .2em 0;
  }

  .highlight {
    display: inline-block;
    font-size: 2.5rem;
    line-height: 1;
    color: var(--c-primary);
  }
}

/* 联系方式卡片 */
.contact-card {
  grid-column: 1 / -1;
  color: var(--c-text-1);

  .contact-links {
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
    max-width: 600px;
    margin: 0 auto;
  }

  .contact-link {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 50px;
    height: 50px;
    background-color: var(--c-bg-1);
    border: 1px solid var(--c-border);
    border-radius: 50%;
    color: var(--c-text-1);
    font-size: 1.4rem;
    transition: all .2s ease;
    padding: 0;
    box-shadow: none;

    &:hover {
      background-color: var(--c-bg-soft);
      color: var(--c-text);
    }
  }
}

/* 网站统计卡片 */
.stats-card {
  grid-column: 1 / -1;
  color: var(--c-text-1);
}

.stats-content {
  width: 100%;
}

.stats-range-section {
  margin-bottom: 0;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 1rem;
  margin-bottom: 0;
}

.stat-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: .5rem;
  background-color: var(--c-bg-1);
  border-radius: .8rem;
  transition: transform .2s ease;
}

.stat-value {
  margin-bottom: .25rem;
  font-size: 2rem;
  font-weight: bold;
  color: var(--c-text-1);
}

.stat-label {
  opacity: .9;
  font-size: .9rem;
  color: var(--c-text-2);
}

/* 动画效果 */
@keyframes float-in {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* 响应式布局 */
@media (max-width: 768px) {
  .about-page {
    padding: 1rem;
  }
  
  .about-header {
    flex-direction: column;
    text-align: center;
    margin-bottom: 2rem;
  }
  
  .about-header .header-text {
    margin-bottom: 1.5rem;
  }
  
  .avatar-frame {
    width: 100px;
    height: 100px;
  }
  
  .card {
    padding: 1.5rem 1rem;
  }
  
  .cards-grid {
    gap: 1rem;
  }

  .age-card {
    padding: .4rem 1.5rem .5rem;
  }

  .contact-card .contact-link {
    width: 45px;
    height: 45px;
    font-size: 1.2rem;
  }

  .stats-card {
    padding: 3rem 1rem 1.5rem;
  }

  .stats-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 暗黑模式支持 */
:deep(.dark-mode) {
  .card {
    border-color: var(--c-border);
    background-color: var(--c-bg-2);
  }

  .label, .card-bg-icon, .tech-card p, .stat-label {
    color: var(--c-text-2);
  }

  .info-card .card-link {
    color: var(--c-text-2);

    &:hover {
      color: var(--c-primary);
    }
  }

  .specialty-card .highlight {
    color: var(--c-primary);
  }

  .contact-card .contact-link {
    background-color: var(--c-bg-1);
    border-color: var(--c-border);
    color: var(--c-text-1);

    &:hover {
      background-color: var(--c-bg-soft);
      color: var(--c-text);
      transform: translateY(-2px);
      border-color: var(--c-border);
    }
  }

  .stat-item {
    background-color: var(--c-bg-1);
  }

  .avatar-frame {
    border-color: var(--c-border);
    background-color: var(--c-bg-soft);
  }
}
</style>

<style>
.dark .tippy-box {
  background-color: var(--c-bg-2);

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

添加留言板页面

在 /app/pages/新建 comments.vue

<script setup lang="ts">
const title = '留言板'
const description = '您可以在此畅所欲言,当然,请遵守互联网相关的法律法规'
const image = 'https://bu.dusays.com/2025/10/21/68f7083fcc594.webp'
useSeoMeta({ title, description, ogImage: image })
</script>

<template>
<template #aside>
	<WidgetBlogStats />
	<WidgetBlogTech />
	<WidgetBlogLog />
</template>

<ZPageBanner :title :description :image />

<div class="page-comments">
  <Quote>{{ description }}</Quote>

  <Quote icon="tabler:files">
    如果您有什么想说的、想问的,或者发现了本站的小问题🐞欢迎留言
  </Quote>
  
  <Quote icon="tabler:link">
    需要添加友链?请移步<ProseA :href="'/link'" class="link-text">友链</ProseA>页面
  </Quote>
</div>

<PostComment />
</template>

<style scoped>
.page-comments {
  animation: float-in .2s backwards;
  margin: 1rem;
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.5rem;

  .quote {
    max-width: 700px;
    width: 100%;
    color: var(--c-text-2);
    font-size: 1.2rem;
    font-weight: var(--font-weight-medium);
    line-height: 1.5;
    text-align: center;
  }
}

.link-text {
  color: inherit;
  transition: background-size 0.3s ease;

  &:hover {
    color: var(--c-primary);
    text-decoration: none;
    background: linear-gradient(var(--c-primary-soft), var(--c-primary-soft)) no-repeat bottom/100% 30%;
  }
}
</style>

侧边栏

如何添加自定义侧边栏

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

import {
    LazyWidgetBlogLog,
    LazyWidgetBlogStats,
    LazyWidgetBlogTech,
    LazyWidgetCommGroup,
    LazyWidgetEmpty,
    LazyWidgetGithubCard,
    LazyWidgetToc,
    LazyWidgetLoveWall, // 恋爱墙
} from '#components'
import { pascal } from 'radash'

// @keep-sorted
const rawWidgets = {
    LazyWidgetBlogLog,
    LazyWidgetBlogStats,
    LazyWidgetBlogTech,
    LazyWidgetCommGroup,
    LazyWidgetEmpty,
    LazyWidgetGithubCard,
    LazyWidgetToc,
    LazyWidgetLoveWall, // 恋爱墙
}

在你需要添加的page页面修改对应的vue,/app/pages/index.vue是文章首页.

// 修改下列参数,'WidgetBlogStats'在上方就是上方'WidgetLoveWall'则是最底下,可自行调整侧边栏顺序
<template #aside>
    <WidgetBlogStats />
    <WidgetBlogTech />
    <WidgetBlogLog />
    <WidgetCommGroup />
    <WidgetLoveWall /> // 恋爱墙
</template>

添加访客欢迎显示

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

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

// 类型定义
interface IPLocation {
	ip: string
	beginip: string
	endip: string
	region: string
	isp: string
	asn: string
	llc: string
	latitude: number
	longitude: number
}

// 常量配置
const CACHE_KEY = 'visitor_region_info_cache'
const CACHE_DURATION = 10 * 60  * 1000
const API_URL = 'https://uapis.cn/api/v1/network/myip'
const REFERENCE_LNG = 120.3074357
const REFERENCE_LAT = 31.4933074
const EARTH_RADIUS = 6371
const DEFAULT_WELCOME_TEXT = '欢迎客官来到本站'

// 欢迎语映射数据
const provinceWelcomeMap: Record<string, string> = {
	北京市: '北——京——欢迎你~~~',
	天津市: '讲段相声吧',
	河北: '燕赵大地,英雄辈出的河北,等你探索!',
	山西: '展开坐具长三尺,已占山河五百余',
	内蒙古自治区: '草原辽阔的内蒙古,等你来策马奔腾。',
	辽宁: '我想吃烤鸡架!',
	吉林: '状元阁就是东北烧烤之王',
	黑龙江: '很喜欢哈尔滨大剧院',
	上海市: '走在外滩,感受历史与现代的交融。',
	江苏: '水乡泽国,江南佳丽地。',
	浙江: '这里是浙江,充满江南的韵味!',
	河南: '这里是河南,历史悠久文化灿烂。',
	安徽: '安徽山水,黄山、九华山欢迎你。',
	福建: '福建山水如画,美景无处不在。',
	江西: '落霞与孤鹜齐飞,秋水共长天一色',
	山东: '山东好客,欢迎来感受齐鲁文化!',
	湖北: '湖北,长江中游的明珠,风景秀丽。',
	湖南: '湖南,烟雨迷蒙的湘江流过这片土地。',
	广东: '带你感受广东的热情与美食!',
	广西壮族自治区: '广西山清水秀,民俗风情浓郁。',
	海南: '阳光、沙滩、椰风海韵,欢迎来海南度假。',
	四川: '来四川,品麻辣火锅,赏壮丽山河。',
	贵州: '来贵州,品茅台,赏黄果树瀑布。',
	云南: '云南风景独特,风情万种。',
	西藏自治区: '西藏,神秘而纯净,等待你的探索。',
	陕西: '陕西,历史与文化的交汇之地。',
	甘肃: '甘肃,丝绸之路的重要节点。',
	青海: '青海,湖泊与草原的美丽结合。',
	宁夏回族自治区: '宁夏,塞上江南,黄河流经的美丽地方。',
	新疆维吾尔自治区: '新疆的城市各具特色,等待你的探索。',
	台湾: '我在这头,大陆在那头',
	香港特别行政区: '永定贼有残留地鬼嚎,迎击光非岁玉',
	澳门特别行政区: '性感荷官,在线发牌',
}

// 城市特定欢迎语映射
const cityWelcomeMap: Record<string, Record<string, string>> = {
	北京市: {
		北京市: '北——京——欢迎你~~~',
	},
	上海市: {
		上海市: '走在外滩,感受历史与现代的交融。',
	},
	广东: {
		广州市: '看小蛮腰,喝早茶了嘛~',
		深圳市: '今天你逛商场了嘛~',
		珠海市: '浪漫之城珠海,海风轻拂。',
		东莞市: '东莞,制造业之都,经济活跃。',
		佛山市: '佛山,武术之乡,陶瓷文化深厚。',
	},
	浙江: {
		杭州市: '西湖美景,三月天~',
		宁波市: '来宁波,感受大海的气息。',
		温州市: '温州人杰地灵,商贸繁荣。',
		绍兴市: '绍兴,酒乡文化,古韵悠长。',
		湖州市: '湖州,太湖之滨,风景如画。',
	},
	四川: {
		成都市: '宽窄巷子,成都慢生活。',
		绵阳市: '享受科技城的宁静与创新。',
		自贡市: '自贡的盐文化与灯会,独具魅力。',
		德阳市: '德阳,历史悠久,文化底蕴深厚。',
		乐山市: '乐山大佛,世界文化遗产。',
	},
	福建: {
		厦门市: '鼓浪屿听海,厦门美食让人流连忘返。',
		福州市: '有福之州,来此感受千年古城。',
		泉州市: '泉州,海上丝绸之路的起点。',
		漳州市: '漳州,古城文化与美食的结合。',
		南平市: '南平,武夷山的自然风光。',
	},
	山东: {
		青岛市: '来青岛喝啤酒,看大海吧!',
		济南市: '泉城济南,四面荷花三面柳。',
		烟台市: '烟台的葡萄酒与海鲜,令人陶醉。',
		潍坊市: '潍坊,风筝之都,文化底蕴深厚。',
		德州市: '德州,扒鸡闻名,文化悠久。',
	},
	江苏: {
		南京市: '六朝古都南京,历史与现代的碰撞。',
		苏州市: '来苏州,感受园林之美。',
		无锡市: '无锡太湖美景,灵山大佛令人心旷神怡。',
		常州市: '常州,文化与科技的交汇点。',
		南通市: '南通,海门潮涌,文化底蕴深厚。',
	},
	河北: {
		石家庄市: '燕赵大地,英雄辈出的河北,等你探索!',
	},
	河南: {
		郑州市: '中原大地,郑州是交通枢纽与历史重镇。',
		洛阳市: '千年古都洛阳,牡丹花开的城。',
		开封市: '开封,古都文化与美食的汇聚地。',
		新乡市: '新乡,历史悠久,文化底蕴深厚。',
		焦作市: '焦作,云台山的自然风光。',
	},
	湖南: {
		长沙市: '热辣长沙,吃小龙虾逛黄兴路步行街。',
		岳阳市: '岳阳楼,洞庭湖的美景尽收眼底。',
		株洲市: '株洲,火车制造业的发源地。',
		湘潭市: '湘潭,伟人故里,文化底蕴深厚。',
	},
	湖北: {
		武汉市: '来大武汉,过长江大桥,吃热干面!',
		宜昌市: '三峡大坝,壮丽的自然奇观。',
		荆州市: '荆州,历史文化名城,古韵悠长。',
		襄阳市: '襄阳,古城文化与美食的结合。',
	},
	安徽: {
		合肥市: '创新之城合肥,科教文化汇聚地。',
		黄山市: '黄山,天下第一奇山,风景如画。',
		芜湖市: '芜湖,长江之畔,文化底蕴深厚。',
		马鞍山市: '马鞍山,文化与自然的完美结合。',
	},
	广西壮族自治区: {
		桂林市: '桂林山水甲天下,风景如画。',
		南宁市: '绿城南宁,宜居宜游。',
		柳州市: '柳州的螺蛳粉,独具风味。',
		防城港市: '防城港,海洋资源丰富,风景迷人。',
	},
	贵州: {
		贵阳市: '贵阳,山城之美,民族风情浓郁。',
		遵义市: '遵义,红色之城,历史悠久。',
		安顺市: '安顺,黄果树瀑布的故乡,风景如画。',
		毕节市: '毕节,拥有丰富的自然资源与人文景观。',
		六盘水市: '六盘水,凉爽的夏天,避暑胜地。',
		铜仁市: '铜仁,秀美的山水与独特的民族文化。',
		凯里市: '凯里,苗族文化的发源地,风情独特。',
	},
	云南: {
		昆明市: '春城昆明,四季如春,风景秀丽。',
		大理市: '苍山洱海,大理古城,你来了就不想走。',
		丽江市: '丽江古城,纳西文化的瑰宝。',
		西双版纳傣族自治州: '西双版纳,热带雨林的奇妙之地。',
	},
	西藏自治区: {
		拉萨市: '拉萨,西藏的首府,布达拉宫的故乡。',
		日喀则市: '日喀则,历史悠久的文化名城。',
		林芝市: '林芝,素有"西藏江南"之称,风景如画。',
		昌都市: '昌都,历史悠久,文化底蕴深厚。',
		山南市: '山南,藏文化的发源地之一。',
		那曲市: '那曲,草原风光,牧民生活的地方。',
		阿里地区: '阿里,神秘的西部,拥有壮丽的自然景观。',
	},
	新疆维吾尔自治区: {
		乌鲁木齐市: '乌鲁木齐,天山脚下的城市,文化多元。',
		喀什市: '喀什,古丝绸之路的重要节点,历史悠久。',
		克拉玛依市: '克拉玛依,石油之城,经济发展迅速。',
		吐鲁番市: '吐鲁番,火焰山的故乡,葡萄之乡。',
		哈密市: '哈密,哈密瓜的发源地,风景如画。',
		博乐市: '博乐,草原风光,民族文化交融。',
		阿克苏市: '阿克苏,苹果之乡,风景秀丽。',
		和田市: '和田,玉石之乡,历史文化深厚。',
	},
	内蒙古自治区: {
		呼和浩特市: '呼和浩特,内蒙古的首府,历史悠久。',
		包头市: '包头,钢铁之城,经济发展迅速。',
		乌兰察布市: '乌兰察布,草原文化与现代城市的结合。',
		赤峰市: '赤峰,拥有丰富的自然资源与人文景观。',
		通辽市: '通辽,草原文化的发源地,风情独特。',
		鄂尔多斯市: '鄂尔多斯,现代化城市与草原文化的交融。',
		巴彦淖尔市: '巴彦淖尔,黄河之畔,风景如画。',
		锡林郭勒盟: '锡林郭勒,草原辽阔,马背上的民族风情。',
	},
	陕西: {
		西安市: '西安,古都文化与兵马俑的故乡。',
		咸阳市: '咸阳,历史悠久,文化底蕴深厚。',
	},
	甘肃: {
		兰州市: '兰州,黄河之滨,牛肉面闻名。',
		天水市: '天水,历史悠久,文化底蕴深厚。',
	},
	青海: {
		西宁市: '西宁,青海湖的门户,风景如画。',
	},
	吉林: {
		长春市: '长春,汽车城,文化底蕴深厚。',
		吉林市: '吉林市,松花江畔,风景如画。',
	},
	黑龙江: {
		哈尔滨市: '哈尔滨,冰雪之城,俄罗斯风情浓厚。',
		齐齐哈尔市: '齐齐哈尔,黑龙江的明珠,文化底蕴深厚。',
	},
	山西: {
		太原市: '太原,山西会,历史悠久。',
	},
	辽宁: {
		沈阳市: '沈阳,重工业基地,文化底蕴深厚。',
		大连市: '大连,北方明珠,浪漫之都。',
	},
	海南: {
		海口市: '海口,阳光沙滩,椰风海韵。',
		三亚市: '三亚,碧海蓝天,度假胜地。',
	},
}

// 国家欢迎语映射
const countryWelcomeMap: Record<string, string> = {
	日本: 'よろしく,一起去看樱花吗',
	美国: 'Let us live in peace!',
	英国: '想同你一起夜乘伦敦眼',
	俄罗斯: '干了这瓶伏特加!',
	法国: 'C\'est La Vie',
	德国: 'Die Zeit verging im Fluge.',
	澳大利亚: '一起去大堡礁吧!',
	加拿大: '拾起一片枫叶赠予你',
	韩国: '一起去首尔购物吧!',
	新加坡: '花园城市新加坡,欢迎你!',
	印度: '探索神秘的咖喱之国!',
	巴西: '一起感受桑巴的热情!',
	意大利: '罗马假日,浪漫之旅!',
	西班牙: '弗拉门戈的舞步等你来!',
	荷兰: '风车与郁金香的国度!',
	瑞士: '阿尔卑斯山的雪景等你欣赏!',
	新西兰: '霍比屯的中土世界!',
	泰国: '萨瓦迪卡,一起去海滩吧!',
	马来西亚: '多元文化,美食天堂!',
	印度尼西亚: '千岛之国,潜水胜地!',
	菲律宾: '椰风海韵,度假天堂!',
	越南: '河内风情,岘港之美!',
	土耳其: '热气球之旅,卡帕多奇亚!',
	阿联酋: '奢华迪拜,沙漠奇迹!',
	沙特阿拉伯: '麦加朝圣,沙漠王国!',
	埃及: '金字塔,法老的呼唤!',
	南非: '彩虹之国,野生动物的天堂!',
	肯尼亚: '动物大迁徙,东非草原!',
	尼日利亚: '西非明珠,活力之国!',
	墨西哥: '玛雅文明,亡灵之城!',
	阿根廷: '探戈与足球的故乡!',
	智利: '复活节岛,葡萄酒之乡!',
	哥伦比亚: '咖啡之国,南美风情!',
	秘鲁: '马丘比丘,印加帝国!',
	希腊: '爱琴海畔,古典文明!',
	葡萄牙: '大航海时代的荣光!',
	比利时: '巧克力与啤酒的国度!',
	奥地利: '音乐之都,维也纳之声!',
	捷克: '布拉格广场上的钟声!',
	波兰: '肖邦的故乡,琥珀之路!',
	瑞典: '诺贝尔的故乡,北欧设计!',
	挪威: '峡湾与极光的胜地!',
	丹麦: '童话王国的魅力!',
	芬兰: '千湖之国,极光之地!',
	冰岛: '冰与火之国,温泉与瀑布!',
	爱尔兰: '绿岛之歌,威士忌之乡!',
	匈牙利: '多瑙河畔,温泉之国!',
	罗马尼亚: '德古拉城堡,特兰西瓦尼亚!',
	乌克兰: '黑海沿岸,葵花之乡!',
	哈萨克斯坦: '丝绸之路,中亚明珠!',
	巴基斯坦: '喜马拉雅的风光!',
	孟加拉国: '恒河三角洲,孟加拉湾!',
	斯里兰卡: '印度洋上的珍珠!',
	尼泊尔: '徒步天堂,雪山之国!',
	阿富汗: '兴都库什的呼唤!',
	伊朗: '波斯帝国的辉煌!',
	伊拉克: '两河流域,文明古国!',
	以色列: '圣城耶路撒冷!',
	约旦: '佩特拉古城,死海漂浮!',
	黎巴嫩: '中东小巴黎,美食天堂!',
	卡塔尔: '石油王国,沙漠明珠!',
	科威特: '石油之都,海湾风情!',
	巴林: '波斯湾的金融中心!',
	阿曼: '阿拉伯半岛的绿色角落!',
	厄瓜多尔: '赤道之国,亚马逊雨林!',
	乌拉圭: '南美瑞士,足球强国!',
	巴拉圭: '伊瓜苏瀑布的故乡!',
	委内瑞拉: '石油之国,加拉加斯!',
	玻利维亚: '天空之镜,乌尤尼盐沼!',
	巴拿马: '运河之都,连接两洋!',
	哥斯达黎加: '中美洲瑞士,生态旅游!',
	古巴: '加勒比海的雪茄与朗姆!',
	牙买加: '雷鬼音乐,蓝山咖啡!',
	多米尼加: '加勒比海度假胜地!',
	马达加斯加: '狐猴之岛,独一无二!',
	坦桑尼亚: '塞伦盖蒂,动物王国!',
	加纳: '黄金海岸,可可之国!',
	喀麦隆: '非洲缩影,文化熔炉!',
	摩洛哥: '摩洛哥风情,撒哈拉沙漠!',
	突尼斯: '迦太基古城,地中海明珠!',
}

// 响应式数据
const userLocation = shallowRef<IPLocation | null>(null)
const welcomeText = ref(DEFAULT_WELCOME_TEXT)
const distance = ref<number | null>(null)
const loading = ref(true)
const errorMessage = ref<string | null>(null)

// 计算属性
const locationDescription = computed(() => {
	if (!userLocation.value?.region)
		return '未知位置'

	const regionParts = userLocation.value.region.split(' ')
	const country = regionParts[0] || ''
	const province = regionParts[1] || ''
	const city = regionParts[2] || ''

	if (country === '中国') {
		return province === city ? province : `${province} ${city}`
	}
	else {
		return country
	}
})

// 时间段问候语
const timeGreeting = computed(() => {
	const hour = new Date().getHours()

	if (hour >= 5 && hour < 11)
		return '🌤️ 早上好,一日之计在于晨!'
	if (hour >= 11 && hour < 13)
		return '☀️ 中午好,记得午休喔~'
	if (hour >= 13 && hour < 17)
		return '🕞 下午好,饮茶先啦!'
	if (hour >= 17 && hour < 19)
		return '🚶‍♂️ 即将下班,记得按时吃饭~'
	if (hour >= 19 && hour < 24)
		return '🌙 晚上好,夜生活嗨起来!'
	return '夜深了,早点休息,少熬夜哦~'
})

function getDistance(lng1: number, lat1: number, lng2: number, lat2: number): number {
	const dLat = (lat2 - lat1) * Math.PI / 180
	const dLng = (lng2 - lng1) * Math.PI / 180
	const a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
		+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180)
		* Math.sin(dLng / 2) * Math.sin(dLng / 2)
	const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
	return Math.round(EARTH_RADIUS * c)
}

// 缓存管理
function getIpInfoFromCache(): IPLocation | null {
	try {
		const cached = localStorage.getItem(CACHE_KEY)
		if (!cached)
			return null

		const { data, timestamp } = JSON.parse(cached)
		if (Date.now() - timestamp > CACHE_DURATION) {
			localStorage.removeItem(CACHE_KEY)
			return null
		}
		return data
	}
	catch (error) {
		console.error('读取缓存失败:', error)
		return null
	}
}

function setIpInfoToCache(data: IPLocation) {
	try {
		localStorage.setItem(CACHE_KEY, JSON.stringify({
			data,
			timestamp: Date.now(),
		}))
	}
	catch (error) {
		console.error('保存缓存失败:', error)
	}
}

// 业务逻辑
// 根据用户位置设置欢迎语
function setWelcomeText(location: IPLocation) {
	if (!location?.region)
		return

	const regionParts = location.region.split(' ')
	const country = regionParts[0] || ''
	const province = regionParts[1] || ''
	const city = regionParts[2] || ''

	if (country === '中国') {
		// 优先使用城市特定欢迎语,否则使用份默认欢迎语
		welcomeText.value = cityWelcomeMap[province]?.[city]
			|| provinceWelcomeMap[province]
			|| '带我去你的城市逛逛吧!'
	}
	else {
		// 其他国家的欢迎语
		welcomeText.value = countryWelcomeMap[country]
			|| '带我去你的国家逛逛吧'
	}
}

// 计算用户与博主位置的距离
function calculateDistance(data: IPLocation) {
	try {
		const { latitude, longitude } = data

		// 检查经纬度是否有效
		if (latitude && longitude) {
			distance.value = getDistance(REFERENCE_LNG, REFERENCE_LAT, longitude, latitude)
		}
		else {
			distance.value = null
		}
	}
	catch (error) {
		console.error('计算距离失败:', error)
		distance.value = null
	}
}

// 获取用户IP位置信息
async function fetchIPLocation() {
	// 重置状态
	loading.value = true
	errorMessage.value = null

	try {
		// 1. 尝试从缓存获取数据
		const cachedData = getIpInfoFromCache()
		if (cachedData) {
			userLocation.value = cachedData
			setWelcomeText(cachedData)
			calculateDistance(cachedData)
			return
		}

		// 2. 从API获取新数据
		const controller = new AbortController()
		const timeoutId = setTimeout(() => controller.abort(), 3000)

		const response = await fetch(API_URL, { signal: controller.signal })

		clearTimeout(timeoutId)

		if (!response.ok) {
			throw new Error(`HTTP错误! 状态码: ${response.status}`)
		}

		const data: IPLocation = await response.json()

		// 处理响应数据
		if (data.ip && data.region) {
			// 保存数据到缓存
			setIpInfoToCache(data)

			// 更新组件状态
			userLocation.value = data
			setWelcomeText(data)
			calculateDistance(data)
		}
	}
	catch (error) {
		// 错误处理
		if (error instanceof Error) {
			if (error.name === 'AbortError') {
				errorMessage.value = '请求超时,请稍后重试'
			}
			else if (error.message.includes('Network')) {
				errorMessage.value = '网络连接失败'
			}
			else {
				errorMessage.value = error.message
			}
		}
		else {
			errorMessage.value = '获取IP位置信息失败'
		}

		console.error('获取IP位置信息失败:', error)

		welcomeText.value = DEFAULT_WELCOME_TEXT
	}
	finally {
		loading.value = false
	}
}

// 组件挂载时初始化
onMounted(() => {
	fetchIPLocation()
})
</script>

<template>
<BlogWidget card title="访客信息">
	<div v-if="loading" class="loading">
		<div class="loading-spinner" />
		<span>获取位置信息中...</span>
	</div>

	<div v-else-if="errorMessage" class="error">
		<div class="error-icon">
			😕
		</div>
		<p>{{ errorMessage }}</p>
		<ZButton text="重试" class="retry-btn" @click="fetchIPLocation" />
	</div>

	<div v-else class="welcome-message">
		<div class="message-content">
			<p>欢迎来自 <b>{{ locationDescription }}</b> 的小友💖</p>
			<p v-if="distance">
				当前位置距博主约 <b>{{ distance }}</b> 公里!
			</p>
			<p><b>{{ timeGreeting }}</b></p>
			<p>Tip:<b>{{ welcomeText }}</b></p>
		</div>
	</div>
</BlogWidget>
</template>

<style scoped>
.widget-title {
	color: var(--c-primary);
}

.loading, .error {
	padding: 1rem;
	text-align: center;
	color: var(--c-text-2);
}

.welcome-message {
	padding: 0.5rem;

	.message-content {
		line-height: 1;

		p {
			margin-bottom: 0.3rem;

			&:last-child {
				margin-bottom: 0;
			}

			b {
				color: var(--c-primary);
			}
		}
	}
}

.loading {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	gap: 0.8rem;

	.loading-spinner {
		width: 40px;
		height: 40px;
		border: 3px solid rgb(0 0 0 / 10%);
		border-top: 3px solid var(--c-primary);
		border-radius: 50%;
		animation: spin 1s linear infinite;
	}

	@keyframes spin {
		0% { transform: rotate(0deg); }
		100% { transform: rotate(360deg); }
	}
}

.error {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	gap: 0.5rem;

	.error-icon {
		margin-bottom: 0.5rem;
		font-size: 2rem;
	}
}

.retry-btn {
	margin-top: 0.5rem;

	&:hover,
	&:active {
		color: var(--c-primary);
	}
}
</style>

最新评论

在 /app/components/widget/新建 LatestComments.vue

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

// Twikoo配置
const TWIKOO_URL = 'https://twikoo地址'
const ADMIN_MAIL_MD5 = 'twikoo评论管理邮箱的SHA256值'

//// 评论数据状态
const latestComments = ref({
  comments: [],
  loading: true,
  error: false,
  lastFetchTime: 0
})

const comments = computed(() => latestComments.value.comments)
const loading = computed(() => latestComments.value.loading)
const error = computed(() => latestComments.value.error)

// 清理评论内容,移除HTML标签
function cleanContent(content: string): string {
  if (!content) return ''
  
  const replacements: [RegExp, string][] = [
    [/<pre><code>[\s\S]*?<\/code><\/pre>/g, '[代码块]'],
    [/<code>([^<]{4,})<\/code>/g, '[代码]'],
    [/<code>([^<]{1,3})<\/code>/g, '$1'],
    [/<img(?![^>]*class="tk-owo-emotion")[^>]*>/g, '[图片]'],
    [/<a[^>]*?>[\s\S]*?<\/a>/g, '[链接]'],
    [/<(?!img[^>]*class="tk-owo-emotion")[^>]+>/g, '']
  ]
  
  return replacements.reduce((text, [regex, replacement]) => 
    text.replace(regex, replacement), content
  ).trim()
}

// 获取最新评论
async function fetchLatestComments() {
  const now = Date.now()
  // 10分钟内不重复请求
  if (now - latestComments.value.lastFetchTime < 10 * 60 * 1000) {
    return
  }
  
  try {
    latestComments.value.loading = true
    latestComments.value.error = false
  
    const response = await fetch(TWIKOO_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        event: 'GET_RECENT_COMMENTS',
        includeReply: true,
        pageSize: 10
      }),
      timeout: 5000
    })
  
    const data = await response.json()
  
    if (!data.data) {
      throw new Error('No data')
    }
  
    // 处理评论数据
    latestComments.value.comments = data.data
      .filter((comment: any) => comment.url !== '/')
      .slice(0, 5)
      .map((comment: any) => ({
        content: cleanContent(comment.comment),
        author: comment.nick,
        date: toZonedTemporal(new Date(comment.created).toISOString()).toString(),
        avatar: comment.avatar,
        isAdmin: comment.mailMd5 === ADMIN_MAIL_MD5,
        url: comment.url,
        id: comment.id
      }))
  
    latestComments.value.lastFetchTime = now
  } catch (err) {
    console.error('Failed to fetch comments:', err)
    latestComments.value.error = true
  } finally {
    latestComments.value.loading = false
  }
}

// 导航到评论
function navigateToComment(comment: any) {
  window.open(`${comment.url}#${comment.id}`, '_self')
}

// 生命周期钩子
let interval: number | null = null

onMounted(() => {
  fetchLatestComments()
  // 每10分钟刷新一次
  interval = window.setInterval(fetchLatestComments, 10 * 60 * 1000)
})

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

<template>
<BlogWidget card title="最新评论">
  <div class="comments-container">
    <transition name="fade" mode="out-in">
      <template v-if="loading">
        <div class="loading">
          <div class="loading-spinner"></div>
          <p>加载中...</p>
        </div>
      </template>
  
      <template v-else-if="error">
        <div class="error">
          <span class="error-icon">⚠️</span>
          <span>评论加载失败</span>
        </div>
      </template>
  
      <template v-else>
        <ul class="comments-list">
          <li 
            v-for="comment in comments" 
            :key="comment.id"
            class="comment-item"
            @click="navigateToComment(comment)"
          >
            <div class="comment-meta">
              <div class="author-info">
                <img :src="comment.avatar" :alt="comment.author" class="avatar" />
                <span class="author">{{ comment.author }}</span>
                <Icon v-if="comment.isAdmin" class="admin-badge" name="i-material-symbols:verified" />
              </div>
              <UtilDate class="date" :date="comment.date" month="2-digit" day="numeric" />
            </div>
            <p
              class="comment-content"
              :innerHTML="comment.content"
              v-tip="{ content: '点击查看完整评论', placement: 'top-end' }"
            ></p>
          </li>
        </ul>
      </template>
    </transition>
  </div>
</BlogWidget>
</template>

<style scoped>
.comments-container {
  min-height: 343px;
  position: relative;
}

.loading,
.error {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  color: var(--c-text-2);
  gap: 8px;
  text-align: center;
}

.loading-spinner {
  width: 40px;
  height: 40px;
  border: 3px solid var(--c-bg-3);
  border-top-color: var(--c-primary);
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 0.5rem;
}

.error-icon {
  font-size: 3rem;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.comments-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.comment-item {
  border-bottom: 1px solid var(--c-border);
  cursor: pointer;
  padding: 0.5rem 0;
  
  &:last-child {
    border-bottom: none;
  }
}

.comment-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 0.8em;
  color: var(--c-text-2);
  margin-bottom: 0.3rem;
}

.author-info {
  display: flex;
  align-items: center;
  gap: 6px;
}

.avatar {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  object-fit: cover;
}

.author {
  color: var(--c-text-1);
  font-weight: 500;
}

.admin-badge {
  color: var(--c-primary);
  font-size: 1.1em;
}

.date {
  color: var(--c-text-3);
}

.comment-content {
  background-color: var(--c-bg-1);
  border-radius: 4px;
  color: var(--c-text-2);
  font-size: 0.9em;
  overflow: hidden;
  padding: 6px 10px !important;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 0.2s;
  margin: 0;
  
  &:hover {
    color: var(--c-text-1);
  }
  
  img.tk-owo-emotion {
    height: 16px;
    margin: 0 5px;
    vertical-align: text-bottom;
    width: 16px;
  }
}

/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

修改TOC

<script setup lang="ts">
import type { TocLink } from '@nuxt/content'

const [DefineTemplate, ReuseTemplate] = createReusableTemplate({
	props: {
		tocTree: { type: Array as PropType<TocLink[]> },
	},
})

const { toc } = useArticle()
const scrollableEl = useTemplateRef('toc')
const { tocOffsets, activeHeadingId } = useToc(toc, computed(() => scrollableEl.value?.body))

function hasHeading(tocTree: TocLink, heading?: string): boolean {
	return tocTree.id === heading || !!tocTree.children?.some(child => hasHeading(child, heading))
}
</script>

<template>
<BlogWidget
	ref="toc"
	title="文章目录"
	shrink
	:style="{ minHeight: `clamp(4rem, ${tocOffsets.length}rem, 20rem)` }"
>
	<template #action>
		<div class="toc-actions">
			<!-- use <a> for anchor -->
			<a href="#main-content" aria-label="返回开头">
				<Icon name="tabler:arrow-up-circle" />
			</a>

			<a href="#twikoo" aria-label="评论区">
				<Icon name="tabler:message-circle" />
			</a>
		</div>
	</template>

	<!-- 放在顶层会导致 Transition 失效 -->
	<DefineTemplate v-slot="{ tocTree }">
		<ol>
			<li
				v-for="(entry, index) in tocTree"
				:key="index"
				:class="{
					'has-active': hasHeading(entry, activeHeadingId),
					'active': entry.id === activeHeadingId,
				}"
			>
				<!-- 使用 <a> 确保键盘焦点切换 -->
				<a :href="`#${entry?.id}`" :title="entry.text">{{ entry.text }}</a>
				<ReuseTemplate v-if="entry.children" :toc-tree="entry.children" />
			</li>
		</ol>
	</DefineTemplate>

	<ReuseTemplate
		v-if="toc?.links.length"
		class="toc"
		:toc-tree="toc.links"
	/>
	<p v-else class="no-toc">
		暂无目录信息
	</p>
</BlogWidget>
</template>

<style scoped>
.toc {
	position: relative;

	&::before {
		content: "";
		position: absolute;
		inset: 0.3rem;
		width: 3px;
		border-radius: 1rem;
		background-color: var(--c-bg-soft);
	}
}

ol {
	padding-inline-start: 0.8rem;
}

li {
	opacity: 0.6;
	font-size: 0.94em;
	color: var(--c-text);
	transition: opacity 0.2s;

	&:hover {
		opacity: 0.94;
	}

	&.has-active, &.active {
		opacity: 1;
		font-size: 1em;
	}

	&.active::before {
		content: "";
		position: absolute;
		inset-inline-start: 0.3rem;
		margin: 0.2rem 0;
		padding: 0.6rem 1.5px;
		border-radius: 1rem;
		background-color: var(--c-primary);
	}

	> a {
		display: block;
		overflow: hidden;
		padding: 0.2em 0.5em;
		border-radius: 0.5em;
		white-space: nowrap;
		text-overflow: ellipsis;
		transition: all 0.2s;

		&:hover {
			background-color: var(--c-bg-soft);
		}
	}
}

.no-toc {
	padding: 1em;
	text-align: center;
	color: var(--c-text-3);
}

.toc-actions {
	display: flex;
	flex-direction: row;
	align-items: center;
	gap: 0.4rem;
	margin-left: auto;
}

.toc-actions a {
	position: relative;
	border: 0;
	background: transparent;
	color: var(--c-text);
	width: 2.2rem;
	height: 2.2rem;
	padding: 0;
	border-radius: 999px;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	cursor: pointer;
	outline: none;
	box-shadow: inset 0 0 0 1px var(--c-border);
	transition: all 0.2s;
}

.toc-actions a:hover {
	color: var(--c-primary);
	box-shadow: inset 0 0 0 1px var(--c-primary);
}

.toc-actions a svg {
	position: relative;
	z-index: 1;
}
</style>

文章footer

添加分享功能

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

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

defineOptions({ inheritAttrs: false })
const props = defineProps<ArticleProps>()

const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
	title: string
}>({ inheritAttrs: false })

const appConfig = useAppConfig()
const title = `${props.title} | ${appConfig.title}`
const href = new URL(props.path!, appConfig.url).href
const { copy, copied } = useCopy(href)
</script>

<template>
<div class="post-footer">
	<DefineTemplate v-slot="{ $slots, title }">
		<section>
			<div class="title text-creative">
				{{ title }}
			</div>

			<div class="content">
				<component :is="$slots.default" />
			</div>
		</section>
	</DefineTemplate>

	<ReuseTemplate v-if="references" title="参考链接">
		<ul>
			<li v-for="{ title, link }, i in references" :key="i">
				<ProseA :href="link || ''">
					{{ title ?? link }}
				</ProseA>
			</li>
		</ul>
	</ReuseTemplate>

	<ReuseTemplate :title="meta?.slots?.copyright?.props?.title as string || '许可协议'">
		<ContentRenderer v-if="meta?.slots?.copyright" :value="meta?.slots?.copyright" />
		<p v-else>
			本文采用 <ProseA :href="appConfig.copyright.url">
				{{ appConfig.copyright.name }}
			</ProseA>
			许可协议,转载请注明出处。
		</p>
	</ReuseTemplate>

	<section class="share">
		<div class="title text-creative">
			分享文章
		</div>

		<div class="content">
			<ZButton
				class="share-button"
				icon="ri:qq-line"
				v-tip="'QQ'"
				:to="`https://connect.qq.com/widget/shareqq/index.html?title=${encodeURIComponent(title)}&url=${encodeURIComponent(href)}`"
			/>
			<ZButton
				class="share-button"
				icon="ri:weibo-fill"
				v-tip="'微博'"
				:to="`https://service.weibo.com/share/share.php?title=${encodeURIComponent(title)}&url=${encodeURIComponent(href)}`"
			/>
			<ZButton
				class="share-button"
				icon="tabler:mail"
				v-tip="'邮件'"
				:to="`mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(href)}`"
			/>
			<ZButton
				class="share-button"
				icon="tabler:link"
				v-tip="{
					content: copied ? '已复制链接' : '复制链接',
					hideOnClick: false
				}"
				@click="copy()"
			/>
		</div>
	</section>
</div>
</template>

<style scoped>
.post-footer {
	margin: 2rem 0.5rem;
	border: 1px solid var(--c-border);
	border-radius: 1rem;
	background-color: var(--c-bg-2);
}

section {
	padding: 1rem;

	& + section {
		border-top: 1px solid var(--c-border);
	}
}

.title {
	font-weight: bold;
	color: var(--c-text);
}

.content {
	margin-top: 0.5em;
	font-size: 0.9rem;

	li {
		margin: 0.5em 0;
	}
}

.share-button {
	display: inline-flex;
	aspect-ratio: 1;
	border-radius: 50%;
	border: 1px solid var(--c-border);
	box-shadow: none;
}
</style>

添加打赏功能

在 /app/components/post/新建 Donation.vue

<script setup lang="ts">
const { donation } = useAppConfig()
</script>

<template>
<div class="donation" v-if="donation?.enable">
  <Tooltip :delay="200" interactive hide-on-click="toggle" max-width="">
    <ZButton class="donate-button" icon="tabler:heart-filled" text="赞赏作者" />
    <template #content>
      <div class="donation-content">
        <div class="donation-list" v-if="Object.keys(donation.items).length">
          <figure class="donation-item" v-for="(image, label) in donation.items">
            <UtilImg class="image" width="160" height="160" :src="image" />
            <figcaption class="label">{{ label }}</figcaption>
          </figure>
        </div>
        <p class="donation-message" v-if="donation.message">{{ donation.message }}</p>
      </div>
    </template>
  </Tooltip>
</div>
</template>

<style scoped>
.donation {
  display: flex;
  justify-content: center;

  .donate-button {
    padding: .6rem .8rem;
    border: 1px solid var(--c-border);
    box-shadow: none;
  }

  .donation-content {
    text-align: center;
    padding: .5rem .6rem;

    .donation-list {
      display: flex;
      padding: .5rem 0;
      gap: 1.5rem;
      justify-content: center;
      flex-wrap: wrap;
    }

    .donation-item {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: .5rem;

      .image {
        border-radius: .5rem;
      }

      .label {
        color: var(--c-text-2);
      }
    }

    .donation-message {
      color: var(--c-text-1);
    }
  }

  :deep([data-tippy-root]) {
    max-width: calc(100% - 1rem);

    .tippy-box {
      border: 1px solid var(--c-border);
      background-color: var(--c-bg-2);
    }

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

修改 /app/pages/[...slug].vue

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

修改 blog.config.ts

    /** 禁止搜索引擎收录的路径 */
	robotsNotIndex: ['/preview', '/previews/*'],
},

/** 赞赏配置 */
donation: {
    enable: true,
    message: '感谢您的支持,这将激励我创作更多优质内容!',
    items: {
      "微信支付": 'https://example.com/wechat-pay.png',
      "支付宝": 'https://example.com/alipay.png',
      "xxx": 'https://example.com/xxx.png'
    }
},

/** 博客 Atom 订阅源 */

指定页面关闭评论、赞赏、分享、功能

按照如下代码修改 /app/pages/[...slug].vue

   <PostFooter v-if="post.postfooter" v-bind="post" />
	<PostDonation v-if="post.donation" />
	<PostSurround />
	<PostComment v-if="post.comment" />

在 content.config.ts中添加如下代码

const articleSchema = z.object({
	draft: z.boolean().default(false),
	permalink: z.string().optional(),
	postfooter: z.boolean().default(true),
	donation: z.boolean().default(true),
	comment: z.boolean().default(true),

使用示例

---
title: '文章标题'
date: '2025-01-01'
postfooter: false#关闭分享按钮(连带着版权一起隐藏)
donation: false#关闭赞赏
comment: false#关闭评论
---

Twikoo评论样式美化

修改 /app/components/post/Comment.vue。

<script setup lang="tsx">
import type { TippyComponent } from 'vue-tippy'

const appConfig = useAppConfig()

const commentEl = useTemplateRef('comment')
const popoverEl = useTemplateRef<TippyComponent>('popover')
const popoverJumpTo = ref('')
const popoverInputEl = useTemplateRef('popover-input')
const showUndo = ref(false)

const popoverBind = ref<TippyComponent['$props']>({})

/** 评论区链接守卫 */
useEventListener(commentEl, 'click', (e) => {
	if (!(e.target instanceof Element))
		return

	if (e.target.matches('.tk-avatar-img'))
		e.stopPropagation()

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

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

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

	nextTick(checkUndoable)
	popoverEl.value?.show()
}, { capture: true })

function checkUndoable() {
	showUndo.value = popoverInputEl.value?.textContent !== popoverJumpTo.value
}

function undo() {
	if (!popoverInputEl.value)
		return
	popoverInputEl.value.textContent = popoverJumpTo.value
	checkUndoable()
}

function confirmOpen() {
	window.open(popoverInputEl.value?.textContent, '_blank')
}

onMounted(() => {
	window.twikoo?.init?.({
		envId: appConfig.twikoo?.envId,
		// twikoo 会把挂载后的元素变为 #twikoo
		el: '#twikoo',
	})
})
</script>

<template>
<section ref="comment" class="z-comment">
	<h3 class="text-creative">
		评论区
	</h3>

	<!-- interactive 默认会把气泡移动到 triggerTarget 的父元素上 -->
	<Tooltip
		ref="popover"
		v-bind="popoverBind"
		:append-to="() => commentEl!"
		interactive
		:aria="{ expanded: false }"
		trigger="focusin"
	>
		<template #content>
			<div class="popover-confirm">
				<span
					ref="popover-input"
					class="input"
					contenteditable="plaintext-only"
					spellcheck="false"
					@input="checkUndoable"
					@keydown.enter.prevent="confirmOpen"
					v-text="popoverJumpTo"
				/>

				<button
					v-if="showUndo"
					aria-label="恢复原始内容"
					@click="undo()"
				>
					<Icon name="tabler:arrow-back-up" />
				</button>

				<ZButton
					primary
					text="访问"
					@click="confirmOpen"
				/>
			</div>
		</template>
	</Tooltip>

	<div id="twikoo">
		<div class="comment-loading">
			<div class="loading-spinner"></div>
			<p>评论加载中...</p>
		</div>
	</div>
</section>
</template>

<style scoped>
.z-comment {
	margin: 3rem 1rem;

	> h3 {
		margin-top: 3rem;
		font-size: 1.25rem;
	}
}

:deep() > [data-tippy-root] > .tippy-box {
	padding: 0;
}

.popover-confirm {
	display: flex;
	align-items: center;
	overflow-wrap: anywhere;

	> .input {
		min-width: 0;
		padding: 0.3em 0.6em;
		outline: none;
	}

	> button {
		flex-shrink: 0;
		align-self: stretch;
		padding: 0.3em;
		border-radius: 0 0.5em 0.5em 0;
	}
}

:deep(#twikoo) {
	margin: 2em 0;

	.tk-admin-container {
		position: fixed;
		z-index: calc(var(--z-index-popover) + 1);
	}

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

		.tk-avatar,
		.tk-submit-action-icon.__markdown {
			display: none;
		}

		.tk-preview-container {
			margin: 0 0 .5rem 0;
		}

		.tk-row.actions {
			justify-content: flex-end;
			margin: 0 0 .5rem;
			order: 3;
		}

		.tk-textarea {
			order: 1;
			margin-bottom: .5rem;
			font-family: var(--font-monospace);

			.tk-input__inner {
				background-color: var(--c-bg-2);
				border: 2px solid var(--c-border);
				border-radius: 12px;
				padding: .8rem;
				transition: all .2s;

				&:focus {
					background-color: var(--c-bg);
					background-position-y: 350px;
					border-color: var(--c-primary);
				}
			}
		}

		.tk-meta-input {
			order: 2;
			position: relative;

			.tk-input {
				background: var(--c-bg-2);
				border: 2px solid var(--c-border);
				border-radius: 10px;
				transition: all .2s;

				&:focus-within {
					background: var(--c-bg);
					border-color: var(--c-primary);

					&::before, &::after {
						animation: fadeInTip .3s ease;
						display: block;
					}
				}

				&::before {
					background: var(--c-bg);
					border: 1px solid var(--c-border);
					border-radius: 8px;
					color: var(--c-text-1);
					display: none;
					font-size: .9rem;
					left: 50%;
					padding: .8rem 1rem;
					position: absolute;
					top: -60px;
					transform: translate(-50%);
					white-space: nowrap;
					z-index: 100;
				}

				&::after {
					border: 8px solid transparent;
					border-top: 8px solid var(--c-bg);
					content: "";
					display: none;
					left: 50%;
					position: absolute;
					top: -12px;
					transform: translate(-50%);
				}
			}

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

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

			.tk-input-group__prepend {
				background: var(--c-bg-1);
				border: none;
				border-radius: 8px 0 0 8px;
				color: var(--c-text-2);
				transition: all .2s;
			}
		}
	}

	.OwO .OwO-body {
		animation: fadeInPanel .3s ease .1s 1 normal both;
		background: var(--c-bg);
		border-radius: 8px;
		transform: translateZ(0);
	}

	.tk-avatar {
		border-radius: 50%;
		overflow: hidden;

		@supports (corner-shape: squircle) {
			corner-shape: superellipse(1.2);
		}
	}

	.tk-avatar.tk-clickable {
		cursor: auto;
	}

	.tk-time {
		color: var(--c-text-3);
	}

	/* 防止 a 被 overflow hidden */
	.tk-content {
		margin: -0.2em;
		padding: 0.2em;
		font-size: .95rem;
		line-height: 1.6;

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

		p > code, > code {
			background: var(--c-bg-2);
			border: 1px solid var(--c-border);
			border-radius: 6px;
			padding: .2em .4em;
		}

		.code-toolbar, > span > pre {
			background: var(--c-bg-2);
			border: 2px solid var(--c-border);
			border-radius: 8px;
			overflow: auto;
			padding: .4rem;
			position: relative;

			&::before {
				display: none;
			}

			pre {
				margin-top: .75rem;

				code {
					display: block;
					padding-top: .75rem;
				}
			}
		}
	}

	.tk-comments-title, .tk-nick {
		font-family: var(--font-creative);
		margin-bottom: 0;
	}

	.tk-nick-link {
		color: var(--c-primary);
	}

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

	/* 屏蔽点赞和踩赞按钮,保留回复按钮*/
	.tk-action .tk-action-link:not(:last-child) {
		display: none !important;
	}

	.tk-comment .tk-main {
		.tk-meta {
			margin-bottom: .3rem;
		}

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

	.tk-replies:not(.tk-replies-expand) {
		mask-image: linear-gradient(to top, transparent, #FFF 4em);
	}

	.tk-expand {
		border-radius: 0.5em;
		background-color: var(--c-bg-2);
		color: var(--c-text-1);
		padding: 0.375rem 1rem;
		transition: background-color 0.1s;

		&:hover {
			background-color: var(--c-bg-3);
		}
	}

	.tk-button:not(.tk-button--primary) {
		border-radius: 8px;
		background-color: var(--c-bg-2);
		color: var(--c-text-1);
		border: 1px solid var(--c-border);

		&:hover {
			background-color: var(--c-bg-3);
			border-color: var(--c-primary);
		}
	}

	.tk-button--primary {
		border-radius: 8px;
		background-color: var(--c-primary);
		color: white;
		border: 1px solid var(--c-primary);

		&:hover {
			background-color: var(--c-primary-soft);
			border-color: var(--c-primary-soft);
		}
	}

	.tippy-svg-arrow > svg {
		fill: inherit;
		width: auto;
		height: auto;
	}
}

:deep(:where(.tk-preview-container,.tk-content)) {
	pre {
		overflow: auto;
		border-radius: 0.5em;
		font-size: 0.85em;
	}

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

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

	p {
		margin: 0.2em 0;
	}

	img {
		border-radius: 0.5em;
	}

	menu, ol, ul {
		margin: 0.5em 0;
		padding-inline-start: 1.5em;
		font-size: 0.9rem;
		list-style: revert;

		> li {
			margin: 0.2em 0;

			&::marker {
				color: var(--c-primary);
			}
		}
	}

	blockquote {
		margin: 0.5rem 0 0.8rem;
		padding: 0.2em 0.8rem;
		border-inline-start: 4px solid var(--c-border);
		border-radius: 8px;
		background-color: var(--c-bg-2);
		font-size: 0.9em;
	}
}

.comment-loading {
	color: var(--c-text-2);
	padding: 2rem;
	text-align: center;

	.loading-spinner {
		animation: spin 1s linear infinite;
		border: 3px solid var(--c-bg-3);
		border-top-color: var(--c-primary);
		border-radius: 50%;
		height: 40px;
		margin: 0 auto 1rem;
		width: 40px;
	}

	p {
		font-size: .9rem;
	}
}

@keyframes spin {
	0% { transform: rotate(0); }
	to { transform: rotate(1turn); }
}

@keyframes fadeInTip {
	from { opacity: 0; transform: translate(-50%, 10px); }
	to { opacity: 1; transform: translate(-50%); }
}

@keyframes fadeInPanel {
	from { opacity: 0; transform: translateY(-20px); }
	to { opacity: 1; transform: translateY(0); }
}
</style>

用键盘敲击出的不只是字符,更是一段段生活的剪影、一个个心底的梦想。希望我的文字能像一束光,在您阅读的瞬间,照亮某个角落,带来一丝温暖与共鸣。

具有版权性

请您在转载、复制时注明本文 作者、链接及内容来源信息。 若涉及转载第三方内容,还需一同注明。

具有时效性

目录

欢迎来到webjing的站点,为您导航全站动态

19 文章数
3 分类数
0 评论数
1标签数
最近评论