警告:

自建独立博客需要一定的相关知识,且会比传统动态建站工具要复杂得多
你可能会遇到一系列的问题,请确保你有独立解决问题的能力
假如你想修改主题的某些组件或样式,你还需要掌握 Vue.js 的相关知识
虽然如今你可以借助 AI 来辅助完成一些需求,但知其然,知其所以然
是每一个优秀的人都应该具备的基本素养。

播放器小胶囊去除歌词 ​

  • 因为播放器胶囊在启用SSL的情况下不知道为什么歌词显示“Not available”
    而且歌词本身做的也烂所以整个砍掉

TIPS

请先确保你部署了音乐API以及在 .vitepress/theme/assets/themeConfig.mjs
enable: true音乐播放器

CSS
<style lang="scss" scoped>
.player {
  height: 42px;
  margin-top: 12px;
  transition: transform 0.3s;
  cursor: pointer;
  .player-content {
    margin: 0;
    width: fit-content;
    border-radius: 50px;
    overflow: hidden;
    color: var(--main-font-color);
    font-family: var(--main-font-family);
    background-color: var(--main-card-background);
    border: 1px solid var(--main-card-border);
    box-shadow: 0 6px 10px -4px var(--main-dark-shadow);
    transition: all 0.3s;
    :deep(.aplayer-body) {
      display: flex;
      flex-direction: row;
      align-items: center;
      padding: 6px;
      padding-right: 12px;
      pointer-events: none;
      .aplayer-pic {
        width: 30px;
        height: 30px;
        min-width: 30px;
        border-radius: 50%;
        margin-right: 8px;
        outline: 1px solid var(--main-card-border);
        animation: rotate 20s linear infinite;
        animation-play-state: paused;
        z-index: 2;
        .aplayer-button {
          display: none;
        }
      }
      .aplayer-info {
        display: flex;
        flex-direction: row;
        align-items: center;
        height: auto;
        margin: 0;
        padding: 0;
        border: none;
        .aplayer-music {
          margin: 0;
          padding: 0;
          height: auto;
          display: flex;
          line-height: normal;
          z-index: 2;
          .aplayer-title {
            line-height: normal;
            display: inline-block;
            white-space: nowrap;
            max-width: 120px;
            overflow: hidden;
            text-overflow: ellipsis;
          }
          .aplayer-author {
            display: none;
          }
        }
        .aplayer-lrc {
          // 移除歌词显示:始终隐藏 //
          margin: 0 !important;
          opacity: 0 !important;
          width: 0 !important;
          margin-left: 0 !important; // 确保移除任何左侧间距
          z-index: 2;
          transition: none; // 移除过渡效果
          &::before,
          &::after {
            display: none;
          }
          .aplayer-lrc-contents {
            display: none; // 确保歌词内容不显示
          }
        }
        .aplayer-controller {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          z-index: 0;
          .aplayer-time {
            display: none;
          }
          .aplayer-bar-wrap {
            margin: 0;
            padding: 0;
            opacity: 0;
            transition: opacity 0.3s;
            .aplayer-bar {
              height: 100%;
              background: transparent;
              .aplayer-loaded {
                display: none;
              }
              .aplayer-played {
                height: 100%;
                background: var(--main-color-white) !important;
                transition: width 0.3s;
              }
            }
          }
        }
      }
      .aplayer-notice,
      .aplayer-miniswitcher {
        display: none;
      }
    }
    :deep(.aplayer-list) {
      display: none;
    }
    &::after {
      content: "播放音乐";
      position: absolute;
      top: 0;
      left: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100%;
      height: 100%;
      font-size: 14px;
      opacity: 0;
      color: var(--main-card-background);
      background-color: var(--main-color);
      pointer-events: none;
      transition: opacity 0.3s;
      z-index: 3;
    }
    &:hover {
      border-color: var(--main-color);
      box-shadow: 0 8px 16px -4px var(--main-color-bg);
      &::after {
        opacity: 1;
      }
    }
  }
  &.playing {
    .player-content {
      color: var(--main-card-background);
      background-color: var(--main-color);
      border: 1px solid var(--main-color);
      :deep(.aplayer-body) {
        .aplayer-pic {
          animation-play-state: running;
        }
        .aplayer-info {
          .aplayer-lrc {
            // 移除歌词显示:播放状态下也保持隐藏
            opacity: 0 !important;
            width: 0 !important;
          }
          .aplayer-controller {
            .aplayer-bar-wrap {
              opacity: 1;
            }
          }
        }
      }
      &::after {
        opacity: 0;
      }
    }
  }
  &:active {
    transform: scale(0.98);
  }
  @media (max-width: 768px) {
    display: none;
  }
}
</style>

修改说明:

  1. 在 .aplayer-lrc 中添加了以下样式:
CSS
margin: 0 !important;
opacity: 0 !important;
width: 0 !important;
margin-left: 0 !important; // 确保移除任何左侧间距
transition: none; // 移除过渡效果
  • 这将强制歌词容器的宽度、透明度和外边距始终为零,从而完全隐藏歌词,并移除任何使它出现或消失的动画效果。
  1. 在 .aplayer-lrc-contents 中添加了 display: none;:
CSS
.aplayer-lrc-contents {
  display: none; // 确保歌词内容不显示
}
  • 这进一步确保了歌词内容本身不会被渲染。
  1. 在 .playing 状态下的 .aplayer-lrc 中也做了类似修改:
CSS
.aplayer-lrc {
    // 移除歌词显示:播放状态下也保持隐藏
    opacity: 0 !important;
    width: 0 !important;
}
  • 这确保了即使播放器处于播放状态,歌词也始终保持隐藏,不会出现任何宽度变化。

自定义鼠标样式 ​

在 .vitepress/theme/App.vue 添加以下高亮内容:

vue
import { storeToRefs } from "pinia";
import { mainStore } from "@/store";
import { calculateScroll, specialDayGray } from "@/utils/helper";
import cursorInit from "@/utils/cursor.js";

const route = useRoute();
const store = mainStore();
const { frontmatter, page, theme } = useData();
const { loadingStatus, footerIsShow, themeValue, themeType, backgroundType, fontFamily, fontSize } =
  storeToRefs(store);

  onMounted(() => {
  // 自定义鼠标
  cursorInit();
  })

// 右键菜单
const rightMenuRef = ref(null);

在 .vitepress/theme/style/main.scss 添加以下内容

CSS
// 自定义鼠标
#cursor {
  position: fixed;
  width: 18px;
  height: 18px;
  background: #fff;
  border-radius: 25px;
  opacity: 0.25;
  z-index: 10086;
  pointer-events: none;
  transition: 0.2s ease-in-out;
  transition-property: background, opacity, transform;

  &.hidden {
    opacity: 0;
  }

  &.active {
    opacity: 0.5;
    transform: scale(0.5);
  }
}

创建 .vitepress/theme/utils/cursor.js 文件

js
import { isEqual } from "lodash-es";

let mainCursor;

const lerp = (a, b, n) => {
  if (Math.round(a) === b) {
    return b;
  }
  return (1 - n) * a + n * b;
};

const getStyle = (el, attr) => {
  try {
    return window.getComputedStyle ? window.getComputedStyle(el)[attr] : el.currentStyle[attr];
  } catch (e) {
    console.error(e);
  }
  return false;
};

const cursorInit = () => {
  mainCursor = new Cursor();
  return mainCursor;
};

class Cursor {
  constructor() {
    this.pos = {
      curr: null,
      prev: null,
    };
    this.pt = [];
    this.create();
    this.init();
    this.render();
  }

  move(left, top) {
    this.cursor.style["left"] = `${left}px`;
    this.cursor.style["top"] = `${top}px`;
  }

  create() {
    if (!this.cursor) {
      this.cursor = document.createElement("div");
      this.cursor.id = "cursor";
      this.cursor.classList.add("xs-hidden");
      this.cursor.classList.add("hidden");
      document.body.append(this.cursor);
    }

    // 根据用户代理或屏幕宽度判断是否为移动设备
    const isMobile = /Mobi|Android/i.test(navigator.userAgent) //|| window.innerWidth <= 768; // 768px 是平板/手机的常见断点

    if (isMobile) {
      this.cursor.classList.add("hidden"); // 确保自定义光标被隐藏
      // 从所有元素中移除自定义光标样式
      if (this.scr) {
        this.scr.remove();
      }
      document.body.style.cursor = 'auto'; // 恢复默认光标
      return; // 停止为移动设备创建光标
    }


    var el = document.getElementsByTagName("*");
    for (let i = 0; i < el.length; i++)
      if (getStyle(el[i], "cursor") == "pointer") this.pt.push(el[i].outerHTML);

    document.body.appendChild((this.scr = document.createElement("style")));
    this.scr.innerHTML = `* {cursor: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8' width='10px' height='10px'><circle cx='4' cy='4' r='4' fill='white' /></svg>") 4 4, auto !important}`;
  }
  refresh() {
    this.scr.remove();
    this.cursor.classList.remove("active");
    this.pos = {
      curr: null,
      prev: null,
    };
    this.pt = [];

    this.create();
    this.init();
    this.render();
  }

  init() {
    // 只有在非移动设备上才绑定鼠标事件
    const isMobile = /Mobi|Android/i.test(navigator.userAgent) //|| window.innerWidth <= 768;
    if (isMobile) {
        return;
    }

    document.onmousemove = (e) => {
      this.pos.curr == null && this.move(e.clientX - 8, e.clientY - 8);
      this.pos.curr = {
        x: e.clientX - 8,
        y: e.clientY - 8,
      };
      this.cursor.classList.remove("hidden");
      this.render();
    };
    document.onmouseenter = () => this.cursor.classList.remove("hidden");
    document.onmouseleave = () => this.cursor.classList.add("hidden");
    document.onmousedown = () => this.cursor.classList.add("active");
    document.onmouseup = () => this.cursor.classList.remove("active");
  }

