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:
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user