警告:
自建独立博客需要一定的相关知识,且会比传统动态建站工具要复杂得多
你可能会遇到一系列的问题,请确保你有独立解决问题的能力
假如你想修改主题的某些组件或样式,你还需要掌握 Vue.js 的相关知识
虽然如今你可以借助 AI 来辅助完成一些需求,但知其然,知其所以然
是每一个优秀的人都应该具备的基本素养。
播放器小胶囊去除歌词
- 因为播放器胶囊在启用SSL的情况下不知道为什么歌词显示“Not available”
而且歌词本身做的也烂所以整个砍掉
TIPS
请先确保你部署了音乐API以及在 .vitepress/theme/assets/themeConfig.mjs
enable: true音乐播放器
<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>
修改说明:
- 在
.aplayer-lrc
中添加了以下样式:
margin: 0 !important;
opacity: 0 !important;
width: 0 !important;
margin-left: 0 !important; // 确保移除任何左侧间距
transition: none; // 移除过渡效果
- 这将强制歌词容器的宽度、透明度和外边距始终为零,从而完全隐藏歌词,并移除任何使它出现或消失的动画效果。
- 在
.aplayer-lrc-contents
中添加了display: none;
:
.aplayer-lrc-contents {
display: none; // 确保歌词内容不显示
}
- 这进一步确保了歌词内容本身不会被渲染。
- 在
.playing
状态下的.aplayer-lrc
中也做了类似修改:
.aplayer-lrc {
// 移除歌词显示:播放状态下也保持隐藏
opacity: 0 !important;
width: 0 !important;
}
- 这确保了即使播放器处于播放状态,歌词也始终保持隐藏,不会出现任何宽度变化。
自定义鼠标样式
在 .vitepress/theme/App.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 添加以下内容
// 自定义鼠标
#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 文件
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 删除以下内容:
const pageLink = theme.value.site + router.route.path;
if (pageLink) copyText(pageLink);
替换成以下内容:
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 即可修复
<!-- 参考资料 -->
<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 { ref, computed, onMounted, onBeforeUnmount } from 'vue';
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 文本换行:通过 CSS 属性
问题二:需要根据屏幕宽度百分比动态限制标题字数。
- 解决方案: 纯 CSS 无法直接实现根据屏幕宽度百分比动态计算和限制字数的功能。
- JavaScript 辅助实现:通过 JavaScript 获取当前的屏幕宽度 (
window.innerWidth
),根据预设的百分比和字体大小估算出最大可容纳的字符数,然后对超过这个字数限制的标题文本进行截断,并在末尾添加省略号。 - 数据绑定:在 Vue 模板中,需要将渲染的数据源从原始的
references
切换为经过 JavaScript 处理后的limitedReferences
. - 响应式更新:在组件挂载 (
onMounted
) 时监听窗口大小变化 (window.addEventListener('resize')
),并在组件卸载前 (onBeforeUnmount
) 移除监听器,以确保在屏幕尺寸变化时字数限制能够动态更新。
- JavaScript 辅助实现:通过 JavaScript 获取当前的屏幕宽度 (
问题三:在 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 文件内
// 中转页地址
const redirectPage = "/redirect.html";
/redirect 后加上 .html
slogan点击切换
第一次打开显示默认标语,而不是一言。
顺序如下:
初次打开(默认标语)等待3秒后 切换成新获取的一言 单击一次一言,切换到默认标语,再往后之后的每一次点击都是获取一句新的hitokoto显示
theme/components/Banner.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 { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
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 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; // 确保显示的是默认标语
}
};
// 切换一言和默认标语的逻辑
const toggleHitokoto = async () => {
if (isHitokotoDisplayed.value && isFirstClickAfterInitialHitokoto.value) {
// 第一次点击:当前显示一言,且是第一次点击,切换到默认标语
isHitokotoDisplayed.value = false;
isFirstClickAfterInitialHitokoto.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;
},
);
onMounted(() => {
if (props.type === "text") {
// 初次打开时,isHitokotoDisplayed 默认为 false,所以会显示默认标语。
// 3秒后获取并显示一言
hitokotoInitialTimeout.value = setTimeout(() => {
getHitokotoData();
}, 3000); // 3000 毫秒 = 3 秒
}
bannerType.value = store.bannerType;
});
onBeforeUnmount(() => {
// 清除初始加载的定时器,防止组件卸载后仍然执行
if (hitokotoInitialTimeout.value) {
clearTimeout(hitokotoInitialTimeout.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>
改动点:
isHitokotoDisplayed
初始值:
const isHitokotoDisplayed = ref(false); // 初始时设置为 false,显示默认标语
现在,它默认是 false
,这样在组件初次加载时会显示 defaultSlogan
。
hitokotoInitialTimeout
新增:
const hitokotoInitialTimeout = ref(null); // 用于初次加载的定时器
重新引入一个 ref
来存储 setTimeout
的 ID,以便在组件卸载时清除它。
isFirstClickAfterInitialHitokoto
状态:
const isFirstClickAfterInitialHitokoto = ref(true);
这个布尔值专门用于跟踪 在首次一言显示后,是否是第一次点击。
onMounted
逻辑调整:
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
。
toggleHitokoto
逻辑更新:
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
)。 在这两种情况下,我们都希望获取并显示一个新的“一言”。
- 当前显示的是默认标语(因为
onBeforeUnmount
清理:
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
逻辑会进入“获取新一言”的分支,尝试重新获取。
- 在
自定义指针适配浅色模式
- 更改 theme/App.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 { createApp } from 'vue';
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>
- 更改 theme/style/main.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);
}
}
- 更改theme/utils/cursor.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;
- 更改theme/store/index.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,
// 个性化配置显示
showSeetings: 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);
}
}
};
- 更改theme/components/Control.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 { ref, nextTick } from 'vue';
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()
方法中只是初始化了光标样式,并没有在主题变化时重新触发样式更新。 - 解决方案:
- 在
cursor.js
中,将设置光标颜色的逻辑封装到updateCursorStyle(themeType)
方法中。 - 添加
setThemeType(newThemeType)
方法,供外部调用以更新currentThemeType
并触发updateCursorStyle()
。 - 将主题监听的职责从
cursor.js
转移到 Pinia Store,避免cursor.js
承担过多职责。
- 在
- 原因:
问题2:SCSS 中定义的鼠标指针样式(例如光标本身那个圆点)无法随主题切换而改变。
- 原因: SCSS 编译后是静态 CSS,无法直接响应 JavaScript 变量的变化。
cursor.js
动态生成的 SVG 光标可以变化,但 SCSS 样式本身不行。 - 解决方案:
- SCSS 使用 CSS 变量: 在
_cursor.scss
中,将光标的背景色定义为 CSS 变量,如background: var(--cursor-bg-color, #fff);
。 - Pinia Store 动态设置 CSS 变量: 在
index.js
的updateActualThemeValue()
action 中,根据themeType
计算出实际的颜色值(#000
或#fff
),并通过document.documentElement.style.setProperty('--cursor-bg-color', '...')
将该值动态设置到<html>
元素上。这样,SCSS 中引用的 CSS 变量就会实时更新。
- SCSS 使用 CSS 变量: 在
- 原因: SCSS 编译后是静态 CSS,无法直接响应 JavaScript 变量的变化。
问题3:在“自动”模式下,系统主题从外部更改(例如通过操作系统设置)后,指针样式没有变。
- 原因: 现有逻辑主要依赖 Pinia Store 的
changeThemeType
action 来触发样式的更新,而系统层面的主题变化并没有直接触发这个 action。 - 解决方案:
- 在 Pinia Store 的
initializeCursor
函数中,添加对window.matchMedia('(prefers-color-scheme: dark)')
的监听。 - 定义一个
handleSystemThemeChange
函数作为监听器回调,在该函数内部判断当前的store.themeType
是否为“auto”。 - 如果
themeType
为“auto”且系统主题发生变化,则调用 Pinia Store 中新增的triggerThemeUpdate()
action。triggerThemeUpdate()
会调用updateActualThemeValue()
并通知appCursorInstance
更新光标样式。
- 在 Pinia Store 的
- 原因: 现有逻辑主要依赖 Pinia Store 的
二、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 内部明确触发更新可靠。 - 解决方案:
$message
安全调用: 在所有使用$message
的地方,添加if (typeof $message !== "undefined")
判断,确保$message
存在时才调用其方法。$message
全局注册: 建议在main.js
中,通过app.config.globalProperties.$message = ...
明确注册你的消息提示服务,使其在整个应用中可用。- Pinia Store 导入路径修正: 将
Control.vue
中的import
路径修改为正确的 Pinia Store 文件的相对或别名路径(例如import { mainStore } from '@/stores/index.js';
)。 - Pinia Store Action 中直接调用更新: 在
index.js
的changeThemeType()
action 内部,在this.themeType
改变后,直接调用this.updateActualThemeValue()
来更新 CSS 变量,并通知appCursorInstance
更新光标。这确保了主题改变后立即触发视觉更新。
- 原因A:
问题5:
index.js
编辑器报错:paths
的方括号不闭合。- 原因: Pinia
persist
插件的paths
选项是一个数组,由于编写或拷贝过程中的疏忽,导致数组的闭合方括号]
缺失。 - 解决方案: 在
persist
配置的paths
数组的末尾补上缺失的]
,修正语法错误。
- 原因: Pinia
三、应用生命周期与 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 过程中被跳过。
- 在
- 原因: Vue 项目在进行构建时,如果启用了服务器端渲染 (SSR),会在 Node.js 环境中尝试预渲染组件。然而,
问题7:应用加载时,光标和主题样式可能没有正确初始化,或者不与持久化数据同步。
- 原因:
cursor.js
实例的创建和 Pinia Store 的状态恢复是异步的,如果没有明确的初始化逻辑,可能导致初始视觉效果不正确。 - 解决方案:
- 在
index.js
中定义export const initializeCursor = () => { ... }
函数。 - 该函数负责:
- 确保
cursor.js
的appCursorInstance
只被创建一次,并且在非浏览器环境下不会尝试创建。 - 在
appCursorInstance
创建后,立即调用store.updateActualThemeValue()
来根据 Pinia Store 的当前状态(可能已从持久化中恢复)设置初始的 CSS 变量。 - 同时,调用
appCursorInstance.setThemeType(store.themeType)
来设置cursor.js
中 SVG 光标的初始颜色。
- 确保
- 在
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。
- Vercel 的路由机制:
Vercel 在静态网站部署过程中,自动为每个生成的静态页面映射一个 URL。对于生成的静态页面(例如 posts/2025/0611.html
),Vercel 会创建一个对应的 Clean URL 路径,即 /posts/2025/0611
。用户无需添加 .html
后缀即可访问该页面。
- 默认启用 Clean URL:
Vercel 会默认启用 Clean URL 功能,使得 URL 在访问时不需要显式地包含 .html
后缀。这对于静态页面来说非常方便,因为它使得页面 URL 更加简洁和用户友好。
- 自定义配置(如需要):
尽管 Vercel 默认启用 Clean URL,但您仍然可以在 Vercel 的配置文件 vercel.json
中自定义 URL 重定向规则。例如,您可以配置重写规则以确保特定的 URL 格式或重定向行为。
{
"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 机制。
- 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 错误页面。
处理步骤:
- 配置:将
try_files
指令添加到您的 Nginx 配置文件中。 - 重载 Nginx:在更改配置后,通过
sudo nginx -t
测试配置并重新加载 Nginx (sudo systemctl reload nginx
)。
- 配置:将
目录结构示例:
假设网站的文件结构如下:
/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 功能。
- Apache 配置方案:
使用 RewriteRule
来重写 URL,使其支持不带 .html
后缀的访问。
示例 .htaccess
配置:
RewriteEngine On
# 重写规则:如果访问没有 .html 后缀的 URL,自动加上 .html
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)/$ $1.html [L]
- 解释:
RewriteCond %{REQUEST_FILENAME} !-f
:这行条件检查请求的文件是否存在。如果文件不存在,则执行后续的重写规则。RewriteRule ^(.*)/$ $1.html [L]
:该规则将没有后缀的 URL 重写为带.html
后缀的 URL。
- 目录结构示例:
假设您有以下目录结构:
/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
指令。
- Caddy 配置方案:
your-domain.com {
root * /var/www/html
# 重写规则:将没有后缀的 URL 映射为带 .html 后缀的 URL
rewrite /posts/* /posts/{1}.html
file_server
}
- 解释:
rewrite /posts/* /posts/{1}.html
:这行规则会将/posts/*
形式的 URL 重写为/posts/*.html
。
- 处理步骤:
- 重新加载 Caddy 配置:使用
caddy reload
来使配置生效。
- 五、LiteSpeed 上实现 Clean URL
LiteSpeed 是一款高性能的 Web 服务器,类似于 Apache,它使用 .htaccess
文件进行 URL 重写。
- LiteSpeed 配置方案:
在 .htaccess
中使用类似 Apache 的 RewriteRule
来实现 Clean URL。
RewriteEngine On
# 重写规则:将没有 .html 后缀的 URL 自动加上 .html 后缀
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)/$ $1.html [L]
- 解释:
与 Apache 类似,RewriteCond
和 RewriteRule
指令将帮助您将没有 .html
后缀的 URL 映射到带 .html
后缀的页面。
- 六、总结
各大 Web 服务器 Clean URL 实现对比:
特性 | Vercel | Nginx | Apache | Caddy | LiteSpeed |
---|---|---|---|---|---|
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秒变换一次,手动点击后显示默认一言。之后需要手动点击才可以切换
<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 { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
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
<script setup>
import { ref, onMounted } from 'vue'
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
<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>