  render() {
    // 只有在非移动设备上才进行渲染
    const isMobile = /Mobi|Android/i.test(navigator.userAgent) //|| window.innerWidth <= 768;
    if (isMobile) {
        return;
    }

    if (this.pos.prev) {
      this.pos.prev.x = lerp(this.pos.prev.x, this.pos.curr.x, 0.35);
      this.pos.prev.y = lerp(this.pos.prev.y, this.pos.curr.y, 0.35);
      this.move(this.pos.prev.x, this.pos.prev.y);
    } else {
      this.pos.prev = this.pos.curr;
    }
    if (!isEqual(this.pos.curr, this.pos.prev)) {
      requestAnimationFrame(() => this.render());
    }
  }
}

export default cursorInit;

修复右键菜单复制地址 ​

在 theme/components/RightMenu.vue 删除以下内容:

vue
const pageLink = theme.value.site + router.route.path;
if (pageLink) copyText(pageLink);

替换成以下内容:

vue
const pageLink = theme.value?.siteMeta?.site + router.route.path;
if (!pageLink) {
    $message.error("复制失败:无法获取页面地址");
    return;
}
copyText(pageLink);

更新凌晨问好内容 ​

在 theme/utils/helper.mjs 里更改

  • 凌晨好,昨晚睡得怎么样?
  • 凌晨好,夜深了。
  • 早上好,今天也要开心哦!
  • 早上好,昨晚睡得怎么样?

修复参考资料 ​

在参考资料传入title过长的时候会造成:

  • 文字被挤出框外
  • 移动端宽度被撑大 只需要用下面直接全部覆盖
    theme/components/References.vue 即可修复
vue
<!-- 参考资料 -->
<template>
  <div v-if="limitedReferences.length" class="references s-card">
    <div class="title">
      <i class="iconfont icon-quote"></i>
      <span class="title-text">参考资料</span>
    </div>
    <ul class="list">
      <a
        v-for="(item, index) in limitedReferences"
        :key="index"
        :href="item.url"
        class="list-item"
        target="_blank"
      >
        <span class="item-title">{{ item.title }}</span>
      </a>
    </ul>
  </div>
</template>

<script setup>
import { useData } from 'vitepress';

const { frontmatter } = useData(); // frontmatter 已经在这里声明了
const screenWidth = ref(0);
// 直接使用 frontmatter 来初始化 references
const references = ref(frontmatter.value?.references || []); // 不需要重新声明 frontmatter

// 计算属性,用于动态限制标题字数
const limitedReferences = computed(() => {
  //2025.06.12更新:在 Next.js 的服务端渲染过程中,应用会在服务器端先进行渲染
  //而在服务器端的 JavaScript 环境中,并没有浏览器提供的 window 对象。
  //最简单的解决方法是确保在客户端代码中访问 window
  //可以通过判断代码是否在浏览器环境中运行来避免在服务器端渲染时执行涉及 window 的代码
  //使用 typeof window !== 'undefined' 来判断
  onMounted(() => {
  // 只有在浏览器环境才会执行这里的代码
  if (typeof window !== 'undefined') {
    const screenWidth = window.innerWidth;
  }
});
  // 假设你想让标题占据屏幕宽度的某个百分比,例如 70%
  // 这里的 '16' 是一个估算值,代表一个汉字或英文字符的平均像素宽度。
  // 你需要根据你的字体大小和字体类型进行精确调整。
  const maxChars = Math.floor((screenWidth * 0.7) / 16); // 估算最大字符数

  return references.value.map(item => {
    let title = item.title;
    if (title.length > maxChars) {
      title = title.substring(0, maxChars) + '...'; // 截断并添加省略号
    }
    return { ...item, title: title };
  });
});

// 在组件挂载时监听窗口大小变化,以便动态调整字数限制
onMounted(() => {
  window.addEventListener('resize', updateReferences);
  updateReferences(); // 首次加载时也更新
});

// 在组件卸载前移除事件监听器
onBeforeUnmount(() => {
  window.removeEventListener('resize', updateReferences);
});

// 更新 references 的函数,触发 computed 重新计算
const updateReferences = () => {
  // 这里的 references.value 应该从 useData() 提供的最新 frontmatter 中获取
  // 因为 frontmatter 已经是响应式的,当数据变化时,computed 会自动更新。
  // 如果 frontmatter.value?.references 不会自动更新,你可能需要确保 useData() 返回的是响应式数据
  // 或者在 VitePress 的生命周期中,当 frontmatter 更新时,手动触发 references.value 的更新
  // 在 VitePress 中,useData() 返回的数据通常是响应式的,所以这里可能不需要手动更新 references.value
  // 如果需要,可以这样:
  references.value = frontmatter.value?.references || [];
};
</script>

<style lang="scss" scoped>
.references {
  margin: 1rem 0;
  padding: 18px;
  margin-top: 2rem;
  background-color: var(--main-card-second-background);
  .title {
    display: flex;
    flex-direction: row;
    align-items: center;
    color: var(--main-font-second-color);
    font-size: 15px;
    margin-bottom: 0.8rem;
    .iconfont {
      margin-right: 4px;
      font-size: 18px;
      color: var(--main-font-second-color);
      opacity: 0.6;
    }
  }
  .list {
    display: flex;
    flex-direction: column;
    margin: 0;
    list-style-type: none;
    padding-left: 0.4rem;
    .list-item {
      display: inline-flex;
      flex-direction: row;
      align-items: center;
      position: relative;
      // width: max-content; // 如果你希望文本换行,这个可能需要调整或移除
      // 通常我们会让它占据可用宽度
      width: 100%; // 让列表项占据其父容器的全部宽度,以便文本换行
      padding-left: 1rem;
      margin-bottom: 0.4rem;
      overflow: auto;
      transition: color 0.3s;
      .item-title {
        padding-bottom: 2px;
        white-space: normal; /* 允许文本正常换行 */
        word-break: break-word; /* 允许在单词内换行以防止溢出 */
        overflow-wrap: break-word; /* 标准化属性,与 word-wrap: break-word 相同 */
      }
      &:last-child {
        margin-bottom: 0;
      }
      &::before {
        content: "";
        position: absolute;
        left: 0;
        width: 8px;
        height: 8px;
        opacity: 0.6;
        background-color: var(--main-font-color);
        border-radius: 50%;
        transition: background-color 0.3s;
      }
      &::after {
        content: "";
        position: absolute;
        left: 0;
        bottom: 0;
        height: 2px;
        width: 0;
        margin-left: 1rem;
        background-color: var(--main-color);
        transition: width 0.3s;
      }
      &:hover {
        color: var(--main-color);
        &::before {
          background-color: var(--main-color);
        }
        &::after {
          width: calc(100% - 1rem);
        }
      }
    }
  }
}
</style>

遇到的问题和解决方案

问题一:参考资料标题过长,在小屏幕设备上会超出容器。

  • 解决方案:
    • CSS 文本换行:通过 CSS 属性 white-space: normal; 允许文本正常换行,并结合 word-break: break-word; 和 overflow-wrap: break-word; 确保长单词也能在需要时内部断开,防止溢出容器 。
    • 调整容器宽度:将 .list-item 的宽度设置为 100%,使其占据父容器的全部可用宽度,以便文本能够正确换行 。
    • Flexbox 布局优化:在 .item-title 上使用 flex-grow: 1; 让标题占用剩余空间,并添加 min-width: 0; 来解决 Flex 容器中长文本可能不换行的问题.

问题二:需要根据屏幕宽度百分比动态限制标题字数。

  • 解决方案: 纯 CSS 无法直接实现根据屏幕宽度百分比动态计算和限制字数的功能。
    • JavaScript 辅助实现:通过 JavaScript 获取当前的屏幕宽度 (window.innerWidth),根据预设的百分比和字体大小估算出最大可容纳的字符数,然后对超过这个字数限制的标题文本进行截断,并在末尾添加省略号。
    • 数据绑定:在 Vue 模板中,需要将渲染的数据源从原始的 references 切换为经过 JavaScript 处理后的 limitedReferences.
    • 响应式更新:在组件挂载 (onMounted) 时监听窗口大小变化 (window.addEventListener('resize')),并在组件卸载前 (onBeforeUnmount) 移除监听器,以确保在屏幕尺寸变化时字数限制能够动态更新。

问题三:在 Vue <script setup> 中出现“无法重新声明块范围变量‘frontmatter’”的错误。

  • 解决方案:
    • 这个错误是由于在 <script setup> 中 frontmatter 变量被重复声明导致的.
    • const { frontmatter } = useData(); 已经完成了 frontmatter 的声明和解构 [cite: 2, 3],后续代码直接使用这个已声明的 frontmatter 变量即可,无需再次声明或解构。例如,直接使用 references.value = frontmatter.value?.references || []; 来初始化或更新 references 变量.

修复跳转安全中心 ​

在 theme/utils/commonTools.mjs 文件内

js
// 中转页地址
const redirectPage = "/redirect.html";

/redirect 后加上 .html

slogan点击切换 ​

第一次打开显示默认标语,而不是一言。

顺序如下:

初次打开(默认标语)等待3秒后 切换成新获取的一言 单击一次一言,切换到默认标语,再往后之后的每一次点击都是获取一句新的hitokoto显示

theme/components/Banner.vue

vue
<template>
  <div v-if="type === 'text'" :class="['banner', bannerType]" id="main-banner">
    <h1 class="title">你好,欢迎来到{{ theme.siteMeta.title }}</h1>
    <div class="subtitle">
      <Transition name="fade" mode="out-in">
        <span :key="displayText" class="text" @click="toggleHitokoto">
          {{ displayText }}
        </span>
      </Transition>
    </div>
    <Transition name="fade" mode="out-in">
      <i v-if="height === 'full'" class="iconfont icon-up" @click="scrollToHome" />
    </Transition>
  </div>
  <div
    v-else-if="type === 'page'"
    :class="['banner-page', 's-card', { image }]"
    :style="{
      backgroundImage: image ? `url(${image})` : null,
    }"
  >
    <div class="top">
      <div class="title">
        <span class="title-small">{{ title }}</span>
        <span class="title-big">{{ desc }}</span>
      </div>
      <div class="top-right">
        <slot name="header-slot" />
      </div>
    </div>
    <slot />
    <div class="footer">
      <div class="footer-left">
        {{ footer }}
      </div>
      <div class="footer-right">
        <slot name="footer-slot" />
      </div>
    </div>
  </div>
