Skip to content
0

v4-新增好看的动画效果

v4-新增好看的动画效果

image-20251015003628189

目录

[toc]

背景

新增 友链 悬浮时 滚动暂停、且会有css效果,很不错,如今,这个友链已经很丝滑了,感谢时光、白木 两位大佬的开发。💖💖💖

环境

Teek@1.5.1-2025.10.19版本

Teek-one开箱即用版仓库:https://cnb.cool/onedayxyy/vitepress-theme-teek-one-public (本次涉及具体代码可从此开源库里查找)

版权

配置来自《时光》和《白木》大佬提供的功能,感谢大佬💖💖💖。

效果

在线测试:https://onedayxyy.cn/about/friend-links

image-20251025074822128

image-20251025074834148

配置

提示

特别中意唯知笔记的友链组件,作者也出了教程,但配置相对复杂,需要额外安装第三方库,不是我想要的,所以仿照作者的友链界面和实现流程,重新编写了这个组件,相较于原组件:

  • 无需依赖第三方变量,减少配置成本;
  • 用原生 Flex 布局替代 Element Plus 的 el-row/el-col,无需额外安装 UI 库;
  • 具有相同的核心功能(分组展示、自适应布局、hover 交互),同时支持自定义隐藏评论区。

1.新建组件

  1. 新建组件与文件结构

    在 VitePress 主题目录下创建友链组件相关文件,结构如下:

    bash
    #需要新建 SLink 目录:
    #docs\.vitepress\theme\components\SLink
    DiySlinkShiroki.scss  DiySlinkShiroki.ts  index.vue  LinkItem.vue
    
    #需要新建 Twikoo.vue:
    #docs\.vitepress\theme\components
    Twikoo.vue
  2. 编写友链组件代码

    友链组件分为两部分:index.vue(负责分组展示与留言区)和 LinkItem.vue(单个友链卡片),完整代码如下:

    详细信息
    vue
    <template>
      <div class="my-links-container">
        <!-- 页面主标题区域 -->
        <div class="my-links-title">
          <h1>{{ title }}</h1>
        </div>
        <!-- 顶部Banner区域 -->
        <div v-if="bannerShow" class="flink-banner" id="banners">
          <!-- 左上角smallTitle -->
          <div class="icon-heartbeat1 banners-small-title">
            {{ smallTitle }}
          </div>
    
          <!-- 右上角功能按钮组 -->
          <div v-if="bannerButtonGroupShow" class="banner-button-group">
            <!-- 随机访问按钮 -->
            <button class="banner-button secondary" @click="handleRandomVisit" :disabled="allLinks.length === 0"
              aria-label="随机访问友链">
              <i class="icon-paper-plane" style="font-size: 18px;"></i>
              <span class="banner-button-text">随机访问</span>
            </button>
    
            <!-- 申请友链按钮 -->
            <a class="banner-button primary" href="#post-comment" :disabled="!shouldShow" aria-label="申请友链">
              <i class="icon-link" style="font-size: 18px;"></i>
              <span class="banner-button-text">申请友链</span>
            </a>
          </div>
    
          <!-- 两行头像横向无限滚动区域(错位排列) - 白木新增样式 -->
          <div class="tags-group-all">
            <!-- 星爆效果容器 - 白木新增样式 -->
            <div class="global-stars" ref="starsContainer">
              <svg v-for="(style, index) in starStyles" :key="index" 
                class="star-item" 
                :class="`gstar-${index + 1}`" 
                :style="style" 
                viewBox="0 0 24 24">
                <path d="M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z" />
              </svg>
            </div>
            <div class="tags-group-wrapper">
              <!-- 第一行 -->
              <div class="tags-group-row" :class="{ 'offset-start': index % 2 === 0 }" v-for="(row, index) in avatarRows"
                :key="index">
                <div class="tags-group-content">
                  <!-- 白木新增样式 -->
                  <a 
                    v-for="(link, linkIndex) in row" 
                    :key="linkIndex" 
                    class="tags-group-icon" 
                    target="_blank"
                    :href="link.link" 
                    :title="link.name" 
                    rel="external nofollow noopener"
                    @mouseenter="handleAvatarMouseEnter($event, link)"
                    @mouseleave="handleAvatarMouseLeave"
                  >
                    <img :src="link.avatar" :alt="link.name" loading="lazy" :class="{ irregular: link.irregular }">
                    <!-- 毛玻璃半透明背景 - 白木新增样式 -->
                    <div 
                      v-if="activeLink && activeLink.link === link.link" 
                      class="avatar-overlay"
                    >
                      <span class="avatar-text">{{ activeLink.name }}</span>
                    </div>
                  </a>
                </div>
              </div>
            </div>
    
          </div>
        </div>
        <!-- 友链分组列表,每个分组包含标题、描述和友链列表 -->
        <div v-for="(group, index) in linksData" :key="index" class="my-links-group">
          <!-- 分组标题容器 -->
          <div class="title-wrapper">
            <h3>{{ group.title }}</h3>
          </div>
    
          <!-- 分组描述文本 -->
          <p class="group-desc">{{ group.desc }}</p>
    
          <!-- 友链列表容器 -->
          <div class="links-grid">
            <!-- 每个友链项使用LinkItem子组件展示,通过:data传递友链信息 -->
            <div v-for="link in group.list" :key="link.link" class="links-grid__item">
              <LinkItem :data="link" />
            </div>
          </div>
        </div>
    
        <!-- 留言/评论区域,默认显示,可通过frontmatter隐藏 -->
        <div v-if="commentShow" class="my-message-section" id="post-comment">
          <div class="title-wrapper">
            <h3>申请友链</h3>
          </div>
          <p>想要和我交换友链?请在评论区按以下格式留言 💞</p>
    
          <!-- 留言卡片容器 -->
          <div class="message-card">
            <!-- 复制按钮区域 -->
            <div class="copy-button-container">
              <button class="copy-button" @click="copyMessageFormat" :aria-label="copyButtonText">
                <i class="icon-copy" style="font-size: 16px;"></i>
                <span class="copy-button-text">{{ copyButtonText }}</span>
              </button>
            </div>
            
            <p>留言格式:</p>
            <!-- 示例格式 -->
            <pre ref="messageFormat">
    名称: One
    链接: https://onedayxyy.cn/
    头像: https://img.onedayxyy.cn/images/Teek/Teekwebsite/xyy-logo.webp
    描述: 明心静性,爱自己</pre>
            <!-- 评论区插槽 -->
            <!-- 默认为Twikoo评论组件,可通过插槽自定义其他评论系统 -->
            <slot name="comments">
              <Twikoo />
            </slot>
          </div>
        </div>
      </div>
    </template>
    
    <script setup>
    import { useData } from "vitepress";
    import LinkItem from "./LinkItem.vue";
    // 导入Twikoo评论组件
    import Twikoo from "../Twikoo.vue";
    import { computed, ref, onMounted } from "vue";
    // 引入顶部区域头像星爆效果+毛玻璃背景功能  - 白木新增样式
    import { useStarBurst } from "./DiySlinkShiroki.ts";
    
    /**
     * 单个友链的数据结构定义
     * @typedef {Object} Link
     * @property {string} name - 友链网站名称
     * @property {string} link - 友链网站URL地址
     * @property {string} avatar - 友链网站头像/Logo的图片URL
     * @property {string} descr - 友链网站的简短描述
     * @property {boolean} [irregular] - 可选参数,默认值为false,为false时将把头像处理为圆形
     */
    
    /**
     * 友链分组的数据结构定义
     * @typedef {Object} LinkGroup
     * @property {string} title - 分组标题
     * @property {string} desc - 分组描述文字
     * @property {Link[]} list - 该分组下的友链列表数组
     */
    
    // 从页面frontmatter中获取配置数据
    const { frontmatter } = useData();
    
    // 从frontmatter中读取links字段,如果未定义则使用空数组
    const linksData = computed(() => frontmatter.value.links || []);
    
    // 从frontmatter中读取title字段,默认值为"我的友链"
    const title = computed(() => frontmatter.value.title || "我的友链");
    
    // 当frontmatter中comments为false时隐藏,默认显示
    const commentShow = computed(() => frontmatter.value.comments !== false);
    // 当frontmatter中banner为false时隐藏,默认显示
    const bannerShow = computed(() => frontmatter.value.banner !== false);
    // 当frontmatter中bannerButtonGroup为false时隐藏,默认显示
    const bannerButtonGroupShow = computed(() => frontmatter.value.bannerButtonGroup !== false);
    // 可自定义frontmatter中的smallTitle,作为banner的小标题,默认值为"与各位博主一起成长进步"
    const smallTitle = computed(() => frontmatter.value.smallTitle || "与各位博主一起成长进步");
    
    const allLinks = computed(() => {
      return linksData.value.reduce((acc, group) => {
        const processedLinks = group.list.map(link => ({
          ...link,
          avatar: link.avatar
        }));
        acc.push(...processedLinks);
        return acc;
      }, []);
    });
    
    // 将头像平均分成两行,并复制内容以实现无缝滚动
    const avatarRows = computed(() => {
      const avatars = allLinks.value;
      if (avatars.length === 0) return [[], []];
    
      const mid = Math.ceil(avatars.length / 2);
      const row1 = avatars.slice(0, mid);
      const row2 = avatars.slice(mid);
    
      // 复制内容以实现无缝滚动
      return [
        [...row1, ...row1], // 第一行复制一份
        [...row2, ...row2]  // 第二行复制一份
      ];
    });
    
    const handleRandomVisit = () => {
      if (allLinks.value.length === 0) return;
      const randomIndex = Math.floor(Math.random() * allLinks.value.length);
      const randomLink = allLinks.value[randomIndex];
      window.open(randomLink.link, "_blank");
    };
    
    // 星爆效果相关 - 白木新增样式
    const { gStarStyle, setWrap, onAvatarEnter, onAvatarLeave } = useStarBurst();
    const starStyles = ref(gStarStyle);
    const activeLink = ref(null);
    const starsContainer = ref(null);
    
    // 处理头像鼠标进入事件 - 白木新增样式
    const handleAvatarMouseEnter = (event, link) => {
      // 调用星爆效果函数 - 白木新增样式
      onAvatarEnter(event);
      
      // 设置当前激活的链接 - 白木新增样式
      activeLink.value = link;
    };
    
    // 处理头像鼠标离开事件 - 白木新增样式
    const handleAvatarMouseLeave = () => {
      // 调用星爆效果函数 - 白木新增样式
      onAvatarLeave();
      
      // 清除激活的链接 - 白木新增样式
      activeLink.value = null;
    };
    
    // 组件挂载后设置星星容器 - 白木新增样式
    onMounted(() => {
      if (starsContainer.value) {
        setWrap(starsContainer.value);
      }
    });
    
    // 复制功能相关
    const messageFormat = ref(null);
    const copyButtonText = ref('复制格式');
    const copyMessageFormat = async () => {
      if (!messageFormat.value) return;
        const text = messageFormat.value.textContent;
        await navigator.clipboard.writeText(text);
        // 复制成功反馈
        copyButtonText.value = '已复制 !';
        // 2秒后恢复原文本
        setTimeout(() => {
          copyButtonText.value = '复制格式';
        }, 2000);
    };
    </script>
    
    <style scoped>
    /* 字体图标 */
    @import url("https://cdn.ksah.cn/fonts/icomoon/font.css");
    /* 导入自定义样式 - 包含白木新增样式 */
    @import "./DiySlinkShiroki.scss";
    
    /* 主容器样式 */
    .my-links-container {
      max-width: 1500px;
      margin: 0 auto;
      padding: 40px 10px;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
        Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
    }
    
    /* 标题区域样式 */
    .my-links-title {
      margin-bottom: 50px;
      padding: 0 10px;
    }
    
    /* 主标题样式 */
    .my-links-title h1 {
      font-size: 2rem;
      font-weight: 600;
      background: -webkit-linear-gradient(10deg, #bd34fe 5%, #e43498 15%);
      background-clip: text;
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      -webkit-font-smoothing: antialiased;
      text-rendering: optimizeLegibility;
      line-height: 1.2;
      display: inline-block;
    }
    
    /* Banner区域 */
    .flink-banner {
      border: 1px solid var(--vp-c-divider);
      background-color: var(--vp-c-bg);
      border-radius: 12px;
      padding: 50px 20px 30px;
      margin-bottom: 60px;
      position: relative;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
    }
    
    /* 左上角图标 */
    .icon-heartbeat1::before {
      margin-right: 8px;
    }
    
    /* 左上角smallTitle */
    .banners-small-title {
      position: absolute;
      top: 20px;
      left: 20px;
      font-size: 1.5rem;
      font-weight: 500;
      color: var(--vp-c-text-1);
      z-index: 2;
    }
    
    /* 右上角按钮组 */
    .banner-button-group {
      position: absolute;
      top: 20px;
      right: 20px;
      display: flex;
      gap: 12px;
      z-index: 2;
    }
    
    .banner-button {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      padding: 8px 16px;
      border-radius: 8px;
      font-size: 0.9rem;
      font-weight: 500;
      cursor: pointer;
      transition: all 0.3s ease;
      border: none;
      text-decoration: none;
    }
    
    .banner-button.secondary {
      background: var(--vp-c-bg-soft);
      color: var(--vp-c-text-1);
      border: 1px solid var(--vp-c-divider);
    }
    
    .banner-button.primary {
      background: var(--vp-button-brand-bg);
      color: var(--vp-button-brand-text);
    }
    
    /* 两行头像横向滚动区域 */
    .tags-group-all {
      width: 100%;
      overflow: hidden;
      padding: 40px 0 10px;
      position: relative;
    }
    
    /* 滚动包装器 */
    .tags-group-wrapper {
      display: flex;
      flex-direction: column;
      gap: 20px;
    }
    
    /* 每一行 */
    .tags-group-row {
      display: flex;
      width: max-content;
      animation: scrollRow 60s linear infinite;
      will-change: transform;
      backface-visibility: hidden;
    }
    
    /* 原本的悬停时停止滚动动画样式,
    已移至DiySlinkShiroki.scss
    - 白木新增样式
    */
    
    /* 内容组 */
    .tags-group-content {
      display: flex;
      gap: 20px;
      padding: 0 10px;
    }
    
    /* 上下行错位排列 */
    .offset-start {
      margin-left: 60px;
      /* 错开半个头像 */
    }
    
    /* 滚动动画 */
    @keyframes scrollRow {
      0% {
        transform: translateX(0);
      }
    
      100% {
        transform: translateX(-40%);
      }
    }
    
    /* 头像样式样式
    已移至DiySlinkShiroki.scss
    - 白木新增样式
    */
    
    /* 头像覆盖层样式
    已移至DiySlinkShiroki.scss
    - 白木新增样式
    */
      
      /* 确保内容在banner内部 - 白木新增样式后,修复一个bug */
      .tags-group-all {
        position: relative;
        overflow: hidden;
        width: 100%;
        box-sizing: border-box;
      }
      
      /* 星星相关样式
      已移至DiySlinkShiroki.scss
      - 白木新增样式
      */
    
    .my-links-group {
      margin-bottom: 40px;
    }
    
    /* 分组标题装饰线样式 */
    .title-wrapper {
      position: relative;
      margin: 40px 0;
      height: 1px;
      background: #ddd;
      transition: 0.3s;
    }
    
    /* 分组标题文字样式 */
    .title-wrapper h3 {
      position: absolute;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      background: var(--vp-c-bg);
      padding: 0 20px;
      font-size: 1.3rem;
      font-weight: 600;
      color: var(--vp-c-text-1);
      z-index: 1;
    }
    
    /* 分组描述文字样式 */
    .group-desc {
      text-align: center;
      color: var(--vp-c-text-2);
      font-size: 0.95rem;
      margin-bottom: 30px;
      padding: 0 10px;
    }
    
    /* 友链网格布局 - 核心响应式实现 */
    .links-grid {
      display: flex;
      flex-wrap: wrap;
      justify-content: center;
      /* 让所有行的内容居中对齐 */
      gap: 24px;
      margin-bottom: 60px;
      padding: 0 8px;
    }
    
    /* 每个友链项的样式,设置基础宽度 */
    .links-grid__item {
      flex: 0 0 calc(100% - 24px);
      /* 移动设备:每行1个 */
      break-inside: avoid;
    }
    
    /* 平板设备:每行2个 */
    @media (min-width: 768px) {
      .links-grid__item {
        flex: 0 0 calc(50% - 24px);
      }
    }
    
    /* 桌面设备:每行最多4个 */
    @media (min-width: 1024px) {
      .links-grid__item {
        flex: 0 0 calc(25% - 24px);
      }
    }
    
    /* 留言区样式 */
    .my-message-section {
      text-align: center;
      margin-top: 20px;
    }
    
    /* 留言卡片样式 */
    .message-card {
      width: 100%;
      max-width: 1500px;
      margin: 30px auto;
      padding: 32px;
      border-radius: 12px;
      background: var(--vp-c-bg);
      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
      border: 1px solid var(--vp-c-divider);
      text-align: left;
      position: relative;
    }
    
    /* 复制按钮容器 */
    .copy-button-container {
      position: absolute;
      top: 20px;
      right: 20px;
      z-index: 2;
    }
    
    /* 复制按钮样式 */
    .copy-button {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 8px 16px;
      border-radius: 8px;
      font-size: 0.9rem;
      font-weight: 500;
      cursor: pointer;
      transition: all 0.3s ease;
      border: 1px solid var(--vp-c-divider);
      background: var(--vp-c-bg-soft);
      color: var(--vp-c-text-1);
      text-decoration: none;
      border: none;
    }
    
    .copy-button:hover {
      background: var(--vp-button-brand-bg);
      color: var(--vp-button-brand-text);
      transform: translateY(-1px);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    }
    
    .copy-button:active {
      transform: translateY(0);
    }
    
    /* 示例格式代码块样式 */
    .message-card pre {
      background: var(--vp-code-block-bg);
      padding: 16px;
      border-radius: 8px;
      font-size: 0.95rem;
      overflow-x: auto;
      margin: 20px 0;
      border: 1px solid var(--vp-c-divider);
      line-height: 1.5;
      position: relative;
    }
    
    /* 移动端留言卡片适配 */
    @media (max-width: 768px) {
      .message-card {
        padding: 24px;
        margin: 24px auto;
      }
    
      .copy-button-container {
        position: static;
        margin-bottom: 16px;
        display: flex;
        justify-content: flex-end;
      }
      
      .copy-button {
        padding: 6px 12px;
        font-size: 0.85rem;
      }
    
      .tags-group-icon {
        flex: 0 0 80px;
        width: 80px;
        height: 80px;
      }
    
      .tags-group-content {
        gap: 15px;
      }
    
      .offset-start {
        margin-left: 40px;
        /* 移动端适配 */
      }
    
      .flink-banner {
        padding: 30px 15px 20px;
      }
    
      .banner-button {
        padding: 6px 12px;
        font-size: 0.85rem;
      }
    
      /* 两个按钮 */
      .banner-button-group {
        display: none;
      }
    
      /* 移动端滚动速度调整 */
      .tags-group-row {
        animation-duration: 40s;
      }
    }
    
    /* 减少动画对性能的影响 */
    @media (prefers-reduced-motion: reduce) {
      .tags-group-row {
        animation: none;
      }
    }
    </style>
    vue
    <template>
      <div class="link-item-card" ref="cardRef">
        <!-- 标签 -->
        <div class="link-tag" v-if="data.tag">{{ data.tag }}</div>
    
        <a :href="data.link" :title="data.name" target="_blank" rel="noopener">
          <!-- 头像 -->
          <div class="link-avatar">
            <img
              v-if="!imageFailed && data.avatar"
              :src="data.avatar"
              :alt="data.name"
              @error="handleImageError"
              :class="{ irregular: data.irregular }"
            />
            <span v-else class="avatar-placeholder">
              {{ data.name ? data.name.charAt(0).toUpperCase() : '?' }}
            </span>
          </div>
    
          <!-- 信息 -->
          <div class="link-content">
            <div class="link-name">{{ data.name }}</div>
            <div class="link-desc" :title="data.descr">
              {{ data.descr }}
            </div>
          </div>
        </a>
      </div>
    </template>
    
    <script setup lang="ts">
    import { ref, onMounted, onBeforeUnmount } from "vue";
    
    interface LinkData {
      name: string;
      link: string;
      tag?: string;
      avatar?: string;
      descr?: string;
      irregular?: boolean;
    }
    
    defineProps<{
      data: LinkData;
    }>();
    
    const imageFailed = ref(false);
    const handleImageError = () => (imageFailed.value = true);
    
    const cardRef = ref<HTMLElement | null>(null);
    let rafId: number;
    const lights: any[] = [];
    const lightCount = 1; // 只用一个光圈覆盖整个卡片
    
    const lerp = (start: number, end: number, t: number) => start + (end - start) * t;
    
    // 预设的光圈颜色配置表
    // 格式:rgba(红, 绿, 蓝, 透明度)
    // 透明度 0.8 表示半透明效果
    const presetColors = [
      "rgba(255, 99, 132, 0.8)", // 红色调
      "rgba(54, 162, 235, 0.8)", // 蓝色调
      "rgba(255, 159, 64, 0.8)", // 橙色调
      "rgba(75, 192, 192, 0.8)", // 青色调
      "rgba(153, 102, 255, 0.8)", // 紫色调
      "rgba(255, 159, 64, 0.8)", // 橙色调
      "rgba(255, 99, 132, 0.8)", // 红色调
      "rgba(0, 255, 255, 0.8)", // 青绿色
    ];
    
    const getRandomColor = () => presetColors[Math.floor(Math.random() * presetColors.length)];
    
    const getGradientColor = (lightColor: string) => {
      const [r, g, b] = lightColor.match(/\d+/g)!.map(Number);
      return `rgba(${r}, ${g}, ${b}, 0.6)`; // 让光圈中心更亮
    };
    
    onMounted(() => {
      if (!cardRef.value) return;
      const card = cardRef.value;
      const lightColor = getRandomColor();
    
      const handleMouseEnter = () => {
        for (let i = 0; i < lightCount; i++) {
          const lightEl = document.createElement("div");
          lightEl.classList.add("light");
          lightEl.style.position = "absolute";
          lightEl.style.borderRadius = "50%";
          lightEl.style.pointerEvents = "none";
          lightEl.style.background = `rgba(255,255,255,0)`;
          card.appendChild(lightEl);
    
          lights.push({
            el: lightEl,
            x: card.offsetWidth / 2,
            y: card.offsetHeight / 2,
            size: Math.max(card.offsetWidth, card.offsetHeight) * 1.8,
            opacity: 0,
            targetX: card.offsetWidth / 2,
            targetY: card.offsetHeight / 2,
            speed: 0.08,
            lightColor,
          });
        }
      };
    
      const handleMouseLeave = () => {
        lights.forEach(light => {
          light.el.style.opacity = "0";
          setTimeout(() => light.el.remove(), 300);
        });
        lights.length = 0;
      };
    
      const handleMouseMove = (e: MouseEvent) => {
        const rect = card.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;
    
        lights.forEach(light => {
          light.targetX = x;
          light.targetY = y;
    
          light.x = lerp(light.x, light.targetX, light.speed);
          light.y = lerp(light.y, light.targetY, light.speed);
    
          const maxDim = Math.max(card.offsetWidth, card.offsetHeight);
          const targetSize = maxDim * 1.8;
          light.size = lerp(light.size, targetSize, 0.1);
    
          light.opacity = lerp(light.opacity, 0.6, 0.1);
    
          const gradientColor = getGradientColor(light.lightColor);
          light.el.style.background = `radial-gradient(circle at ${light.x}px ${light.y}px, ${gradientColor} 0%, rgba(255,255,255,0) 100%)`;
    
          light.el.style.width = `${light.size}px`;
          light.el.style.height = `${light.size}px`;
          light.el.style.left = `${light.x - light.size / 2}px`;
          light.el.style.top = `${light.y - light.size / 2}px`;
          light.el.style.opacity = light.opacity.toString();
        });
      };
    
      card.addEventListener("mouseenter", handleMouseEnter);
      card.addEventListener("mouseleave", handleMouseLeave);
      card.addEventListener("mousemove", handleMouseMove);
    
      const animate = () => (rafId = requestAnimationFrame(animate));
      animate();
    
      onBeforeUnmount(() => cancelAnimationFrame(rafId));
    });
    </script>
    
    <style scoped>
    .link-tag {
      position: absolute;
      top: 8px;
      right: 8px;
      z-index: 10;
      font-size: 0.75rem;
      font-weight: 500;
      padding: 1px 6px;
      border-radius: 8px;
      background-color: var(--vp-badge-tip-bg);
      color: var(--vp-badge-tip-text);
      letter-spacing: 0.2px;
    }
    
    .link-item-card {
      position: relative;
      height: 100px;
      border-radius: 12px;
      background: var(--vp-c-bg);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
      border: 1px solid var(--vp-c-divider);
      transition: all 0.3s ease;
      overflow: hidden;
      z-index: 0; /* 作为光圈定位参考 */
    }
    
    .link-item-card:hover {
      transform: translateY(-4px);
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
    }
    
    .link-item-card a {
      display: flex;
      align-items: center;
      height: 100%;
      text-decoration: none;
      color: inherit;
    }
    
    .link-avatar {
      flex: 0 0 100px;
      height: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: transform 0.3s ease;
      position: relative;
      z-index: 1; /* 在光圈之上 */
    }
    
    .link-avatar img {
      width: 60px;
      height: 60px;
      border-radius: 50%;
      object-fit: cover;
      transition: transform 0.3s ease;
    }
    
    .link-avatar img.irregular {
      border-radius: 8px;
      object-fit: contain;
    }
    
    .link-avatar .avatar-placeholder {
      width: 60px;
      height: 60px;
      background: #f0f0f0;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-weight: 600;
      color: #555;
      font-size: 1.2rem;
    }
    
    .link-item-card:hover .link-avatar img,
    .link-item-card:hover .avatar-placeholder {
      transform: scale(1.2);
    }
    
    .link-content {
      flex: 1;
      padding: 0 16px 0 0px;
      position: relative;
      z-index: 1;
    }
    
    .link-name {
      font-size: 1rem;
      font-weight: 600;
      color: var(--vp-c-text-1);
      margin-bottom: 6px;
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 2;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: normal;
    }
    
    .link-desc {
      font-size: 0.875rem;
      color: var(--vp-c-text-2);
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 2;
      overflow: hidden;
      line-height: 1.4;
    }
    
    /* 光圈样式 */
    .light {
      position: absolute;
      border-radius: 50%;
      pointer-events: none;
      z-index: 0;
      transition:
        width 0.1s,
        height 0.1s,
        opacity 0.1s;
    }
    
    .banner-button.primary[data-v-7a7fc354] {
        background: #0975e3;
        color: var(--vp-button-brand-text);
    }
    </style>
    scss
    /* 自定义友链样式 */
    
    /* 头像容器样式 */
    .tags-group-icon {
      flex: 0 0 120px;
      width: 120px;
      height: 120px;
      border-radius: 50%;
      overflow: hidden;
      border: 2px solid var(--vp-c-bg-soft);
      transition: transform 0.3s ease, box-shadow 0.3s ease;
      display: flex;
      align-items: center;
      justify-content: center;
      position: relative;
    }
    
    /* 头像图片样式 */
    .tags-group-icon img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
    
    /* 不规则形状头像样式 */
    .tags-group-icon img.irregular {
      border-radius: 8px;
      object-fit: contain;
    }
    
    /* 头像悬停效果 */
    .tags-group-icon:hover {
      transform: scale(1.05);
      box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
      z-index: 1;
    }
    
    /* 头像覆盖层 - 毛玻璃半透明背景 */
    .avatar-overlay {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      padding: 20px 16px;
      background: var(--vp-c-bg-mute, rgba(255, 255, 255, 0.308));
      backdrop-filter: blur(8px);
      -webkit-backdrop-filter: blur(8px);
      text-align: center;
      display: flex;
      align-items: center;
      justify-content: center;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      z-index: 2;
      border-radius: inherit;
      transform-origin: center;
      /* 背景只做放大进入动画,不闪烁 */
      animation: scaleInFromCenter 0.8s ease forwards;
    }
    
    /* 深色模式下使用黑色毛玻璃背景 */
    :root.dark .avatar-overlay {
      background: rgba(0, 0, 0, 0.5);
    }
    
    /* 亮色模式下使用白色毛玻璃背景 */
    :root.light .avatar-overlay {
      background: rgba(255, 255, 255, 0.308);
    }
    
    /* 从中间展开的动画 - 修改为放大效果 */
    @keyframes scaleInFromCenter {
      from {
        opacity: 0;
        transform: scale(0);
      }
      to {
        opacity: 1;
        transform: scale(1.3); /* 放大到1.3倍 */
      }
    }
    
    /* 鼠标悬停头像滚动容器时暂停所有滚动动画 */
    .tags-group-all:hover .tags-group-row {
      animation-play-state: paused;
    }
    
    /* 头像文字样式 */
    .avatar-text {
      /* 亮色模式下为蓝色文字 */
      color: rgb(0, 17, 255);
      /* 文字单独做闪烁动画 */
      animation: textTwinkle 3s ease-in-out infinite 0.3s;
    }
    
    /* 深色模式下使用白色文字,确保可见性 */
    :root.dark .avatar-text {
      color: rgb(255, 255, 255);
    }
    
    /* 文字闪烁动画 */
    @keyframes textTwinkle {
      0%, 100% {
        opacity: 1;
        transform: scale(1);
        text-shadow: 0 0 4px rgba(254, 254, 255, 0.8);
      }
      50% {
        opacity: 0.7;
        transform: scale(1.1);
        text-shadow: 0 0 4px rgb(255, 255, 255);
      }
    }
    
    /* 星星容器样式 */
    .global-stars {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      pointer-events: none;
      z-index: 10;
    }
    
    /* 星星项样式 */
    .star-item {
      position: absolute;
      pointer-events: none;
      width: 24px;
      height: 24px;
      fill: var(--star, #fff);
      filter: drop-shadow(0 0 4px var(--glow, rgba(255,255,255,0.8)));
      opacity: 0;
      transform: translate(-50%, -50%) scale(0);
      transition: all 0.5s ease;
    }
    
    /* 六色与辉光 */
    .global-stars svg.gstar-1 {
      --star: #ff5f5f;
      --glow: #ff5f5f88;
    }
    .global-stars svg.gstar-2 {
      --star: #ff3e88;
      --glow: #ff443e88;
    }
    .global-stars svg.gstar-3 {
      --star: #00ffff;
      --glow: #3bd8ff88;
    }
    .global-stars svg.gstar-4 {
      --star: #3183ff;
      --glow: #d852ab88;
    }
    .global-stars svg.gstar-5 {
      --star: #fa3be0;
      --glow: #fa3bd188;
    }
    .global-stars svg.gstar-6 {
      --star: #bd34fe;
      --glow: #bd34fe88;
    }
    
    /* 显示星星的样式 */
    .global-stars.show .star-item {
      animation: starBurst 0.6s ease-out forwards, starTwinkle 2s infinite alternate ease-in-out 0.6s;
    }
    
    /* 星爆动画 */
    @keyframes starBurst {
      0% {
        opacity: 0;
        transform: translate(-50%, -50%) scale(0);
      }
      20% {
        opacity: 1;
      }
      100% {
        opacity: 0.8; /* 保持可见而不是消失 */
        transform: translate(calc(-50% + var(--offset-x, 0px)), calc(-50% + var(--offset-y, 0px))) scale(1);
      }
    }
    
    /* 星星闪烁动画 */
    @keyframes starTwinkle {
      0% {
        opacity: 0.4;
        filter: drop-shadow(0 0 4px var(--glow, rgba(255,255,255,0.4)));
        transform: translate(calc(-50% + var(--offset-x, 0px)), calc(-50% + var(--offset-y, 0px))) scale(0.9);
      }
      100% {
        opacity: 1;
        filter: drop-shadow(0 0 8px var(--glow, rgba(255,255,255,0.8)));
        transform: translate(calc(-50% + var(--offset-x, 0px)), calc(-50% + var(--offset-y, 0px))) scale(1.1);
      }
    }
    ts
    // 星星定位 - 全局星星
    import { reactive, ref } from "vue";
    
    type StarStyle = {
      left?: string;
      top?: string;
      transform?: string;
      opacity?: string;
      ["--offset-x"]?: string;
      ["--offset-y"]?: string;
    };
    
    export function useStarBurst() {
      const gStarStyle = reactive<StarStyle[]>(
        Array.from({ length: 6 }, () => ({
          left: "0px",
          top: "0px",
          transform: "translate(-50%, -50%) scale(0)",
          opacity: "0",
          "--offset-x": "0px",
          "--offset-y": "0px",
        }))
      );
    
      const starsWrap = ref<HTMLElement>();
    
      function setWrap(el: HTMLElement) {
        starsWrap.value = el;
      }
    
      function onAvatarEnter(e: MouseEvent) {
        if (!starsWrap.value) return;
    
        const bannerRect = starsWrap.value.getBoundingClientRect();
        const iconRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
        const centerX = iconRect.left + iconRect.width / 2 - bannerRect.left;
        const centerY = iconRect.top + iconRect.height / 2 - bannerRect.top;
    
        starsWrap.value.classList.add("show");
    
        // 固定偏移,美观齐爆
        const offsets = [
          { x: -50, y: -60 },
          { x: 20, y: -60 },
          { x: -70, y: 0 },
          { x: 60, y: -12 },
          { x: -40, y: 50 },
          { x: 50, y: 60 },
        ];
    
        for (let i = 0; i < 6; i++) {
          gStarStyle[i] = {
            left: `${centerX}px`,
            top: `${centerY}px`,
            "--offset-x": `${offsets[i].x}px`,
            "--offset-y": `${offsets[i].y}px`,
            transform: "translate(-50%, -50%) scale(0)",
            opacity: "0",
          };
        }
        
        // 使用setTimeout强制触发浏览器重新渲染,确保动画正确启动
        setTimeout(() => {
          // 这里不需要做任何事,setTimeout本身会触发一次渲染周期
        }, 0);
      }
    
      function onAvatarLeave() {
        if (!starsWrap.value) return;
        starsWrap.value.classList.remove("show");
        gStarStyle.forEach((_, i) => {
          gStarStyle[i] = {
            transform: "translate(-50%, -50%) scale(0)",
            opacity: "0",
            "--offset-x": "0px",
            "--offset-y": "0px",
          };
        });
      }
    
      return { gStarStyle, setWrap, onAvatarEnter, onAvatarLeave };
    }
    vue
    <template>
      <div id="twikoo"></div>
    </template>
    
    <script setup lang="ts">
    import { onMounted, watch ,nextTick } from 'vue'
    import { useRoute } from 'vitepress'
    
    const route = useRoute()
    
    const initTwikoo = async () => {
      // 判断是否在浏览器环境中
      if (typeof window !== 'undefined') {
        const twikoo = await import('twikoo')
        twikoo.init({
          envId: 'https://twikoo.onedayxyy.cn/', // 换成你自己配置的域名
          el: '#twikoo'
        })
      }
    }
    
    
    const modifyCommentButton = () => {
      nextTick(() => {
        // 精确选择评论按钮
        const commentBtn = Array.from(document.querySelectorAll('.tk-right-bottom-button__button'))
          .find(btn => btn.getAttribute('title') === '前往评论');
        
        if (commentBtn && !commentBtn.getAttribute('data-custom-bound')) {
          commentBtn.setAttribute('data-custom-bound', 'true');
          
          commentBtn.addEventListener('click', (e) => {
            e.preventDefault();
            const twikooSection = document.getElementById('twikoo');
            if (twikooSection) {
              twikooSection.scrollIntoView({ 
                behavior: 'smooth',
                block: 'start'
              });
            }
          });
        }
      });
    }
    
    
    // 监听路由刷新评论
    watch(route, () => {
      initTwikoo()
      modifyCommentButton()
    })
    
    onMounted(() => {
      initTwikoo()
      modifyCommentButton()
    })
    </script>

2.注册组件

ts
import SLink from "./components/SLink/index.vue";

export default {
  enhanceApp({ app }) {
    // 注册全局组件
    app.component("friend-link", SLink);
  },
};

3.使用方法

  • frontmatter 中设置 layoutfriend-link,启用友链布局模式
  • 建议设置 sidebar: false,界面更美观
  • 设置 comments: false 可关闭底部评论区

新建如下文件:

使用模板
md
---
date: 2025-09-03 02:06:02
layout: friend-link
title: 我的友链
banner: true
bannerButtonGroup: true
smallTitle: 与各位博主一起成长进步
comments: true
sidebar: false
permalink: /about/friend-links
article: false
# comment: false
copyright: false
articleShare: false
appreciation: false
links:
  - title: 个人站点
    #desc: "建站中学习和使用了以下博客/网站的技术和分享,特别鸣谢!💖"
    list:
      - name: One
        link: https://onedayxyy.cn/
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/xyy-logo.webp
        irregular: true
        descr: 明心静性,爱自己
        tag: 小菜鸡
      # - name: One
      #   link: https://zola.onedayxyy.cn/
      #   avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/xyy-logo.webp
      #   irregular: true
      #   descr: 明心静性,爱自己(基于rust开发的zola,移植自张洪heo博客)         
      # - name: VitePress
      #   link: https://vitepress.dev/zh/
      #   avatar: https://vitepress.dev/vitepress-logo-mini.svg
      #   irregular: true
      #   descr: 由 Vite 和 Vue 驱动的静态站点生成器
  - title: 大佬
    #desc: "建站中学习和使用了以下博客/网站的技术和分享,特别鸣谢!💖"
    list:
      - name: 宇阳
        link: https://liuyuyang.net
        avatar: https://q1.qlogo.cn/g?b=qq&nk=3311118881&s=640
        irregular: true
        descr: 记录所学知识,缩短和大神的差距!
        tag: Thrix博客作者
      - name: Teeker
        link: https://vp.teek.top/
        avatar: https://vitepress.yiov.top/logo.png
        irregular: true
        descr: 一个轻量、简洁高效、灵活配置,易于扩展的 VitePress 主题
        tag: Teek博客作者       
  - title: VitePress
    # desc: "建站中学习和使用了以下博客/网站的技术和分享,特别鸣谢!💖"
    list:
      - name: vitepress-theme-teek
        link: https://vp.teek.top/
        avatar: https://vitepress.yiov.top/logo.png
        irregular: true
        descr: 一个轻量、简洁高效、灵活配置,易于扩展的 VitePress 主题
        tag: Teek官网
      - name: VitePress
        link: https://vitepress.dev/zh/
        avatar: https://vitepress.dev/vitepress-logo-mini.svg
        irregular: true
        descr: 由 Vite 和 Vue 驱动的静态站点生成器
  - title: Teek道友
    # desc: "聚集众多优秀独立博客,随机传送 \U0001F680"
    list:
      - name: 天客 - Teeker
        descr: 朝圣的使徒,正在走向编程的至高殿堂!(Teek作者,大佬666)
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/notes.teek.top.png
        link: https://notes.teek.top/  
        tag: Teek作者
        irregular: true
      - name: One 
        descr: 明心静性,爱自己 
        avatar: https://img.onedayxyy.cn/images/xyy-logo.ico 
        link: https://onedayxyy.cn/ 
        irregular: true
      - name: Hyde Blog
        descr: 前端大佬,666 
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/hyde.webp
        link: https://teek.seasir.top/
        tag: 前端大佬
        irregular: true
      - name: 二丫讲梵 
        descr: 💻学习📝记录🔗分享 
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/eryajiangfan.png 
        link: https://jenkinsguide.opsre.top/ 
        # link: https://wiki.eryajf.net/ 
        irregular: true
      - name: 威威 Blog
        descr: teek伙伴
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/dl-web.top.png
        link: https://dl-web.top/
        irregular: true
        tag: Teek开发者2号
      - name: W3C
        descr: teek伙伴
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/teek.w3c.cool.svg
        link: https://teek.w3c.cool/
        irregular: true         
      - name: 时光驿站
        descr: 干活满满的技术笔记
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/kandu.cxcare.top.svg
        link: https://notes.ksah.cn/ 
        irregular: true
        tag: 前端大佬
      - name: 白木🗼共灵知塔
        descr: 前端大佬
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/baimu.live.png
        link: https://teek.baimu.live/
        irregular: true 
        tag: 前端大佬         
      # - name: 白木
      #   descr: SCUM中文服务器后台说明文档
      #   avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/st.samgame.cn.png
      #   link: https://st.samgame.cn/
      #   irregular: true             
      - name: 凿壁偷光不算偷 Blog
        descr: teek道友 
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/sinc-us-kg-favicon.ico 
        link: https://sinc.us.kg/  
        irregular: true
      - name: SnowLin Blog
        descr: 喵喵(? 
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/SnowLinBlog-logo.png 
        link: https://blog.snowlinlan.com/
        irregular: true
      - name: 心流笔记
        descr: 坚持 & 汲取 & 分享(一个记录生活与学习过程中灵感和感悟的空间)
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/blog.wilsonzy.cn.ico
        link: http://blog.wilsonzy.cn/c/StreamNotes/
        irregular: true
      - name: 科技语者
        descr: 拥抱科技奔赴星辰大海
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/www.uopy.top.ico
        link: https://www.uopy.top/
        irregular: true
      - name: OpForge
        descr: 运维锻造,知识沉淀
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/opforge.srebro.cn.png
        link: https://opforge.srebro.cn/
        irregular: true
      - name: 乔克视界
        descr: 云原生爱好者
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/jokerbai.com.ico
        link: https://jokerbai.com/
        irregular: true
      - name: 记得勇敢
        descr: 常用组件及代码封装
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/vp.xiaoying.org.cn.png
        link: https://vp.xiaoying.org.cn/
        irregular: true
      - name: 骏骏骏的博客
        descr: 后端程序员
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/blog.azhxsn.cn.svg
        link: https://blog.azhxsn.cn/
        irregular: true
      - name: MaraPython
        descr: 行而不辍,未来可期
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/marapython.com.png
        link: https://marapython.com/
        irregular: true
      - name: Mujin Blog
        descr: 只要一息尚存,就还有希望
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/blog.charily.top.ico
        link: https://blog.charily.top/
        irregular: true              
  - title: 张洪Heo风格博客
    # desc: "聚集众多优秀独立博客,随机传送 \U0001F680"
    list:
      - name: 王嘉祥
        link: https://blog.jiaxiang.wang/
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/blog.jiaxiang.wang.webp
        descr: 唱响科普和人生兴事,分享科技与美好生活(rust写的zola主题,移植于张洪heo)
        irregular: true
  - title: 简约唯美风格博客
    # desc: "聚集众多优秀独立博客,随机传送 \U0001F680"
    list:
      - name: 浩瀚星河
        link: https://www.golangblog.com/
        avatar: https://img.onedayxyy.cn/images/Teek/Teekwebsite/www.golangblog.com.jpeg
        descr: 一个程序员的碎碎念
        irregular: true
  - title: 技术类
    # desc: "聚集众多优秀独立博客,随机传送 \U0001F680"
    list:
      - name: 25196
        link: https://www.yangworld.top/
        avatar: https://q1.qlogo.cn/g?b=qq&nk=2779643212&s=640
        descr: 互联网上的小窝
        irregular: true  
      - name: 杨工子
        link: https://www.yangmufa.cn
        avatar: https://img.yangmufa.cn/roc-blog/8c492941ce294ff597feb51f4fd8ff92.jpg
        descr: 我是杨工子;练习编程两年半,C#.Net全栈工程师、RocBlog(鲲鹏博客)作者。
        irregular: true                       
categories:
  - 关于
coverImg: https://img.onedayxyy.cn/images/TeekCover/1.webp
---

4.配置评论

(1)配置评论:

编辑docs\.vitepress\config.ts文件:

ts
  // // 评论配置
  // comment: {
  //   provider: "twikoo",
  //   options: CommentData,
  // },
  comment: {
    provider: "render",
    // options: CommentData,
  },

(2)安装twikoo评论组件

bash
pnpm add twikoo

(3)引入评论组件

编辑docs\.vitepress\theme\components\TeekLayoutProvider.vue文件:

ts
import Twikoo from './Twikoo.vue' //评论组件


    <!-- 评论组件 -->
    <!-- <template #doc-after> -->
    <!-- <template #teek-doc-after-appreciation-after>
       -->
    <template #teek-comment>  
      <Twikoo />
    </template>

(4)解决首页个人简介里头像异常问题

编辑docs\index.md文件:

md
.tk-avatar {
    height: var(--tk-avatar-size) !important;
    width: var(--tk-avatar-size) !important;
}
.tk-avatar--circle {
    border-radius: 50% !important;
}

image-20251023100425060

5.组件效果

http://localhost:5173/about/friend-links

在线测试:https://onedayxyy.cn/about/friend-links

image-20251025074822128

image-20251025074834148

结束。

最近更新