跳转到内容

自建组件

时光2025/10/230 0 m

底部版权声明组件

说明

在技术文档中添加规范的版权声明,既能保护内容权益,也能提升文档的专业感。本组件参考唯知笔记的底部版权样式,结合 VitePress 主题特性优化实现,支持自适应布局与交互反馈,效果简洁且实用。

组件效果

底部版权声明组件

新建组件

  1. 组件文件结构 首先在 VitePress 主题目录下,按以下结构创建 DocFooterCopyright.vue 组件文件:
bash
.vitepress
├─ theme
  ├─ components
  ├─ DocFooterCopyright.vue
  ├─ MyLayout.vue
  1. 编写版权声明组件代码,以下是完整的 DocFooterCopyright.vue 代码,包含自适应样式、交互效果与可配置参数,可直接复制使用并根据需求修改配置:
DocFooterCopyright.vue
vue
<template>
  <div v-if="shouldShow" class="shiguang-public">
    <div class="copyright-card">
      <!-- 公共图标 -->
      <span class="copyright-symbol shiguang-icon shiguang-icon-public"></span>

      <div class="copyright-content">
        <!-- 作者信息 -->
        <div class="copyright-item">
          <span class="copyright-meta">
            <span class="shiguang-icon shiguang-icon-user"></span>
            <span class="meta-text">本文作者</span>:
          </span>
          <span class="copyright-info">
            <a :href="config.authorUrl" target="_blank" rel="noopener">{{
              config.authorName
            }}</a>
          </span>
        </div>

        <!-- 文章标题 -->
        <div class="copyright-item">
          <span class="copyright-meta">
            <span class="shiguang-icon shiguang-icon-title"></span>
            <span class="meta-text">本文标题</span>:
          </span>
          <span class="copyright-info">
            {{ $frontmatter.title }}
          </span>
        </div>

        <!-- 文章链接 -->
        <div class="copyright-item">
          <span class="copyright-meta">
            <span class="shiguang-icon shiguang-icon-link"></span>
            <span class="meta-text">本文链接</span>:
          </span>
          <span class="copyright-info">
            <a :href="currentUrl" target="_blank" rel="noopener">{{
              currentUrl
            }}</a>
          </span>
        </div>

        <!-- 版权声明 -->
        <div class="copyright-item">
          <span class="copyright-meta">
            <span class="shiguang-icon shiguang-icon-cc"></span>
            <span class="meta-text">版权声明</span>:
          </span>
          <span class="copyright-info">
            本站文章除特别声明外,均采用
            <a :href="config.licenseUrl" target="_blank" rel="noopener">{{
              config.licenseName
            }}</a>
            协议,转载请注明来自
            <a :href="config.siteUrl" target="_blank" rel="noopener">{{
              config.siteName
            }}</a
            >!
          </span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from "vue";
import { useData, useRoute } from "vitepress";

// 统一配置区
const config = {
  authorName: "时光",
  authorUrl: "https://github.com/nxcare",
  siteName: "时光笔记",
  siteUrl: "https://kandu.cxcare.top/",
  licenseName: "CC BY-NC-SA 4.0",
  licenseUrl: "https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh",
};

// 获取 frontmatter 和路由
const { frontmatter } = useData();
const route = useRoute();

// 是否显示版权组件
const shouldShow = computed(() => frontmatter.value.copyright !== false);