</template>

<script setup>
<template>
  <div v-if="type === 'text'" :class="['banner', bannerType]" id="main-banner">
    <h1 class="title">你好,欢迎来到{{ theme.siteMeta.title }}</h1>
    <div class="subtitle">
      <Transition name="fade" mode="out-in">
        <span :key="displayText" class="text" @click="toggleHitokoto">
          {{ displayText }}
        </span>
      </Transition>
    </div>
    <Transition name="fade" mode="out-in">
      <i v-if="height === 'full'" class="iconfont icon-up" @click="scrollToHome" />
    </Transition>
  </div>
  <div
    v-else-if="type === 'page'"
    :class="['banner-page', 's-card', { image }]"
    :style="{
      backgroundImage: image ? `url(${image})` : null,
    }"
  >
    <div class="top">
      <div class="title">
        <span class="title-small">{{ title }}</span>
        <span class="title-big">{{ desc }}</span>
      </div>
      <div class="top-right">
        <slot name="header-slot" />
      </div>
    </div>
    <slot />
    <div class="footer">
      <div class="footer-left">
        {{ footer }}
      </div>
      <div class="footer-right">
        <slot name="footer-slot" />
      </div>
    </div>
  </div>
</template>

<script setup>
import { mainStore } from '@/store';
import { getHitokoto } from '@/api';

const store = mainStore();
const { theme } = useData();

const props = defineProps({
  type: { type: String, default: 'text' },
  height: { type: String, default: 'half' },
  title: { type: String, default: '这里是标题' },
  desc: { type: String, default: '这里是简介' },
  footer: { type: String, default: '' },
  image: { type: String, default: '' },
});

// 存储一言数据和状态
const hitokotoData = ref({ hitokoto: '', from: '', from_who: '' });
const isHitokotoDisplayed = ref(false);

// 自动轮询相关
const hitokotoInitialTimeout = ref(null);
const autoSwitchInterval = ref(null);
const autoSwitchActive = ref(false);

// 是否已经手动点击过一次,停止自动并锁定手动模式
const disableAuto = ref(false);

// 默认标语
const defaultSlogan = theme.value.siteMeta.description;

// 计算展示的文字:若当前为“一言”状态则显示一言,否则显示默认标语
const displayText = computed(() =>
  isHitokotoDisplayed.value && hitokotoData.value.hitokoto
    ? hitokotoData.value.hitokoto
    : defaultSlogan
);

// 点击处理:
// 1. 若当前仍在自动模式,第一次点击会停止自动、显示默认标语
// 2. 之后的点击均为手动获取新一言
async function toggleHitokoto() {
  if (!disableAuto.value && autoSwitchActive.value) {
    // 第一次点击:关闭自动、显示默认
    pauseHitokotoCycle();
    disableAuto.value = true;
    isHitokotoDisplayed.value = false;
    return;
  }
  // 手动获取并显示一言
  await fetchAndShowHitokoto();
}

// 真正执行一次请求并显示结果
async function fetchAndShowHitokoto() {
  try {
    const result = await getHitokoto();
    hitokotoData.value = {
      hitokoto: result.hitokoto,
      from: result.from,
      from_who: result.from_who
    };
    isHitokotoDisplayed.value = true;
  } catch (err) {
    console.error('一言获取失败:', err);
  }
}

// 自动轮询执行
async function autoToggleHitokoto() {
  await fetchAndShowHitokoto();
}

// 启动首次延迟 + 后续自动轮询
function startHitokotoCycle() {
  if (disableAuto.value) return;
  hitokotoInitialTimeout.value = setTimeout(async () => {
    await fetchAndShowHitokoto();
    autoSwitchInterval.value = setInterval(autoToggleHitokoto, 7000);
    autoSwitchActive.value = true;
  }, 4000);
}

// 暂停所有定时/请求
function pauseHitokotoCycle() {
  if (hitokotoInitialTimeout.value) {
    clearTimeout(hitokotoInitialTimeout.value);
    hitokotoInitialTimeout.value = null;
  }
  if (autoSwitchInterval.value) {
    clearInterval(autoSwitchInterval.value);
    autoSwitchInterval.value = null;
  }
  autoSwitchActive.value = false;
}

// Page Visibility API:切换标签页时暂停,切回时(若未手动禁用)恢复
function handleVisibilityChange() {
  if (document.hidden) {
    pauseHitokotoCycle();
  } else if (!disableAuto.value && !autoSwitchActive.value && !hitokotoInitialTimeout.value) {
    startHitokotoCycle();
  }
}

// 滚动至首页
function scrollToHome() {
  const bannerDom = document.getElementById('main-banner');
  if (!bannerDom) return;
  scrollTo({ top: bannerDom.offsetHeight, behavior: 'smooth' });
}

// 同步外部 store 的 bannerType
const bannerType = ref(store.bannerType);
watch(() => store.bannerType, val => (bannerType.value = val));

onMounted(() => {
  if (props.type === 'text') {
    startHitokotoCycle();
    document.addEventListener('visibilitychange', handleVisibilityChange);
  }
});

