1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-05 23:32:45 +08:00

feat(Header): enhance code stability

This commit is contained in:
2026-01-14 01:15:57 +08:00
parent 3a6810c436
commit 336fbb34fc
2 changed files with 111 additions and 136 deletions

View File

@@ -1,29 +1,20 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, reactive } from "vue";
import { useRafFn } from "@vueuse/core";
import { useRafFn, useElementHover } from "@vueuse/core";
import { useGlobalData } from "../composables/useGlobalData";
import { isClient } from "../utils/env";
// 解析 CSS 时间变量,返回毫秒数
const parseTimeToken = (cssVar: string, defaultVal: number): number => {
if (!isClient()) return defaultVal;
const val = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
if (!val) return defaultVal;
const num = parseFloat(val);
if (isNaN(num)) return defaultVal;
if (val.endsWith("s") && !val.endsWith("ms")) return num * 1000;
return num;
};
/**
* 配置与状态管理
* CSS_TOKENS: 对应样式表中的时间变量名
*/
const CSS_TOKENS = {
DURATION: "", // 自动轮播间隔
DURATION: "--carousel-duration", // 自动轮播间隔
ANIM_NORMAL: "--md-sys-motion-spring-slow-spatial-duration", // 正常切换速度
ANIM_FAST: "--md-sys-motion-spring-fast-spatial-duration", // 快速跳转速度
ANIM_FAST: "--md-sys-motion-spring-fast-spatial-duration", // 快速追赶速度
};
// 默认配置 (回退值)
/** 默认配置 */
const config = reactive({
duration: 5000,
animNormal: 600,
@@ -31,113 +22,114 @@ const config = reactive({
});
const { frontmatter, theme } = useGlobalData();
const headerRef = ref<HTMLElement | null>(null);
const isHovering = useElementHover(headerRef);
// 数据源处理
/** 图片数据与缓存 */
const blobCache = reactive(new Map<string, string>());
const virtualIndex = ref(0);
const remainingTime = ref(config.duration);
const isFastForwarding = ref(false);
const isAnimating = ref(false);
/**
* 计算当前应该显示的文章印象图列表
*/
const rawImgList = computed<string[]>(() => {
const imp = frontmatter.value.impression;
const list = Array.isArray(imp) ? imp : imp ? [imp] : [theme.value.defaultImpression];
return list.filter(Boolean);
});
const hasMultiple = computed(() => rawImgList.value.length > 1);
const totalCount = computed(() => rawImgList.value.length);
const blobCache = reactive(new Map<string, string>());
// 并行加载图片并转换为 Blob URL
const cacheImages = async (urls: string[]) => {
if (!isClient()) return;
// 筛选未缓存的 URL
const uncachedUrls = urls.filter((url) => !blobCache.has(url));
if (uncachedUrls.length === 0) return;
// 使用 Promise.all 并行请求,提高加载速度
await Promise.all(
uncachedUrls.map(async (url) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
blobCache.set(url, objectUrl);
} catch (e) {
console.warn(`[Carousel] Load failed: ${url}`, e);
blobCache.set(url, url); // 失败回退
}
})
);
};
// 清理 Blob URL 缓存
const clearCache = () => {
blobCache.forEach((url) => {
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
});
blobCache.clear();
};
// 状态管理
const virtualIndex = ref(0);
const remainingTime = ref(config.duration);
const isHovering = ref(false);
const isFastForwarding = ref(false);
const isAnimating = ref(false);
// 核心计算逻辑
const currentRealIndex = computed(() => {
if (totalCount.value === 0) return 0;
return ((virtualIndex.value % totalCount.value) + totalCount.value) % totalCount.value;
});
const hasMultiple = computed(() => totalCount.value > 1);
const animDuration = computed(() => (isFastForwarding.value ? config.animFast : config.animNormal));
/**
* 计算环形进度条百分比
*/
const progress = computed(() => {
if (!hasMultiple.value) return 0;
if (isFastForwarding.value) return 100;
return ((config.duration - remainingTime.value) / config.duration) * 100;
});
// 计算槽位状态
/**
* 获取真实的索引(对总数取模)
*/
const currentRealIndex = computed(() => {
if (totalCount.value === 0) return 0;
return ((virtualIndex.value % totalCount.value) + totalCount.value) % totalCount.value;
});
/**
* 解析 CSS 变量中的时间值
* @param cssVar CSS 变量名
* @param defaultVal 回退默认值
*/
const parseTimeToken = (cssVar: string, defaultVal: number): number => {
if (!isClient()) return defaultVal;
const val = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
if (!val) return defaultVal;
const num = parseFloat(val);
if (isNaN(num)) return defaultVal;
return val.endsWith("s") && !val.endsWith("ms") ? num * 1000 : num;
};
/**
* 并行加载图片并存入 Blob 缓存以消除闪烁
* @param urls 图片地址列表
*/
const cacheImages = async (urls: string[]) => {
if (!isClient()) return;
const uncached = urls.filter((url) => !blobCache.has(url));
await Promise.all(
uncached.map(async (url) => {
try {
const res = await fetch(url);
const blob = await res.blob();
blobCache.set(url, URL.createObjectURL(blob));
} catch {
blobCache.set(url, url);
}
})
);
};
/**
* 执行切换步进动作
* @param dir 方向: 1 为后一项, -1 为前一项
*/
const step = async (dir: 1 | -1) => {
if (isAnimating.value) return;
isAnimating.value = true;
virtualIndex.value += dir;
await new Promise((resolve) => setTimeout(resolve, animDuration.value));
isAnimating.value = false;
};
/**
* 虚拟槽位状态计算 (4 槽位无限轮播逻辑)
* 将 4 个 DOM 元素映射到:当前、下一张、等待中、上一张
*/
const slotStates = computed(() => {
if (!hasMultiple.value) return [];
// 固定 4 个槽位逻辑
return [0, 1, 2, 3].map((slotId) => {
// 相对位置计算0(当前), 1(下个), 2(等待), 3(上个)
const relativePos = (slotId - (virtualIndex.value % 4) + 4) % 4;
// 状态映射表
const stateMap = [
{ cls: "current", order: 2, offset: 0 },
{ cls: "next", order: 3, offset: 1 },
{ cls: "standby", order: 4, offset: 2 },
{ cls: "previous", order: 1, offset: -1 },
];
const { cls, order, offset } = stateMap[relativePos];
const imgIndex = (((currentRealIndex.value + offset) % totalCount.value) + totalCount.value) % totalCount.value;
const rawUrl = rawImgList.value[imgIndex];
return {
id: slotId,
className: cls,
imgUrl: blobCache.get(rawUrl) || rawUrl,
order,
};
return { id: slotId, className: cls, imgUrl: blobCache.get(rawUrl) || rawUrl, order };
});
});
// 动作控制
const step = async (dir: 1 | -1) => {
if (isAnimating.value) return;
isAnimating.value = true;
virtualIndex.value += dir;
// 这里的 duration 需要动态读取当前的 config
await new Promise((resolve) => setTimeout(resolve, animDuration.value));
isAnimating.value = false;
};
// 使用 useRafFn 进行倒计时
/** 自动轮播计时器 */
const { pause, resume } = useRafFn(
({ delta }) => {
if (!hasMultiple.value || isAnimating.value || isFastForwarding.value || isHovering.value) return;
@@ -149,39 +141,32 @@ const { pause, resume } = useRafFn(
{ immediate: false }
);
// 导航控制
const handleNav = async (dir: 1 | -1) => {
if (isFastForwarding.value || !hasMultiple.value || isAnimating.value) return;
await step(dir);
remainingTime.value = config.duration;
};
// 快速跳转到指定索引
/**
* 跳转到指定索引
* @param targetIdx 目标图片索引
*/
const jumpTo = async (targetIdx: number) => {
if (!hasMultiple.value || targetIdx === currentRealIndex.value || isFastForwarding.value || isAnimating.value) return;
isFastForwarding.value = true;
// 简单计算最短路径方向
const dir = targetIdx > currentRealIndex.value ? 1 : -1;
const runFast = async () => {
const run = async () => {
await step(dir);
if (currentRealIndex.value !== targetIdx) await runFast();
if (currentRealIndex.value !== targetIdx) await run();
else {
isFastForwarding.value = false;
remainingTime.value = config.duration;
}
};
await runFast();
await run();
};
// 初始化配置
const initConfig = () => {
config.duration = parseTimeToken(CSS_TOKENS.DURATION, config.duration);
config.animNormal = parseTimeToken(CSS_TOKENS.ANIM_NORMAL, config.animNormal);
config.animFast = parseTimeToken(CSS_TOKENS.ANIM_FAST, config.animFast);
// 重置倒计时以匹配新时长
/**
* 处理手动导航
* @param dir 方向
*/
const handleNav = async (dir: 1 | -1) => {
if (isFastForwarding.value || !hasMultiple.value || isAnimating.value) return;
await step(dir);
remainingTime.value = config.duration;
};
@@ -190,13 +175,8 @@ watch(
async (newList) => {
remainingTime.value = config.duration;
virtualIndex.value = 0;
isAnimating.value = false;
isFastForwarding.value = false;
pause();
// 并行预加载
await cacheImages(newList);
if (hasMultiple.value && isClient()) resume();
},
{ immediate: true }
@@ -204,33 +184,33 @@ watch(
onMounted(() => {
if (isClient()) {
initConfig();
config.duration = parseTimeToken(CSS_TOKENS.DURATION, config.duration);
config.animNormal = parseTimeToken(CSS_TOKENS.ANIM_NORMAL, config.animNormal);
config.animFast = parseTimeToken(CSS_TOKENS.ANIM_FAST, config.animFast);
remainingTime.value = config.duration;
if (hasMultiple.value) resume();
}
});
onUnmounted(() => {
clearCache();
blobCache.forEach((url) => url.startsWith("blob:") && URL.revokeObjectURL(url));
blobCache.clear();
});
</script>
<template>
<header class="Header" @mouseenter="hasMultiple && (isHovering = true)" @mouseleave="hasMultiple && (isHovering = false)">
<header ref="headerRef" class="Header">
<div class="carousel-container" :impression-color="frontmatter.color">
<template v-if="hasMultiple">
<div class="stage" :style="{ '--anim-duration': `${animDuration}ms` }">
<div class="stage" :style="{ '--carousel-duration': `${animDuration}ms` }">
<div
v-for="slot in slotStates"
:key="slot.id"
class="item"
:class="slot.className"
:style="{
backgroundImage: `url('${slot.imgUrl}')`,
order: slot.order,
}"
:style="{ backgroundImage: `url('${slot.imgUrl}')`, order: slot.order }"
></div>
</div>
<div class="progress-ring">
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="var(--md-sys-color-tertiary-container)" stroke-width="5" />
@@ -243,28 +223,23 @@ onUnmounted(() => {
stroke-width="5"
stroke-linecap="round"
:style="{
strokeDasharray: `${2 * Math.PI * 10}`,
strokeDashoffset: `${2 * Math.PI * 10 * (1 - progress / 100)}`,
transition: isFastForwarding
? 'none'
: 'stroke-dashoffset var(--md-sys-motion-spring-fast-spatial-duration) linear',
strokeDasharray: `${2 * Math.PI * 9}`,
strokeDashoffset: `${2 * Math.PI * 9 * (1 - progress / 100)}`,
transition: isFastForwarding ? 'none' : 'stroke-dashoffset 100ms linear',
}"
/>
</svg>
</div>
<div class="controls">
<div class="prev" title="上一张" @click="handleNav(-1)"></div>
<div class="next" title="下一张" @click="handleNav(1)"></div>
</div>
<div class="indicators">
<button
v-for="(_, idx) in rawImgList"
:key="idx"
class="dot"
:class="{ active: currentRealIndex === idx }"
tabindex="-1"
@click="jumpTo(idx)"
></button>
</div>

View File

@@ -52,7 +52,7 @@
opacity: 0;
overflow: hidden;
transition: var(--anim-duration) var(--md-sys-motion-spring-slow-effect);
transition: var(--carousel-duration) var(--md-sys-motion-spring-slow-effect);
&.current {
width: 90%;