// 当前页面完整 URL
const currentUrl = computed(() => {
  const baseUrl = config.siteUrl;
  const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
  const path = route.path === "/" ? "" : route.path.replace(/^\//, "");
  return `${normalizedBaseUrl}${path}`;
});
</script>

<style scoped>
.shiguang-public {
  margin: 2rem 0;
}

.copyright-card {
  position: relative;
  padding: clamp(12px, 4vw, 16px) clamp(16px, 6vw, 20px);
  border: 1px solid var(--vp-c-divider);
  border-radius: 12px;
  background-color: var(--vp-c-bg-alt);
  background: linear-gradient(to bottom, var(--vp-c-bg-alt), var(--vp-c-bg));
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  font-size: clamp(14px, 4vw, 15px);
  line-height: 1.7;
  overflow: hidden;
}

.copyright-card:hover {
  border-color: var(--vp-c-brand);
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(33, 122, 244, 0.12), 0 0 0 1px var(--vp-c-brand);
}

/* 公共信号图标装饰 */
.copyright-symbol {
  position: absolute;
  top: 12px;
  right: 16px;
  color: var(--vp-c-text-3);
  font-size: 20px;
  opacity: 0.7;
  transition: all 0.3s ease;
  pointer-events: none;
}

.copyright-card:hover .copyright-symbol {
  color: var(--vp-c-brand);
  opacity: 1;
  transform: scale(1.1);
}

/* 内容区布局 */
.copyright-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.copyright-item {
  display: flex;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 6px 8px;
}

.copyright-meta {
  display: inline-flex;
  align-items: center;
  color: var(--vp-c-text-2);
  white-space: nowrap;
  font-weight: 500;
}

.copyright-meta .shiguang-icon {
  margin-right: 4px;
  font-size: 1em;
}

.meta-text {
  font-variant: small-caps; /* 小型大写字母,提升设计感 */
}

.copyright-info {
  color: var(--vp-c-text-1);
  word-break: break-all;
}

.copyright-info a {
  color: var(--vp-c-brand-1);
  text-decoration: none;
  font-weight: 500;
  transition: color 0.2s;
}

.copyright-info a:hover {
  color: var(--vp-c-brand-dark);
  text-decoration: underline;
}

/* 图标使用 Emoji(更现代且无需字体) */
.shiguang-icon::before {
  transform: none !important;
  display: inline-block;
}

.shiguang-icon-user::before {
  content: "👤";
}
.shiguang-icon-title::before {
  content: "📝";
}
.shiguang-icon-link::before {
  content: "🔗";
}
.shiguang-icon-cc::before {
  content: "🌐";
}
.shiguang-icon-public::before {
  content: "📡";
}

/* 移动端适配 */
@media (max-width: 768px) {
  .copyright-card {
    padding: 12px 14px;
    font-size: 14px;
    border-radius: 10px;
  }

  .copyright-symbol {
    top: 10px;
    right: 12px;
    font-size: 18px;
  }

  .copyright-item {
    gap: 4px 8px;
  }

  .meta-text {
    font-size: 0.95em;
  }
}

@media (min-width: 1024px) {
  .copyright-card {
    padding: 16px 20px;
  }
}
</style>

注册组件

MyLayout.vue
vue
<script setup lang="ts">
import DefaultTheme from "vitepress/theme";
import DocFooterCopyright from "./DocFooterCopyright.vue";
</script>

<template>
  <DefaultTheme.Layout v-bind="$attrs">
    <!-- 底部版权声明组件 -->
    <template #doc-footer-before>
      <DocFooterCopyright />
    </template>
  </DefaultTheme.Layout>
</template>

<style scoped></style>

友链组件

说明

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

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

组件效果

友链组件

新建组件

  1. 新建组件与文件结构

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

bash
.vitepress
├─ theme
  ├─ components
  ├─ SLink
  ├─ index.vue
  └─ LinkItem.vue
  ├─ MyLayout.vue
  ├─ Twikoo.vue
  └─ index.ts
└─ config.mts
  1. 编写友链组件代码

    友链组件分为两部分:index.vue(负责分组展示与留言区)和 LinkItem.vue(单个友链卡片)。Twikoo.vue 是友链组件下方评论区必需组件,使用前需要安装 Twikoo 服务,完整代码如下:

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" ref="scrollContainer">
        <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">
                <img :src="link.avatar" :alt="link.name" loading="lazy" :class="{ irregular: link.irregular }">
              </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">
名称: 时光笔记
链接: https://notes.ksah.cn
头像: https://notes.ksah.cn/logo.png
描述: 干货满满的技术笔记</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 } from "vue";