onBeforeUnmount(() => {
  pauseHitokotoCycle();
  document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>

<style lang="scss" scoped>
/* 样式保持不变 */
.banner {
  height: 300px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  animation: fade-up 0.6s 0.1s backwards;
  transition: height 0.3s;
  &.full {
    opacity: 0;
    height: calc(100vh - 70px);
    padding-bottom: 100px;
    animation: fade-up 0.6s 0.5s forwards;
    .subtitle {
      opacity: 0;
      animation: fade-up-opacity 0.8s 0.5s forwards;
    }
  }
  .title {
    font-family: "Site Title";
    font-weight: bold;
    font-size: 2.75rem;
  }
  .subtitle {
    width: 80%;
    font-size: 1.25rem;
    opacity: 0.8;
    animation: fade-up-opacity 0.6s 0.1s backwards;
  .text {
    text-align: center;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2; // WebKit 引擎兼容性
    -webkit-box-orient: vertical; // WebKit 引擎兼容性

    line-clamp: 2; // 标准的 line-clamp 属性,提高兼容性
  }
}
  .icon-up {
    font-size: 20px;
    position: absolute;
    bottom: 60px;
    left: calc(50% - 10px);
    transform: rotate(180deg);
    animation: moveDown 2s ease-in-out infinite;
    cursor: pointer;
  }
  @media (max-width: 768px) {
    align-items: flex-start;
    height: 240px;
    .title {
      font-size: 2.25rem;
    }
    .subtitle {
      height: 50px;
      font-size: 1.125rem;
      margin-left: 8px;
      .text {
        text-align: left;
      }
    }
  }
}
.banner-page {
  position: relative;
  display: flex;
  flex-direction: column;
  padding: 2rem;
  min-height: 380px;
  background-size: cover;
  .top {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 2rem;
    .title {
      display: flex;
      flex-direction: column;
      .title-small {
        color: var(--main-font-second-color);
        font-size: 0.875rem;
      }
      .title-big {
        font-size: 2.25rem;
        font-weight: bold;
        line-height: 1.2;
        margin-top: 12px;
      }
    }
  }
  .footer {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    margin-top: auto;
    .footer-left {
      margin-top: auto;
      color: var(--main-font-second-color);
      opacity: 0.8;
    }
  }
  &.image {
    color: #fff !important;
    .top {
      .title-small {
        color: #fff;
        opacity: 0.6;
      }
    }
    .footer {
      .footer-left {
        color: #fff;
      }
      :deep(.iconfont) {
        color: #fff !important;
      }
    }
  }
  @media (max-width: 1200px) {
    min-height: 300px;
  }
  @media (max-width: 768px) {
    min-height: 260px;
    .top-right,
    .footer-right {
      display: none;
    }
  }
}
</style>

改动点:

  1. isHitokotoDisplayed初始值:
js
const isHitokotoDisplayed = ref(false); // 初始时设置为 false,显示默认标语

现在,它默认是 false ,这样在组件初次加载时会显示 defaultSlogan。


  1. hitokotoInitialTimeout 新增:
js
const hitokotoInitialTimeout = ref(null); // 用于初次加载的定时器

重新引入一个 ref 来存储 setTimeout 的 ID,以便在组件卸载时清除它。


  1. isFirstClickAfterInitialHitokoto 状态:
js
const isFirstClickAfterInitialHitokoto = ref(true);

这个布尔值专门用于跟踪 在首次一言显示后,是否是第一次点击。


  1. onMounted 逻辑调整:
js
onMounted(() => {
  if (props.type === "text") {
    // 初次打开时,isHitokotoDisplayed 默认为 false,所以会显示默认标语。
    // 3秒后获取并显示一言
    hitokotoInitialTimeout.value = setTimeout(() => {
      getHitokotoData();
    }, 3000); // 3000 毫秒 = 3 秒
  }
  bannerType.value = store.bannerType;
});

在组件挂载时,如果 type 是 "text",会设置一个3秒的定时器,在3秒后才调用 getHitokotoData()。在此期间,displayText 会显示 defaultSlogan。


  1. toggleHitokoto 逻辑更新:
js
const toggleHitokoto = async () => {
  if (isHitokotoDisplayed.value && isFirstClickAfterInitialHitokoto.value) {
    // 第一次点击:当前显示一言,且是第一次点击,切换到默认标语
    isHitokotoDisplayed.value = false;
    isFirstClickAfterInitialHitokoto.value = false; // 标记已完成第一次切换
  } else {
    // 从第二次点击开始,或当前显示默认标语:获取并显示新的一言
    await getHitokotoData();
  }
};
  • 条件 isHitokotoDisplayed.value && isFirstClickAfterInitialHitokoto.value: 这个条件确保只有在当前显示的是一言,并且这是首次点击以切换到默认标语时,才执行切换到默认标语的操作。
  • else 分支: 否则,这意味着:
    • 当前显示的是默认标语(因为 isHitokotoDisplayed.value 为 false)。
    • 或者,这已经是第二次或更多次点击(因为 isFirstClickAfterInitialHitokoto.value 已经变为 false)。 在这两种情况下,我们都希望获取并显示一个新的“一言”。

  1. onBeforeUnmount 清理:
js
onBeforeUnmount(() => {
  // 清除初始加载的定时器,防止组件卸载后仍然执行
  if (hitokotoInitialTimeout.value) {
    clearTimeout(hitokotoInitialTimeout.value);
  }
});
  • 确保在组件销毁前清除掉可能仍在等待执行的初始定时器,以避免内存泄漏或其他意外行为。

遇到的问题和解决方案

1. 问题:如何控制初次加载时显示默认标语,而非立即显示一言?

  • 解决方案:
    • 将 isHitokotoDisplayed 初始值设置为 false。
    • 在 onMounted 钩子中,移除立即调用 getHitokotoData() 的逻辑。
    • 通过 computed 属性 displayText,根据 isHitokotoDisplayed 的状态,决定显示 defaultSlogan 还是 hitokotoData?.hitokoto. 这样,在组件挂载初期 isHitokotoDisplayed 为 false 时,就会自然显示默认标语。

2. 问题:如何在初次加载默认标语后,延迟一段时间再显示一言?

  • 解决方案:
    • 在 onMounted 钩子中,使用 setTimeout 包装 getHitokotoData() 的调用,并设置所需的延迟时间(例如 3 秒)。
    • 引入 hitokotoInitialTimeout ref 来存储 setTimeout 返回的定时器 ID。
    • 在 onBeforeUnmount 钩子中,使用 clearTimeout(hitokotoInitialTimeout.value) 来清除定时器,防止组件卸载后定时器继续执行导致内存泄漏。

3. 问题:如何实现“第一次点击切换到默认标语,之后每次点击都获取新一言”的复杂点击逻辑?

  • 解决方案:
    • 引入一个新的状态变量 isFirstClickAfterInitialHitokoto (或类似名称)并初始化为 true。这个变量专门用于跟踪在组件初次展示一言后,用户是否进行了“第一次点击”以切换回默认标语。
    • 在 toggleHitokoto 函数中,根据 isHitokotoDisplayed 和 isFirstClickAfterInitialHitokoto 的组合状态来判断执行哪种逻辑:
      • 如果 isHitokotoDisplayed 为 true 且 isFirstClickAfterInitialHitokoto 为 true,则说明当前显示的是一言且是首次点击,此时将 isHitokotoDisplayed 设置为 false(显示默认标语),并将 isFirstClickAfterInitialHitokoto 设置为 false,标记已完成第一次切换。
      • 在其他情况下(即当前显示默认标语,或者已经不是第一次点击),都调用 getHitokotoData() 来获取并显示新的“一言”。

4. 问题:当一言获取失败时,如何处理显示,并确保后续点击逻辑正常?

  • 解决方案:
    • 在 getHitokotoData 的 catch 块中,捕获错误并进行相应的错误处理(例如通过 $message.error 提示用户,并打印控制台错误信息).
    • 最重要的是,即使获取失败,也要将 isHitokotoDisplayed.value 设置为 false,确保显示的是默认标语。这样,在下次点击时,toggleHitokoto 逻辑会进入“获取新一言”的分支,尝试重新获取。

自定义指针适配浅色模式 ​

  1. 更改 theme/App.vue
vue
<template>
  <!-- 背景图片 -->
  <Background />
  <!-- 加载提示 -->
  <Loading />
  <!-- 中控台 -->
  <Control />
  <!-- 导航栏 -->
  <Nav />
  <!-- 主内容 -->
  <main :class="['mian-layout', { loading: loadingStatus, 'is-post': isPostPage }]">
    <!-- 404 -->
    <NotFound v-if="page.isNotFound" />
    <!-- 首页 -->
    <Home v-if="frontmatter.layout === 'home'" showHeader />
    <!-- 页面 -->
    <template v-else>
      <!-- 文章页面 -->
      <Post v-if="isPostPage" />
      <!-- 普通页面 -->
      <Page v-else-if="!page.isNotFound" />
    </template>
  </main>
  <!-- 页脚 -->
  <FooterLink v-show="!loadingStatus" :showBar="isPostPage && !page.isNotFound" />
  <Footer v-show="!loadingStatus" />
  <!-- 悬浮菜单 -->
  <Teleport to="body">
    <!-- 左侧菜单 -->
    <div :class="['left-menu', { hidden: footerIsShow }]">
      <!-- 全局设置 -->
      <Settings />
      <!-- 全局播放器 -->
      <Player />
    </div>
  </Teleport>
  <!-- 右键菜单 -->
  <RightMenu ref="rightMenuRef" />
  <!-- 全局消息 -->
  <Message />
</template>

<script setup>
import { storeToRefs, createPinia } from "pinia";
import { mainStore, initializeCursor } from "@/store";
import { calculateScroll, specialDayGray } from "@/utils/helper";
import cursorInit from "@/utils/cursor.js";

import App from '@/App.vue';

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);

// 在 Pinia store 初始化后调用 initializeCursor
initializeCursor();
// const screenWidth = ref(0);
const route = useRoute();
const store = mainStore();
const { frontmatter, page, theme } = useData();
const { loadingStatus, footerIsShow, themeValue, themeType, backgroundType, fontFamily, fontSize } =
  storeToRefs(store);

  onMounted(() => {
  // 自定义鼠标
  cursorInit();
  })
  //2025.06.12更新:在 Next.js 的服务端渲染过程中,应用会在服务器端先进行渲染
  //而在服务器端的 JavaScript 环境中,并没有浏览器提供的 window 对象。
  //最简单的解决方法是确保在客户端代码中访问 window
  //可以通过判断代码是否在浏览器环境中运行来避免在服务器端渲染时执行涉及 window 的代码
  //onMounted 钩子: 在 setup 或 data 中避免直接访问 window。
  //将依赖 window 对象的代码放入 onMounted 钩子中,因为 onMounted 只会在组件挂载到DOM后执行。
  // onMounted(() => {
  // 只有在浏览器环境才会执行
  // if (typeof window !== 'undefined') {
  //   console.log(window.innerWidth);
  // }
// })
// onMounted(() => {
  // 这里的代码只会在浏览器环境中执行
//   screenWidth.value = window.screen.width;
// });
// 右键菜单
const rightMenuRef = ref(null);

// 判断是否为文章页面
const isPostPage = computed(() => {
  const routePath = decodeURIComponent(route.path);
  return routePath.includes("/posts/");
});

// 开启右键菜单
const openRightMenu = (e) => {
  rightMenuRef.value?.openRightMenu(e);
};

// 复制时触发
const copyTip = () => {
  const copiedText = window.getSelection().toString();
  // 检查文本内容是否不为空
  if (copiedText.trim().length > 0 && typeof $message !== "undefined") {
    $message.success("复制成功,在转载时请标注本文地址");
  }
};

// 更改正确主题类别
const changeSiteThemeType = () => {
  // 主题 class
  const themeClasses = {
    dark: "dark",
    light: "light",
    auto: "auto",
  };
  // 必要数据
  const htmlElement = document.documentElement;
  console.log("当前模式:", themeType.value);
  // 清除所有 class
  Object.values(themeClasses).forEach((themeClass) => {
    htmlElement.classList.remove(themeClass);
  });
  // 添加新的 class
  if (themeType.value === "auto") {
    // 根据当前操作系统颜色方案更改明暗主题
    const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
    const autoThemeClass = systemPrefersDark ? themeClasses.dark : themeClasses.light;
    htmlElement.classList.add(autoThemeClass);
    themeValue.value = autoThemeClass;
  } else if (themeClasses[themeType.value]) {
    htmlElement.classList.add(themeClasses[themeType.value]);
    themeValue.value = themeClasses[themeType.value];
  }
  if (backgroundType.value === "image") {
    htmlElement.classList.add("image");
  } else {
    htmlElement.classList.remove("image");
  }
};

// 切换系统字体样式
const changeSiteFont = () => {
  try {
    const htmlElement = document.documentElement;
    htmlElement.classList.remove("lxgw", "hmos");
    htmlElement.classList.add(fontFamily.value);
    htmlElement.style.fontSize = fontSize.value + "px";
  } catch (error) {
    console.error("切换系统字体样式失败", error);
  }
};

// 监听设置变化
watch(
  () => [themeType.value, backgroundType.value],
  () => changeSiteThemeType(),
);
watch(
  () => fontFamily.value,
  () => changeSiteFont(),
);

onMounted(() => {
  console.log(frontmatter.value, page.value, theme.value);
  // 全站置灰
  specialDayGray();
  // 更改主题类别
  changeSiteThemeType();
  // 切换系统字体样式
  changeSiteFont();
  // 滚动监听
  window.addEventListener("scroll", calculateScroll);
  // 右键监听
  window.addEventListener("contextmenu", openRightMenu);
  // 复制监听
  window.addEventListener("copy", copyTip);
  // 监听系统颜色
  window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", changeSiteThemeType);
});

onBeforeUnmount(() => {
  window.removeEventListener("scroll", calculateScroll);
  window.removeEventListener("contextmenu", openRightMenu);
});
</script>

<style lang="scss" scoped>
.mian-layout {
  width: 100%;
  max-width: 1400px;
  margin: 0 auto;
  padding: 1rem 2rem;
  // 手动实现加载动画
  animation: show 0.5s forwards;
  animation-duration: 0.5s;
  display: block;
  &.loading {
    display: none;
  }
  @media (max-width: 768px) {
    padding: 1rem 1.5rem;
    &.is-post {
      padding: 0;
    }
  }
}
.left-menu {
  position: fixed;
  left: 20px;
  bottom: 20px;
  z-index: 1002;
  transition:
    opacity 0.3s,
    transform 0.3s;
  &.hidden {
    opacity: 0;
    transform: translateY(100px);
  }
}
</style>

  1. 更改 theme/style/main.scss
scss
// 自定义鼠标
#cursor {
  position: fixed;
  width: 18px;
  height: 18px;
  // 使用 CSS 变量来控制背景色
  background: var(--cursor-bg-color, #fff); // 默认值为 #fff (白色)
  border-radius: 25px;
  opacity: 0.25;
  z-index: 10086;
  pointer-events: none;
  transition: 0.2s ease-in-out;
  transition-property: background, opacity, transform;

  &.hidden {
    opacity: 0;
  }

  &.active {
    opacity: 0.5;
    transform: scale(0.5);
  }
}

  1. 更改theme/utils/cursor.js
js
import { isEqual } from "lodash-es";

let mainCursor;

const lerp = (a, b, n) => {
  if (Math.round(a) === b) {
    return b;
  }
  return (1 - n) * a + n * b;
};

// getStyle 辅助函数也需要只在客户端运行
const getStyle = (el, attr) => {
  if (typeof window === 'undefined') return false; // 在非浏览器环境下直接返回
  try {
    return window.getComputedStyle ? window.getComputedStyle(el)[attr] : el.currentStyle[attr];
  } catch (e) {
    console.error(e);
  }
  return false;
};

const cursorInit = () => {
  // 确保只在客户端初始化光标
  if (typeof window !== 'undefined') {
    mainCursor = new Cursor();
    return mainCursor;
  }
  return null; // 在非浏览器环境下返回 null
};

class Cursor {
  constructor() {
    this.pos = {
      curr: null,
      prev: null,
    };
    this.pt = [];
    this.currentThemeType = 'auto';

    // 所有 DOM 操作和事件绑定都在 create/init 中处理,这些方法会包含环境检查
    this.create();
    this.init();
    this.render();
  }

  move(left, top) {
    if (this.cursor) { // 确保 this.cursor 存在
      this.cursor.style["left"] = `${left}px`;
      this.cursor.style["top"] = `${top}px`;
    }
  }

  create() {
    // 确保只在客户端创建 DOM 元素
    if (typeof document === 'undefined') return;

    if (!this.cursor) {
      this.cursor = document.createElement("div");
      this.cursor.id = "cursor";
      this.cursor.classList.add("xs-hidden");
      this.cursor.classList.add("hidden");
      document.body.append(this.cursor);
    }

    const isMobile = /Mobi|Android/i.test(navigator.userAgent);

    if (isMobile) {
      this.cursor.classList.add("hidden");
      if (this.scr) {
        this.scr.remove();
      }
      document.body.style.cursor = 'auto';
      return;
    }

    var el = document.getElementsByTagName("*");
    for (let i = 0; i < el.length; i++)
      if (getStyle(el[i], "cursor") == "pointer") this.pt.push(el[i].outerHTML);

    if (!this.scr) {
      document.body.appendChild((this.scr = document.createElement("style")));
    }
  }

  updateCursorStyle(themeType) {
    if (typeof window === 'undefined' || !this.scr) return; // 确保在客户端且 style 标签已创建

    let cursorColor;
    if (themeType === 'auto') {
      const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
      cursorColor = prefersDarkMode ? 'white' : 'black';
    } else {
      cursorColor = themeType === 'dark' ? 'white' : 'black';
    }
    this.scr.innerHTML = `* {cursor: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8' width='10px' height='10px'><circle cx='4' cy='4' r='4' fill='${cursorColor}' /></svg>") 4 4, auto !important}`;
  }

  setThemeType(newThemeType) {
    this.currentThemeType = newThemeType;
    if (typeof window !== 'undefined' && this.cursor && !/Mobi|Android/i.test(navigator.userAgent)) {
        this.updateCursorStyle(newThemeType);
    }
  }

  refresh() {
    if (typeof document === 'undefined') return; // 确保在客户端

    this.scr.remove();
    this.cursor.classList.remove("active");
    this.pos = {
      curr: null,
      prev: null,
    };
    this.pt = [];

    this.create();
    this.init();
    this.render();
  }

  init() {
    if (typeof document === 'undefined') return; // 确保在客户端

    const isMobile = /Mobi|Android/i.test(navigator.userAgent);
    if (isMobile) {
        return;
    }

    document.onmousemove = (e) => {
      this.pos.curr == null && this.move(e.clientX - 8, e.clientY - 8);
      this.pos.curr = {
        x: e.clientX - 8,
        y: e.clientY - 8,
      };
      this.cursor.classList.remove("hidden");
      this.render();
    };
    document.onmouseenter = () => this.cursor.classList.remove("hidden");
    document.onmouseleave = () => this.cursor.classList.add("hidden");
    document.onmousedown = () => this.cursor.classList.add("active");
    document.onmouseup = () => this.cursor.classList.remove("active");
  }

  render() {
    if (typeof document === 'undefined') return; // 确保在客户端

    const isMobile = /Mobi|Android/i.test(navigator.userAgent);
    if (isMobile) {
        return;
    }

    if (this.pos.prev) {
      this.pos.prev.x = lerp(this.pos.prev.x, this.pos.curr.x, 0.35);
      this.pos.prev.y = lerp(this.pos.prev.y, this.pos.curr.y, 0.35);
      this.move(this.pos.prev.x, this.pos.prev.y);
    } else {
      this.pos.prev = this.pos.curr;
    }
    if (!isEqual(this.pos.curr, this.pos.prev)) {
      requestAnimationFrame(() => this.render());
    }
  }
}

export default cursorInit;

  1. 更改theme/store/index.js
js
import { defineStore } from "pinia";
import cursorInit from '@/utils/cursor.js';

let appCursorInstance;

export const mainStore = defineStore("main", {
  state: () => {
    return {
      // 主题类别
      themeType: "auto",
      themeValue: "light",
      // banner
      bannerType: "half",
      // 加载状态
      loadingStatus: true,
      // 滚动高度
      scrollData: {
        height: 0,
        percentage: 0,
        direction: "down",
      },
      // 页脚可见性
      footerIsShow: false,
      // 中控台显示
      controlShow: false,
      // 搜索框显示
      searchShow: false,
      // 个性化配置显示
      showSettings: false,
      // 播放器数据
      playState: false,
      playerShow: true,
      playerVolume: 0.7,
      playerData: {
        name: "未知曲目",
        artist: "未知艺术家",
      },
      // 移动端菜单显示
      mobileMenuShow: false,
      // 使用自定义右键菜单
      useRightMenu: true,
      // 背景模糊
      backgroundBlur: false,
      // 全站字体
      fontFamily: "hmos",
      // 全站字体大小
      fontSize: 16,
      // 信息显示位置
      infoPosition: "fixed",
      // 上次滚动位置
      lastScrollY: 0,
      // 站点背景
      backgroundType: "patterns",
      backgroundUrl: "https://tuapi.eees.cc/api.php?category={dongman,fengjing}&type=302",
    };
  },
  getters: {},
  actions: {
    // 切换应用状态
    changeShowStatus(value, blur = true) {
      if (typeof document === 'undefined') return; // 确保在客户端

      this[value] = !this[value];
      // 阻止滚动
      document.body.style.overflowY = this[value] ? "hidden" : "";
      // 全局模糊
      const globalApp = document.getElementById("app");
      this[value] && this.backgroundBlur && blur
        ? globalApp.classList.add("blur")
        : globalApp.classList.remove("blur");
    },
    // 更改字体大小
    changeFontSize(isAdd = false) {
      if (typeof document === 'undefined') return; // 确保在客户端

      if (isAdd) {
        if (this.fontSize < 20) {
          this.fontSize++;
        }
      } else {
        if (this.fontSize > 14) {
          this.fontSize--;
        }
      }
      const htmlElement = document.documentElement;
      htmlElement.style.fontSize = this.fontSize + "px";
    },
    // 切换明暗模式
    changeThemeType() {
      if (typeof window === 'undefined') return; // 确保在客户端

      // 禁止壁纸模式切换
      if (this.backgroundType === "image") {
        if (typeof $message !== "undefined") { 
          $message.warning("无法在壁纸模式下切换明暗模式", {
            duration: 1500,
          });
        }
        return false;
      }
      this.themeType === "auto"
        ? (this.themeType = "dark")
        : this.themeType === "dark"
          ? (this.themeType = "light")
          : (this.themeType = "auto");

      // 计算实际生效的 themeValue 并设置 CSS 变量
      this.updateActualThemeValue();

      // 弹窗提示
      if (typeof $message !== "undefined") { 
        const text =
          this.themeType === "light"
            ? "浅色模式"
            : this.themeType === "dark"
              ? "深色模式"
              : "跟随系统";
        $message.info("当前主题为" + text, {
          duration: 1500,
        });
      }

      // 通知光标更新主题
      if (appCursorInstance) {
        appCursorInstance.setThemeType(this.themeType);
      }
    },

    // 新增方法:更新实际生效的主题值并设置CSS变量
    updateActualThemeValue() {
      if (typeof window === 'undefined' || typeof document === 'undefined') return; // 确保在客户端

      let actualTheme;
      if (this.themeType === 'auto') {
        const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
        actualTheme = prefersDarkMode ? 'dark' : 'light';
      } else {
        actualTheme = this.themeType;
      }
      this.themeValue = actualTheme;

      const root = document.documentElement;
      if (actualTheme === 'light') {
        root.style.setProperty('--cursor-bg-color', '#000');
      } else {
        root.style.setProperty('--cursor-bg-color', '#fff');
      }
      
      if (actualTheme === 'dark') {
          root.classList.add('dark');
          root.classList.remove('light');
      } else {
          root.classList.add('light');
          root.classList.remove('dark');
      }
    },

    // 新增action: 外部触发更新主题(用于系统主题变化)
    triggerThemeUpdate() {
        if (typeof window === 'undefined') return; // 确保在客户端
        this.updateActualThemeValue();
        if (appCursorInstance) {
            appCursorInstance.setThemeType(this.themeType);
        }
    }
  },
  // 数据持久化
  persist: [
    {
      key: "siteData",
      paths: [
        "themeType",
        "bannerType",
        "useRightMenu",
        "playerShow",
        "playerVolume",
        "backgroundBlur",
        "backgroundType",
        "fontFamily",
        "fontSize",
        "infoPosition",
        "backgroundUrl",
      ],
    },
  ],
});

// 在 Pinia store 被创建后,初始化光标并处理主题设置
export const initializeCursor = () => {
  // 确保只在客户端执行初始化
  if (typeof window === 'undefined' || typeof document === 'undefined') {
    console.warn('initializeCursor skipped in SSR environment.');
    return;
  }

  const store = mainStore();

  if (!appCursorInstance) {
    appCursorInstance = cursorInit(); // cursorInit 内部也有环境判断
  }

  // 如果 appCursorInstance 在非浏览器环境下返回 null,则跳过后续操作
  if (!appCursorInstance) {
    return;
  }

  store.updateActualThemeValue();
  appCursorInstance.setThemeType(store.themeType);

  if (window.matchMedia) {
    const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');

    const handleSystemThemeChange = (e) => {
      if (store.themeType === 'auto') {
        store.triggerThemeUpdate();
      }
    };

    if (mediaQueryList.addEventListener) {
      mediaQueryList.addEventListener('change', handleSystemThemeChange);
    } else {
      mediaQueryList.addListener(handleSystemThemeChange);
    }
  }
};

  1. 更改theme/components/Control.vue
vue
<template>
  <Teleport to="body">
    <Transition name="fade" mode="out-in" @before-enter="changeCloseStyle">
      <div v-if="store.controlShow" class="control" @click="store.changeShowStatus('controlShow')">
        <div ref="closeControlRef" class="close-control">
          <i class="iconfont icon-close"></i>
        </div>
        <div class="control-mask" />
        <div class="control-content" @click.stop>
          <div class="menu">
            <div class="menu-item open" title="显示模式切换" @click.stop="store.changeThemeType">
              <i :class="`iconfont icon-${store.themeType}`"></i>
            </div>
            <div
              :class="['menu-item', { open: store.useRightMenu }]"
              title="右键菜单开关"
              @click.stop="rightMenuSwitch"
            >
              <i class="iconfont icon-list"></i>
            </div>
            <div
              :class="['menu-item', { open: store.playerShow }]"
              title="播放器开关"
              @click.stop="store.playerShow = !store.playerShow"
            >
              <i class="iconfont icon-music"></i>
            </div>
            <div
              :class="['menu-item', { open: store.backgroundBlur }]"
              title="背景模糊开关"
              @click.stop="store.changeShowStatus('backgroundBlur')"
            >
              <i class="iconfont icon-blur"></i>
            </div>
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup>
import { mainStore } from '@/store'; // **修正 Pinia store 的导入路径 **

const store = mainStore(); // 

const closeControlRef = ref(null); // 

// 更正关闭按钮位置 
const changeCloseStyle = () => { // 
  nextTick().then(() => { // 
    const controlOpenDom = document.querySelector("#open-control"); // 
    if (controlOpenDom && closeControlRef.value) { // 
      const { top, left } = controlOpenDom.getBoundingClientRect(); // 
      closeControlRef.value.style.top = `${top}px`; // 
      closeControlRef.value.style.left = `${left}px`; // 
      closeControlRef.value.style.opacity = "1"; // 
    }
  }); // 
};

// 右键菜单开关 
const rightMenuSwitch = () => { // 
  store.useRightMenu = !store.useRightMenu; // 
  // 确保 $message 可用,否则会报错导致无响应
  if (typeof $message !== "undefined") {
    $message.info(`${store.useRightMenu ? "已开启" : "已关闭"}自定义右键菜单`); // 
  } else {
    console.warn("$message is not defined. Right-click menu switch message not displayed.");
  }
};
</script>

<style lang="scss" scoped>
.control {
  position: fixed;
  top: 0;
  left: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh; 
  z-index: 1109; 
  .close-control {
    position: absolute;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 35px; 
    height: 35px; 
    padding: 0; 
    opacity: 0; 
    transition:
      background-color 0.3s,
      opacity 0.3s; 
    border-radius: 50%; 
    cursor: pointer; 
    .iconfont {
      font-size: 18px; 
      line-height: 1; 
      transition:
        color 0.3s,
        opacity 0.3s; 
    }
    &:hover {
      background-color: var(--main-color); 
      .iconfont {
        color: var(--main-card-background); 
      }
    }
  }
  .control-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%; 
    height: 100%; 
    z-index: -1; 
    background-color: var(--main-mask-background); 
  }
  .control-content {
    position: absolute; 
    animation: fade-up 0.5s forwards; 
    .menu {
      display: flex;
      flex-direction: row;
      align-items: center; 
      .menu-item {
        display: flex;
        align-items: center;
        justify-content: center;
        margin: 0 6px; 
        width: 60px; 
        height: 60px; 
        border-radius: 50%; 
        border: 1px solid var(--main-card-border); 
        background-color: var(--main-card-background); 
        transition:
          transform 0.3s,
          background-color 0.3s; 
        cursor: pointer; 
        .iconfont {
          font-size: 24px; 
          color: var(--main-font-color); 
          transition: color 0.3s; 
        }
        &.open {
          background-color: var(--main-color); 
          .iconfont {
            color: #fff; 
          }
        }
        &:hover {
          transform: scale(1.05); 
        }
        &:active {
          transform: scale(1); 
        }
      }
    }
  }
}
</style>

更改亮暗模式时出现的问题和总结:

一、自定义光标与主题切换:

  • 问题1:初始自定义光标无法根据系统主题(亮/暗模式)自动调整。

    • 原因: cursor.js 内部最初通过 window.matchMedia('(prefers-color-scheme: dark)') 监听系统主题变化,但在 create() 方法中只是初始化了光标样式,并没有在主题变化时重新触发样式更新。
    • 解决方案:
      1. 在 cursor.js 中,将设置光标颜色的逻辑封装到 updateCursorStyle(themeType) 方法中。
      2. 添加 setThemeType(newThemeType) 方法,供外部调用以更新 currentThemeType 并触发 updateCursorStyle()。
      3. 将主题监听的职责从 cursor.js 转移到 Pinia Store,避免 cursor.js 承担过多职责。
  • 问题2:SCSS 中定义的鼠标指针样式(例如光标本身那个圆点)无法随主题切换而改变。

    • 原因: SCSS 编译后是静态 CSS,无法直接响应 JavaScript 变量的变化。cursor.js 动态生成的 SVG 光标可以变化,但 SCSS 样式本身不行。
    • 解决方案:
      1. SCSS 使用 CSS 变量: 在 _cursor.scss 中,将光标的背景色定义为 CSS 变量,如 background: var(--cursor-bg-color, #fff);。
      2. Pinia Store 动态设置 CSS 变量: 在 index.js 的 updateActualThemeValue() action 中,根据 themeType 计算出实际的颜色值(#000 或 #fff),并通过 document.documentElement.style.setProperty('--cursor-bg-color', '...') 将该值动态设置到 <html> 元素上。这样,SCSS 中引用的 CSS 变量就会实时更新。
  • 问题3:在“自动”模式下,系统主题从外部更改(例如通过操作系统设置)后,指针样式没有变。

    • 原因: 现有逻辑主要依赖 Pinia Store 的 changeThemeType action 来触发样式的更新,而系统层面的主题变化并没有直接触发这个 action。
    • 解决方案:
      1. 在 Pinia Store 的 initializeCursor 函数中,添加对 window.matchMedia('(prefers-color-scheme: dark)') 的监听。
      2. 定义一个 handleSystemThemeChange 函数作为监听器回调,在该函数内部判断当前的 store.themeType 是否为“auto”。
      3. 如果 themeType 为“auto”且系统主题发生变化,则调用 Pinia Store 中新增的 triggerThemeUpdate() action。triggerThemeUpdate() 会调用 updateActualThemeValue() 并通知 appCursorInstance 更新光标样式。

二、Pinia Store 与 Vue 组件交互:

  • 问题4:控制台呼出后,点击切换主题按钮无响应。

    • 原因A:$message 未定义。 在 index.js 的 changeThemeType action 和 Control.vue 组件的 rightMenuSwitch 方法中,直接使用了 $message 对象来显示提示。如果 $message 没有在 main.js 中通过 app.config.globalProperties.$message = ... 进行全局注册,那么这些调用会抛出 JavaScript 错误,中断后续代码的执行,导致功能"无响应"。
    • 原因B:Pinia Store 导入路径错误。 在 Control.vue 中,import { mainStore } from "@/store"; 可能与实际的 Pinia Store 文件路径不符(例如实际是 src/stores/index.js)。错误的导入路径会导致 mainStore 未定义,从而 store.changeThemeType 调用失败。
    • 原因C:Pinia $subscribe 机制误解(早期尝试): 最初尝试使用 $subscribe 监听 themeType 变化来更新光标。虽然 $subscribe 可以监听状态变化,但在某些复杂场景下,或当状态更新非常迅速时,直接依赖它可能不如在 action 内部明确触发更新可靠。
    • 解决方案:
      1. $message 安全调用: 在所有使用 $message 的地方,添加 if (typeof $message !== "undefined") 判断,确保 $message 存在时才调用其方法。
      2. $message 全局注册: 建议在 main.js 中,通过 app.config.globalProperties.$message = ... 明确注册你的消息提示服务,使其在整个应用中可用。
      3. Pinia Store 导入路径修正: 将 Control.vue 中的 import 路径修改为正确的 Pinia Store 文件的相对或别名路径(例如 import { mainStore } from '@/stores/index.js';)。
      4. Pinia Store Action 中直接调用更新: 在 index.js 的 changeThemeType() action 内部,在 this.themeType 改变后,直接调用 this.updateActualThemeValue() 来更新 CSS 变量,并通知 appCursorInstance 更新光标。这确保了主题改变后立即触发视觉更新。
  • 问题5:index.js 编辑器报错:paths 的方括号不闭合。

    • 原因: Pinia persist 插件的 paths 选项是一个数组,由于编写或拷贝过程中的疏忽,导致数组的闭合方括号 ] 缺失。
    • 解决方案: 在 persist 配置的 paths 数组的末尾补上缺失的 ],修正语法错误。