/**
 * 单个友链的数据结构定义
 * @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 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");

/* 主容器样式 */
.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;
}

/* 内容组 */
.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%);
  }
}

/* 头像样式 */
.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;
}

.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;
}
.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">
    <!-- 标签 -->
    <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>
defineProps({
  data: {
    type: Object,
    required: true
  }
})

import { ref } from 'vue'
const imageFailed = ref(false)

const handleImageError = () => {
  imageFailed.value = true
}
</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;
}

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

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

.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;
  line-clamp: 2;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: normal;
  word-wrap: break-word;
}

.link-desc {
  font-size: 0.875rem;
  color: var(--vp-c-text-2);
  display: -webkit-box; /* 兼容 WebKit 旧版本 */
  display: box;
  -webkit-box-orient: vertical;
  box-orient: vertical;
  -webkit-line-clamp: 2; /* 兼容旧版 */
  line-clamp: 2; /* 标准属性(核心) */
  overflow: hidden;
  line-height: 1.4;
}
</style>
vue
<template>
  <div id="twikoo"></div>
</template>

<script setup lang="ts">
import { onMounted, watch } 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.cxcare.top/', // 换成你自己配置的域名
      el: '#twikoo'
    })
  }
}

// 监听路由刷新评论
watch(route, () => {
  initTwikoo()
})

onMounted(() => {
  initTwikoo()
})
</script>

注册组件

docs/.vitepress/theme/index.ts
ts
import SLink from "./components/SLink/index.vue";

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

使用方法

  • frontmatter 中设置 layoutfriend-link,启用友链布局模式
  • 建议设置 sidebar: false,界面更美观
  • 设置 comments: false 可关闭底部评论区
  • 设置 banner: false 可关闭顶部头像滚动
  • 设置 bannerButtonGroup: false 可关闭 banner 顶部按钮组
  • 设置 smallTitle 可设置 banner 左侧小标题
links.md
md
---
layout: friend-link
title: 我的友链
sidebar: false
links:
  - title: 鸣谢
    desc: "建站中学习和使用了以下博客/网站的技术和分享,特别鸣谢!\U0001FAE1"
    list:
      - name: 时光笔记
        link: 'https://kandu.cxcare.top/'
        avatar: 'https://kandu.cxcare.top/logo.svg'
        irregular: true
        tag: 编程爱好者
        descr: 干货满满的技术笔记
      - name: vitepress-teek
        link: 'https://vp.teek.top/'
        avatar: 'https://vitepress.yiov.top/logo.png'
        irregular: true
        tag: teek主题
        descr: 一个轻量、简洁高效、灵活配置,易于扩展的 VitePress 主题
      - name: VitePress
        link: 'https://vitepress.dev/zh/'
        avatar: 'https://vitepress.dev/vitepress-logo-mini.svg'
        irregular: true
        tag: vitepress官网
        descr: 由 Vite 和 Vue 驱动的静态站点生成器
      - name: VitePress 快速上手
        link: 'https://vitepress.yiov.top/'
        avatar: 'https://vitepress.dev/vitepress-logo-mini.svg'
        irregular: true
        tag: 美化教程
        descr: 如果你也想搭建它,那跟我一起做吧
      - name: 唯知笔记
        link: 'https://note.weizwz.com/'
        avatar: 'https://note.weizwz.com/logo.png'
        irregular: true
        tag: 实用笔记
        descr: 探索知识的无限可能
      - name: vitepress-theme-async
        link: 'vitepress-theme-async'
        avatar: 'https://vitepress-theme-async.imalun.com/logo@128x128.png'
        irregular: true
        tag: 主题参考
        descr: 一个简单而轻量级的 Vitepress 主题
      - name: Nólëbase Integrations
        link: 'https://nolebase-integrations.ayaka.io/pages/zh-CN/integrations/'
        avatar: 'https://nolebase-integrations.ayaka.io/android-chrome-192x192.png'
        irregular: true
        tag: 美化参考
        descr: 多元化的文档工程工具合集
      - name: vitepress plugins
        link: 'https://github.com/T-miracle/vitepress-plugins'
        avatar: 'https://avatars.githubusercontent.com/u/44044121?s=48&v=4'
        irregular: true
        tag: 实用插件
        descr: 3个 vitepress 插件
  - title: 传送门
    desc: "聚集众多优秀独立博客,随机传送 \U0001F680"
    list:
      - name: One Blog
        link: 'https://onedayxyy.cn/'
        avatar: 'https://onedayxyy.cn/favicon.ico'
        irregular: false
        tag: teek伙伴
        descr: 明心静性,爱自己
      - name: 威威 Blog
        link: 'https://dl-web.top/'
        avatar: 'https://dl-web.top/avatar/avatar.svg'
        irregular: false
        tag: teek伙伴
        descr: 人心中的成见是一座大山
      - name: Hyde Blog
        link: 'https://teek.seasir.top/'
        avatar: 'https://teek.seasir.top/favicon.ico'
        irregular: false
        tag: teek伙伴
        descr: 人心中的成见是一座大山
      - name: bugcool
        link: 'https://bugcool.cn/'
        avatar: 'https://bugcool.cn/wp-content/uploads/2025/08/cropped-s-1.png'
        irregular: false
        tag: 科技语者
        descr: 写代码哪有没 bug 的?
      - name: LI SIR
        link: 'https://lisir.me/'
        avatar: 'https://lisir.me/logo.png'
        irregular: false
        tag: 美化参考
        descr: 你的时间花在哪里,你的收获就在哪里
      - name: 王嘉祥
        link: 'https://blog.jiaxiang.wang/'
        avatar: 'https://blog.jiaxiang.wang/img/logo.webp'
        irregular: false
        tag: Zola博客纵享丝滑
        descr: 唱响科普和人生兴事,分享科技与美好生活