三、应用生命周期与 SSR 兼容性:

  • 问题6:构建 (build) 时出现 ReferenceError: document is not defined 错误。

    • 原因: Vue 项目在进行构建时,如果启用了服务器端渲染 (SSR),会在 Node.js 环境中尝试预渲染组件。然而,document、window 等浏览器特有的全局对象在 Node.js 环境中是不存在的,直接访问会导致 ReferenceError。
    • 解决方案: 在所有直接访问 document 或 window 的代码块外部,添加客户端环境判断。
      • 在 cursor.js 和 index.js 的相关函数和方法中,使用 if (typeof window !== 'undefined') 或 if (typeof document !== 'undefined') 进行条件判断。这确保了这些依赖浏览器 API 的代码只会在浏览器环境中执行,而在 SSR 过程中被跳过。
  • 问题7:应用加载时,光标和主题样式可能没有正确初始化,或者不与持久化数据同步。

    • 原因: cursor.js 实例的创建和 Pinia Store 的状态恢复是异步的,如果没有明确的初始化逻辑,可能导致初始视觉效果不正确。
    • 解决方案:
      1. 在 index.js 中定义 export const initializeCursor = () => { ... } 函数。
      2. 该函数负责:
        • 确保 cursor.js 的 appCursorInstance 只被创建一次,并且在非浏览器环境下不会尝试创建。
        • 在 appCursorInstance 创建后,立即调用 store.updateActualThemeValue() 来根据 Pinia Store 的当前状态(可能已从持久化中恢复)设置初始的 CSS 变量。
        • 同时,调用 appCursorInstance.setThemeType(store.themeType) 来设置 cursor.js 中 SVG 光标的初始颜色。
      3. 在 main.js 中,确保在 app.use(pinia); 之后,调用 initializeCursor();,以保证在 Pinia Store 可用后立即执行初始化。

修复卡加载loading ​

以下内容由ChatGPT生成:

  • 总结报告:在 Vercel、Nginx 和其他服务器上实现 Clean URL 机制

背景:

在构建和部署静态网站时,Clean URL(即去除 URL 中的文件扩展名如 .html)是一种常见的做法。它不仅使得 URL 更简洁、易读,而且也符合现代网站的 SEO 和用户体验最佳实践。很多静态网站平台(如 Vercel)都自动启用 Clean URL 功能,使得用户可以访问不带 .html 后缀的 URL。

本文将详细介绍 Vercel、Nginx、Apache、Caddy 和 LiteSpeed 等常见 Web 服务器上如何实现类似的 Clean URL 机制。


  • 一、Vercel 的 Clean URL 实现机制

Vercel 是一个现代的静态网站和前端应用平台,它自动处理静态资源的 URL 路由和重写,使得用户能够访问不带 .html 后缀的 URL。

  1. Vercel 的路由机制:

Vercel 在静态网站部署过程中,自动为每个生成的静态页面映射一个 URL。对于生成的静态页面(例如 posts/2025/0611.html),Vercel 会创建一个对应的 Clean URL 路径,即 /posts/2025/0611。用户无需添加 .html 后缀即可访问该页面。

  1. 默认启用 Clean URL:

Vercel 会默认启用 Clean URL 功能,使得 URL 在访问时不需要显式地包含 .html 后缀。这对于静态页面来说非常方便,因为它使得页面 URL 更加简洁和用户友好。

  1. 自定义配置(如需要):

尽管 Vercel 默认启用 Clean URL,但您仍然可以在 Vercel 的配置文件 vercel.json 中自定义 URL 重定向规则。例如,您可以配置重写规则以确保特定的 URL 格式或重定向行为。

json
{
  "rewrites": [
    {
      "source": "/pages/posts/:slug",
      "destination": "/pages/posts/:slug.html"
    }
  ]
}

通过这类配置,您可以在需要时调整 URL 的访问方式。


  • 二、Nginx 上实现类似 Vercel Clean URL 机制

对于使用 Nginx 部署静态网站的用户,虽然 Nginx 本身并没有像 Vercel 那样自动启用 Clean URL,但通过配置 Nginx 的 try_files 指令,可以模仿 Vercel 的 Clean URL 机制。

  1. Nginx 配置方案:

通过配置 try_files 指令,Nginx 会在用户访问不带 .html 后缀的 URL 时,尝试查找相应的 .html 文件,并返回该文件的内容。如果文件不存在,则返回 404 错误。

示例配置:

server {
    listen 80;
    server_name your-domain.com;

    root /var/www/html;  # 设置您的网站根目录

    # 配置静态文件和 clean URL 处理
    location / {
        try_files $uri.html $uri $uri/ =404;  # 尝试 $uri.html,若没有则尝试原始路径 $uri 或目录 $uri/,如果都没有则返回 404
    }

    # 可选:错误页面
    error_page 404 /404.html;

    # 自定义的 404 错误页面路径
    location = /404.html {
        root /var/www/html;
        internal;
    }
}

在该配置中:

  • try_files $uri.html $uri $uri/ =404; 会首先尝试查找 .html 后缀的文件,如果文件不存在,则尝试访问原始路径或目录。如果都无法找到,最终返回 404 错误页面。
  1. 处理步骤:

    1. 配置:将 try_files 指令添加到您的 Nginx 配置文件中。
    2. 重载 Nginx:在更改配置后,通过 sudo nginx -t 测试配置并重新加载 Nginx (sudo systemctl reload nginx)。
  2. 目录结构示例:

假设网站的文件结构如下:

/var/www/html
├── posts
│   └── 2025
│       └── 0611.html
├── index.html
├── 404.html
  • 当用户访问 http://your-domain.com/posts/2025/0611 时,Nginx 会自动返回 /posts/2025/0611.html 文件的内容。
  • 如果该文件不存在,Nginx 会返回 404 错误页面。

  • 三、Apache 上实现 Clean URL

Apache 作为另一种常见的 Web 服务器,同样支持使用 .htaccess 文件来实现 Clean URL 功能。

  1. Apache 配置方案:

使用 RewriteRule 来重写 URL,使其支持不带 .html 后缀的访问。

示例 .htaccess 配置:

RewriteEngine On

# 重写规则:如果访问没有 .html 后缀的 URL,自动加上 .html
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)/$ $1.html [L]
  1. 解释:
  • RewriteCond %{REQUEST_FILENAME} !-f:这行条件检查请求的文件是否存在。如果文件不存在,则执行后续的重写规则。
  • RewriteRule ^(.*)/$ $1.html [L]:该规则将没有后缀的 URL 重写为带 .html 后缀的 URL。
  1. 目录结构示例:

假设您有以下目录结构:

/var/www/html
├── posts
│   └── 2025
│       └── 0611.html
├── index.html
├── 404.html

当访问 http://your-domain.com/posts/2025/0611 时,Apache 会自动将其重定向到 http://your-domain.com/posts/2025/0611.html。


  • 四、Caddy 上实现 Clean URL

Caddy 是一款现代的 Web 服务器,具有内置的简化配置和自动 HTTPS 功能。要在 Caddy 上实现 Clean URL,可以使用 rewrite 指令。

  1. Caddy 配置方案:
your-domain.com {
    root * /var/www/html

    # 重写规则:将没有后缀的 URL 映射为带 .html 后缀的 URL
    rewrite /posts/* /posts/{1}.html

    file_server
}
  1. 解释:
  • rewrite /posts/* /posts/{1}.html:这行规则会将 /posts/* 形式的 URL 重写为 /posts/*.html。
  1. 处理步骤:
  • 重新加载 Caddy 配置:使用 caddy reload 来使配置生效。

  • 五、LiteSpeed 上实现 Clean URL

LiteSpeed 是一款高性能的 Web 服务器,类似于 Apache,它使用 .htaccess 文件进行 URL 重写。

  1. LiteSpeed 配置方案:

在 .htaccess 中使用类似 Apache 的 RewriteRule 来实现 Clean URL。

RewriteEngine On

# 重写规则:将没有 .html 后缀的 URL 自动加上 .html 后缀
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)/$ $1.html [L]
  1. 解释:

与 Apache 类似,RewriteCond 和 RewriteRule 指令将帮助您将没有 .html 后缀的 URL 映射到带 .html 后缀的页面。


  • 六、总结

各大 Web 服务器 Clean URL 实现对比:

特性VercelNginxApacheCaddyLiteSpeed
Clean URL 默认启用是需要配置 try_files需要配置 .htaccess需要配置 rewrite需要配置 .htaccess
路径映射自动映射 URL 到文件使用 try_files 映射使用 RewriteRule 映射使用 rewrite 映射使用 RewriteRule 映射
配置简易性非常简单需要手动配置需要配置 .htaccess配置简洁与 Apache 相似
适用场景静态网站、前端应用所有静态和动态站点静态和动态站点高性能网站高性能网站

总结:

通过本文的介绍,您可以根据不同的 Web 服务器平台(如 Vercel、Nginx、Apache、Caddy 和 LiteSpeed)实现 Clean URL 机制。无论是通过自动启用还是手动配置,都可以让网站的 URL 更加简洁且用户友好,改善用户体验并有助于 SEO 优化。

一言优化 ​

通过首次加载时,先显示默认一言,然后重复5秒变换一次,手动点击后显示默认一言。之后需要手动点击才可以切换

vue
<template>
  <div v-if="type === 'text'" :class="['banner', bannerType]" id="main-banner">
    <h1 class="title">你好,欢迎来到{{ theme.siteMeta.title }}</h1>
    <div class="subtitle">
      <Transition name="fade" mode="out-in">
        <span :key="displayText" class="text" @click="toggleHitokoto">
          {{ displayText }}
        </span>
      </Transition>
    </div>
    <Transition name="fade" mode="out-in">
      <i v-if="height === 'full'" class="iconfont icon-up" @click="scrollToHome" />
    </Transition>
  </div>
  <div
    v-else-if="type === 'page'"
    :class="['banner-page', 's-card', { image }]"
    :style="{
      backgroundImage: image ? `url(${image})` : null,
    }"
  >
    <div class="top">
      <div class="title">
        <span class="title-small">{{ title }}</span>
        <span class="title-big">{{ desc }}</span>
      </div>
      <div class="top-right">
        <slot name="header-slot" />
      </div>
    </div>
    <slot />
    <div class="footer">
      <div class="footer-left">
        {{ footer }}
      </div>
      <div class="footer-right">
        <slot name="footer-slot" />
      </div>
    </div>
  </div>
</template>

<script setup>
import { mainStore } from "@/store";
import { getHitokoto } from "@/api"; // 确保此路径正确,指向您获取一言的函数

const store = mainStore();
const { theme } = useData();
const props = defineProps({
  // 类型
  type: {
    type: String,
    default: "text",
  },
  // 高度
  height: {
    type: String,
    default: "half",
  },
  // 标题
  title: {
    type: String,
    default: "这里是标题",
  },
  // 简介
  desc: {
    type: String,
    default: "这里是简介",
  },
  // 注释
  footer: {
    type: String,
    default: "",
  },
  // 背景
  image: {
    type: String,
    default: "",
  },
});

const hitokotoData = ref(null);
const hitokotoInitialTimeout = ref(null); // 用于初次加载的定时器
const bannerType = ref(null);
// —— 新增:自动切换是否激活标志 —— 
const autoSwitchActive = ref(false)

// 初始时显示默认标语
const isHitokotoDisplayed = ref(false);
const defaultSlogan = theme.value.siteMeta.description;

// 用于跟踪是否是“第一次点击”一言以切换到默认标语
const isFirstClickAfterInitialHitokoto = ref(true);

const displayText = computed(() => {
  if (isHitokotoDisplayed.value && hitokotoData.value?.hitokoto) {
    return hitokotoData.value.hitokoto;
  } else {
    return defaultSlogan;
  }
});

// 获取一言数据
const getHitokotoData = async () => {
  try {
    const result = await getHitokoto();
    const { hitokoto, from, from_who } = result;
    hitokotoData.value = { hitokoto, from, from_who };
    isHitokotoDisplayed.value = true; // 获取成功后设置为显示一言
  } catch (error) {
    // $message.error("一言获取失败"); // 假设 $message 可用
    console.error("一言获取失败:", error);
    // 如果获取失败,仍然保持默认标语状态
    isHitokotoDisplayed.value = false; // 确保显示的是默认标语
  }
};
// —— 将自动切换逻辑拆分成单独函数,不清理定时器 —— 
async function autoToggleHitokoto() {
  if (isHitokotoDisplayed.value && !isFirstClickAfterInitialHitokoto.value) {
    // 隐藏一言,显示默认
    isHitokotoDisplayed.value = false
  } else {
    // 拉取新一言
    await getHitokotoData()
  }
}
// 点击切换
// 点击时:停止自动切换,并切回默认文案
const toggleHitokoto = async () => {
  if (autoSwitchActive.value) {
    // 停掉自动循环
    clearInterval(autoSwitchInterval.value)
    autoSwitchActive.value = false
    // 切回默认文案
    isHitokotoDisplayed.value = false
    return
  }
  // 只做手动切换(不再重启自动切换)
  if (isHitokotoDisplayed.value && !isFirstClickAfterInitialHitokoto.value) {
    isHitokotoDisplayed.value = false
  } else {
    await getHitokotoData()
  }
}

// 滚动至首页
const scrollToHome = () => {
  const bannerDom = document.getElementById("main-banner");
  if (!bannerDom) return false;
  scrollTo({
    top: bannerDom.offsetHeight,
    behavior: "smooth",
  });
};

watch(
  () => store.bannerType,
  (val) => {
    bannerType.value = val;
  },
);


// —— 新增 ——
// 自动切换的 interval 引用
const autoSwitchInterval = ref(null)

onMounted(() => {
  if (props.type === "text") {
    // 4 秒后首次拉取并显示一言
    hitokotoInitialTimeout.value = setTimeout(async () => {
      await getHitokotoData()
      // 拉取完成后启动自动切换
      autoSwitchInterval.value = setInterval(() => {
        autoToggleHitokoto()
      }, 5000)
      autoSwitchActive.value = true
    }, 4000)
  }
})

onBeforeUnmount(() => {
  // 清除初始加载的定时器,防止组件卸载后仍然执行
  if (hitokotoInitialTimeout.value) {
    clearTimeout(hitokotoInitialTimeout.value);
    clearInterval(autoSwitchInterval.value)
  }
});
</script>

<style lang="scss" scoped>
/* 样式保持不变 */
.banner {
  height: 300px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  animation: fade-up 0.6s 0.1s backwards;
  transition: height 0.3s;
  &.full {
    opacity: 0;
    height: calc(100vh - 70px);
    padding-bottom: 100px;
    animation: fade-up 0.6s 0.5s forwards;
    .subtitle {
      opacity: 0;
      animation: fade-up-opacity 0.8s 0.5s forwards;
    }
  }
  .title {
    font-family: "Site Title";
    font-weight: bold;
    font-size: 2.75rem;
  }
  .subtitle {
    width: 80%;
    font-size: 1.25rem;
    opacity: 0.8;
    animation: fade-up-opacity 0.6s 0.1s backwards;
  .text {
    text-align: center;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2; // WebKit 引擎兼容性
    -webkit-box-orient: vertical; // WebKit 引擎兼容性

    line-clamp: 2; // 标准的 line-clamp 属性,提高兼容性
  }
}
  .icon-up {
    font-size: 20px;
    position: absolute;
    bottom: 60px;
    left: calc(50% - 10px);
    transform: rotate(180deg);
    animation: moveDown 2s ease-in-out infinite;
    cursor: pointer;
  }
  @media (max-width: 768px) {
    align-items: flex-start;
    height: 240px;
    .title {
      font-size: 2.25rem;
    }
    .subtitle {
      height: 50px;
      font-size: 1.125rem;
      margin-left: 8px;
      .text {
        text-align: left;
      }
    }
  }
}
.banner-page {
  position: relative;
  display: flex;
  flex-direction: column;
  padding: 2rem;
  min-height: 380px;
  background-size: cover;
  .top {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 2rem;
    .title {
      display: flex;
      flex-direction: column;
      .title-small {
        color: var(--main-font-second-color);
        font-size: 0.875rem;
      }
      .title-big {
        font-size: 2.25rem;
        font-weight: bold;
        line-height: 1.2;
        margin-top: 12px;
      }
    }
  }
  .footer {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    margin-top: auto;
    .footer-left {
      margin-top: auto;
      color: var(--main-font-second-color);
      opacity: 0.8;
    }
  }
  &.image {
    color: #fff !important;
    .top {
      .title-small {
        color: #fff;
        opacity: 0.6;
      }
    }
    .footer {
      .footer-left {
        color: #fff;
      }
      :deep(.iconfont) {
        color: #fff !important;
      }
    }
  }
  @media (max-width: 1200px) {
    min-height: 300px;
  }
  @media (max-width: 768px) {
    min-height: 260px;
    .top-right,
    .footer-right {
      display: none;
    }
  }
}
</style>

天气组件获取失败自动隐藏 ​

.vitepress\theme\components\Aside\Widgets\Weather.vue

vue
<script setup>
import { getAdcode, getWeather } from '@/api'

// 声明会在请求出错时抛出的事件
const emit = defineEmits(['fetch-error'])

const weatherData = ref(null)
const loading     = ref(true)
const error       = ref(false)

// 移动端检测:若是移动端,则不请求,直接不渲染
const isMobile = /Mobi|Android|iPhone|iPad|Pad|iPod/i.test(navigator.userAgent)

onMounted(async () => {
  if (isMobile) {
    loading.value = false
    return
  }
  try {
    const { adcode }        = await getAdcode(import.meta.env.VITE_WEATHER_KEY)
    const { lives }         = await getWeather(import.meta.env.VITE_WEATHER_KEY, adcode)
    weatherData.value       = lives[0]
  } catch (e) {
    console.error('获取天气失败:', e)
    error.value = true
    // 向父组件抛出“fetch-error”事件
    emit('fetch-error', e)
  } finally {
    loading.value = false
  }
})
</script>

.vitepress\theme\components\Aside\index.vue

vue
<template>
  <aside class="main-aside">
    <Hello v-if="theme.aside.hello.enable" class="weidgets" />
    <div class="sticky">
      <Toc v-if="theme.aside.toc.enable && showToc" class="weidgets" />
  <Weather
    v-if="theme.aside.weather.enable && showWeather"
    class="weidgets"
    @fetch-error="onWeatherError"
  />
      <Countdown class="weidgets" />
      <Tags v-if="theme.aside.tags.enable" class="weidgets" />
      <SiteData v-if="theme.aside.siteData.enable" class="weidgets" />
    </div>
  </aside>
</template>

<script setup>
const { theme } = useData();
const props = defineProps({
  // 显示目录
  showToc: {
    type: Boolean,
    default: false,
  },
});

const showWeather = ref(true)
// 一旦收到子组件的 fetch-error 事件,就把 showWeather 置为 false
function onWeatherError(err) {
  console.error('天气组件获取失败:', err)
  showWeather.value = false
}
</script>
赞赏博主
评论 隐私政策