permalink: /links
prev:
  text: 特殊符号大全
  link: /notes/special-symbol
---

轮播图组件

组件效果

轮播图组件

新建组件

  1. 组件文件结构 首先在 VitePress 主题目录下,按以下结构创建 Carousel.vue 组件文件:
bash
.vitepress
├─ theme
  ├─ components
  ├─ Carousel.vue
  1. 编写版权声明组件代码,以下是完整的 Carousel.vue 代码,包含自适应样式、交互效果与可配置参数,可直接复制使用并根据需求修改配置:
Carousel.vue
vue
<template>
  <div class="carousel-container">
    <!-- 轮播图容器 -->
    <div class="carousel-wrapper" :style="{ height: `${height}px` }">
      <!-- 图片展示区域 -->
      <div class="carousel-slide">
        <div
          class="image-container"
          :class="{ 'fade-enter': isTransitioning }"
          ref="imageContainer"
        >
          <!-- 当前显示的图片 -->
          <img
            v-if="currentImage"
            :src="currentImage"
            :alt="imageAlt"
            class="carousel-image"
            @load="handleImageLoad"
          />

          <!-- 全屏按钮 -->
          <button
            class="fullscreen-btn"
            @click="toggleFullscreen"
            :title="isFullscreen ? '退出全屏' : '全屏查看'"
            v-if="currentImage"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width="24"
              height="24"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
            >
              <polyline v-if="!isFullscreen" points="15 3 21 3 21 9"></polyline>
              <polyline v-if="!isFullscreen" points="9 21 3 21 3 15"></polyline>
              <line v-if="!isFullscreen" x1="21" y1="3" x2="14" y2="10"></line>
              <line v-if="!isFullscreen" x1="3" y1="21" x2="10" y2="14"></line>

              <polyline
                v-if="isFullscreen"
                points="6 18 12 18 12 24"
              ></polyline>
              <polyline v-if="isFullscreen" points="18 6 12 6 12 0"></polyline>
              <line v-if="isFullscreen" x1="18" y1="6" x2="24" y2="12"></line>
              <line v-if="isFullscreen" x1="6" y1="18" x2="0" y2="12"></line>
            </svg>
          </button>

          <!-- 全屏状态下的控制按钮 -->
          <div
            class="fullscreen-controls"
            v-if="isFullscreen && images.length > 1"
          >
            <button class="control-btn prev-btn" @click="showPreviousImage">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="24"
                height="24"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
              >
                <polyline points="15 18 9 12 15 6"></polyline>
              </svg>
            </button>

            <button class="control-btn next-btn" @click="showNextImage">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="24"
                height="24"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
              >
                <polyline points="9 18 15 12 9 6"></polyline>
              </svg>
            </button>
          </div>

          <!-- 初始加载状态指示器 -->
          <div v-if="isInitialLoading" class="loading-indicator">
            <div class="spinner"></div>
            <p>加载图片中...</p>
          </div>

          <!-- 初始加载错误提示 -->
          <div v-if="hasInitialError" class="error-message">
            <p>图片加载失败</p>
            <button @click="initializeCarousel" class="retry-button">
              重新加载
            </button>
          </div>
        </div>
      </div>

      <!-- 非全屏状态下的控制按钮 -->
      <button
        class="control-btn prev-btn"
        @click="showPreviousImage"
        :disabled="images.length <= 1 || isFullscreen || isInitialLoading"
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <polyline points="15 18 9 12 15 6"></polyline>
        </svg>
      </button>

      <button
        class="control-btn next-btn"
        @click="showNextImage"
        :disabled="images.length <= 1 || isFullscreen || isInitialLoading"
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <polyline points="9 18 15 12 9 6"></polyline>
        </svg>
      </button>
    </div>

    <!-- 指示器 -->
    <div
      class="carousel-indicators"
      v-if="images.length > 1 && !isFullscreen && !isInitialLoading"
    >
      <button
        v-for="(img, index) in images"
        :key="index"
        class="indicator-dot"
        :class="{ active: currentIndex === index }"
        @click="goToImage(index)"
        :title="`查看第 ${index + 1} 张图片`"
      ></button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";

// 组件属性定义
const props = defineProps({
  // 轮播图高度
  height: {
    type: Number,
    default: 400,
  },
  // 自动轮播间隔时间(毫秒)
  interval: {
    type: Number,
    default: 8000,
  },
  // 图片API地址
  imageApi: {
    type: String,
    required: true,
  },
  // 图片alt文本
  imageAlt: {
    type: String,
    default: "轮播图片",
  },
  // 初始加载的图片数量
  initialImages: {
    type: Number,
    default: 3,
    validator: (value) => value >= 1, // 至少加载1张初始图片
  },
  // 预加载的图片数量(当前图片之后的数量)
  preloadCount: {
    type: Number,
    default: 2,
    validator: (value) => value >= 1, // 至少预加载1张
  },
  // 初始图片加载的间隔时间(毫秒)
  initialLoadDelay: {
    type: Number,
    default: 500,
  },
  // 最大重试次数
  maxRetry: {
    type: Number,
    default: 3,
  },
});

// 状态管理
const images = ref([]); // 已加载并可显示的图片列表
const preloadQueue = ref([]); // 预加载队列(即将加入轮播的图片)
const currentIndex = ref(0); // 当前显示图片的索引
const currentImage = ref(""); // 当前显示的图片URL
const isInitialLoading = ref(false); // 初始加载状态
const hasInitialError = ref(false); // 初始加载错误状态
const isTransitioning = ref(false); // 图片切换过渡状态
const isFullscreen = ref(false); // 全屏状态
const intervalId = ref(null); // 自动轮播定时器ID
const isPreloading = ref(false); // 预加载状态(用于避免重复预加载)

// DOM引用
const imageContainer = ref(null);

// 生成唯一标识符,用于确保图片请求的唯一性
const generateUniqueId = () => {
  return Date.now() + "-" + Math.random().toString(36).substring(2, 10);
};

// 加载单个图片(返回Promise,用于预加载)
const loadImage = async () => {
  try {
    const uniqueId = generateUniqueId();
    const imageUrl = `${props.imageApi}?uid=${uniqueId}`;

    // 检查图片是否已在列表中(避免重复)
    const allImages = [...images.value, ...preloadQueue.value];
    if (allImages.includes(imageUrl)) {
      return loadImage(); // 如果重复则重新加载
    }

    // 创建Image对象进行预加载
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.src = imageUrl;

      img.onload = () => {
        resolve(imageUrl); // 加载成功返回URL
      };

      img.onerror = () => {
        reject(new Error(`无法加载图片: ${imageUrl}`));
      };
    });
  } catch (error) {
    console.error("图片加载失败:", error);
    throw error;
  }
};

// 批量预加载图片
const preloadImages = async (count) => {
  // 如果正在预加载中,则不重复执行
  if (isPreloading.value) return;

  isPreloading.value = true;

  try {
    // 创建指定数量的加载任务
    const loadPromises = Array.from({ length: count }, () =>
      loadImageWithRetry()
    );
    const newImages = await Promise.all(loadPromises);

    // 将预加载的图片添加到队列
    preloadQueue.value = [...preloadQueue.value, ...newImages];
  } catch (error) {
    console.error("批量预加载失败:", error);
  } finally {
    isPreloading.value = false;
  }
};

// 带重试机制的图片加载
const loadImageWithRetry = async (retry = 0) => {
  try {
    return await loadImage();
  } catch (error) {
    if (retry < props.maxRetry) {
      // 重试前等待一段时间,避免立即重试
      await new Promise((resolve) => setTimeout(resolve, 1000 * (retry + 1)));
      return loadImageWithRetry(retry + 1);
    } else {
      throw new Error(`超过最大重试次数(${props.maxRetry}次)`);
    }
  }
};

// 初始化轮播(加载初始图片)
const initializeCarousel = async () => {
  isInitialLoading.value = true;
  hasInitialError.value = false;
  images.value = [];
  preloadQueue.value = [];
  currentIndex.value = 0;
  currentImage.value = "";

  try {
    // 串行加载初始图片,避免同时请求导致重复
    for (let i = 0; i < props.initialImages; i++) {
      // 每张图片加载前增加延迟
      if (i > 0) {
        await new Promise((resolve) =>
          setTimeout(resolve, props.initialLoadDelay)
        );
      }

      const imageUrl = await loadImageWithRetry();
      images.value.push(imageUrl);
    }

    // 初始图片加载完成后,立即预加载下一批
    await preloadImages(props.preloadCount);

    // 设置第一张图片为当前显示图片
    currentImage.value = images.value[0];
  } catch (error) {
    console.error("轮播初始化失败:", error);
    hasInitialError.value = true;
  } finally {
    isInitialLoading.value = false;
  }
};

// 检查并补充预加载队列
const checkAndPreload = async () => {
  // 当预加载队列中的图片数量不足时,补充预加载
  if (preloadQueue.value.length < props.preloadCount) {
    const needToLoad = props.preloadCount - preloadQueue.value.length;
    await preloadImages(needToLoad);
  }
};

// 显示下一张图片
const showNextImage = () => {
  if (images.value.length <= 1) return;

  isTransitioning.value = true;

  // 计算下一张图片的索引
  const nextIndex = (currentIndex.value + 1) % images.value.length;

  // 如果下一张是列表中的最后几张,从预加载队列补充新图片
  if (
    nextIndex >= images.value.length - props.preloadCount &&
    preloadQueue.value.length > 0
  ) {
    // 从预加载队列中取出一张图片添加到轮播列表
    const newImage = preloadQueue.value.shift();
    images.value.push(newImage);
  }

  // 更新当前索引和图片
  currentIndex.value = nextIndex;
  currentImage.value = images.value[nextIndex];

  // 触发补充预加载
  checkAndPreload();

  // 过渡动画结束
  setTimeout(() => {
    isTransitioning.value = false;
  }, 500);
};

// 显示上一张图片
const showPreviousImage = () => {
  if (images.value.length <= 1) return;

  isTransitioning.value = true;
  currentIndex.value =
    (currentIndex.value - 1 + images.value.length) % images.value.length;
  currentImage.value = images.value[currentIndex.value];

  setTimeout(() => {
    isTransitioning.value = false;
  }, 500);
};

// 跳转到指定图片
const goToImage = (index) => {
  if (index === currentIndex.value || index < 0 || index >= images.value.length)
    return;

  isTransitioning.value = true;
  currentIndex.value = index;
  currentImage.value = images.value[index];

  // 如果跳转到较后的位置,检查是否需要补充预加载
  if (index >= images.value.length - props.preloadCount) {
    checkAndPreload();
  }

  setTimeout(() => {
    isTransitioning.value = false;
  }, 500);
};

// 切换全屏状态
const toggleFullscreen = async () => {
  if (!imageContainer.value) return;

  if (isFullscreen.value) {
    // 退出全屏
    if (document.exitFullscreen) {
      await document.exitFullscreen();
    } else if (document.webkitExitFullscreen) {
      await document.webkitExitFullscreen();
    } else if (document.msExitFullscreen) {
      await document.msExitFullscreen();
    }
  } else {
    // 进入全屏
    if (imageContainer.value.requestFullscreen) {
      await imageContainer.value.requestFullscreen();
    } else if (imageContainer.value.webkitRequestFullscreen) {
      await imageContainer.value.webkitRequestFullscreen();
    } else if (imageContainer.value.msRequestFullscreen) {
      await imageContainer.value.msRequestFullscreen();
    }
  }
};

// 监听全屏状态变化
const handleFullscreenChange = () => {
  isFullscreen.value = !!document.fullscreenElement;
};

// 启动自动轮播
const startAutoPlay = () => {
  // 清除已有的定时器
  if (intervalId.value) {
    clearInterval(intervalId.value);
  }

  // 设置新的定时器
  intervalId.value = setInterval(() => {
    if (!isInitialLoading.value && !hasInitialError.value) {
      showNextImage();
    }
  }, props.interval);
};

// 组件挂载时初始化
onMounted(async () => {
  // 初始化轮播
  await initializeCarousel();

  // 启动自动轮播
  if (images.value.length > 1) {
    startAutoPlay();
  }

  // 监听全屏状态变化事件
  document.addEventListener("fullscreenchange", handleFullscreenChange);
  document.addEventListener("webkitfullscreenchange", handleFullscreenChange);
  document.addEventListener("msfullscreenchange", handleFullscreenChange);
});

// 组件卸载时清理
onUnmounted(() => {
  // 清除定时器
  if (intervalId.value) {
    clearInterval(intervalId.value);
  }

  // 移除事件监听
  document.removeEventListener("fullscreenchange", handleFullscreenChange);
  document.removeEventListener(
    "webkitfullscreenchange",
    handleFullscreenChange
  );
  document.removeEventListener("msfullscreenchange", handleFullscreenChange);

  // 如果处于全屏状态,退出全屏
  if (isFullscreen.value) {
    if (document.exitFullscreen) {
      document.exitFullscreen();
    } else if (document.webkitExitFullscreen) {
      document.webkitExitFullscreen();
    } else if (document.msExitFullscreen) {
      document.msExitFullscreen();
    }
  }
});

// 监听轮播间隔变化,重新启动自动轮播
watch(
  () => props.interval,
  () => {
    startAutoPlay();
  }
);

// 处理图片加载完成事件
const handleImageLoad = () => {
  // 图片加载完成后关闭过渡状态
  isTransitioning.value = false;
};
</script>

<style scoped>
.carousel-container {
  position: relative;
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
}

.carousel-wrapper {
  position: relative;
  width: 100%;
  overflow: hidden;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.carousel-slide {
  position: relative;
  width: 100%;
  height: 100%;
}

.image-container {
  position: relative;
  width: 100%;
  height: 100%;
}

.carousel-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity 0.5s ease-in-out;
}

/* 全屏按钮样式 */
.fullscreen-btn {
  position: absolute;
  bottom: 16px;
  right: 16px;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background-color: rgba(0, 0, 0, 0.5);
  color: white;
  border: none;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  transition: all 0.3s ease;
  z-index: 4;
  backdrop-filter: blur(2px);
}

.fullscreen-btn:hover {
  background-color: rgba(0, 0, 0, 0.7);
  transform: scale(1.1);
}

/* 全屏状态下的控制按钮容器 */
.fullscreen-controls {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none; /* 让点击穿透到图片上 */
}

/* 全屏状态下的控制按钮 */
.fullscreen-controls .control-btn {
  pointer-events: auto; /* 恢复按钮的点击能力 */
  background-color: rgba(0, 0, 0, 0.7);
}

/* 全屏状态下的样式调整 */
:fullscreen .carousel-image {
  object-fit: contain;
  background-color: #000;
}

:-webkit-full-screen .carousel-image {
  object-fit: contain;
  background-color: #000;
}

:-ms-fullscreen .carousel-image {
  object-fit: contain;
  background-color: #000;
}

:fullscreen .fullscreen-btn {
  background-color: rgba(0, 0, 0, 0.7);
}

/* 过渡动画 */
.fade-enter {
  animation: fadeIn 0.5s ease-in-out;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* 加载指示器 */
.loading-indicator {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  background-color: rgba(255, 255, 255, 0.8);
  z-index: 10;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 16px;
}

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

/* 错误消息 */
.error-message {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  background-color: rgba(255, 255, 255, 0.8);
  z-index: 10;
  padding: 20px;
  text-align: center;
}

.retry-button {
  margin-top: 16px;
  padding: 8px 16px;
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.retry-button:hover {
  background-color: #2980b9;
}

/* 控制按钮 */
.control-btn {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background-color: rgba(0, 0, 0, 0.3);
  color: white;
  border: none;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  transition: background-color 0.3s, transform 0.2s;
  z-index: 5;
}

.control-btn:hover {
  background-color: rgba(0, 0, 0, 0.5);
  transform: translateY(-50%) scale(1.1);
}

.control-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: translateY(-50%);
}

.prev-btn {
  left: 16px;
}

.next-btn {
  right: 16px;
}

/* 指示器 */
.carousel-indicators {
  display: flex;
  justify-content: center;
  gap: 8px;
  margin-top: 16px;
}

.indicator-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: #ddd;
  border: none;
  cursor: pointer;
  transition: background-color 0.3s, transform 0.2s;
}

.indicator-dot.active {
  background-color: #3498db;
  transform: scale(1.2);
}

.indicator-dot:hover:not(.active) {
  background-color: #bbb;
}

/* 响应式调整 */
@media (max-width: 768px) {
  .control-btn {
    width: 36px;
    height: 36px;
  }

  .fullscreen-btn {
    width: 36px;
    height: 36px;
    bottom: 12px;
    right: 12px;
  }

  .carousel-indicators {
    gap: 6px;
  }

  .indicator-dot {
    width: 8px;
    height: 8px;
  }
}
</style>

使用方法

新建一个 markdown 文件,填写下面内容即可显示

images.md
markdown
---
permalink: /images
sidebar: false
outline: false
---

<script setup>
import Carousel from './.vitepress/theme/components/Carousel.vue'
</script>

## 自建 API

<Carousel
  imageApi="https://rpic.cxcare.top/api"
  height="500"
  interval="6000"
  imageAlt="自建API图片"
/>

## 威威 API

<Carousel
  imageApi="https://random.dl-web.top"
  height="500"
  interval="6000"
  imageAlt="威威随机图片"
/>

## One API

<Carousel
  imageApi="https://imgapi.onedayxyy.cn"
  height="500"
  interval="6000"
  imageAlt="威威随机图片"
/>

## 白木 API

<Carousel
  imageApi="https://baimu.live/api/tp/acg/ecy.php"
  height="500"
  interval="6000"
  imageAlt="威威随机图片"
/>

VitePress Algolia Twikoo EdgeOne Copyright