1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-08 08:44:15 +08:00

12 Commits

20 changed files with 1065 additions and 1055 deletions

View File

@@ -18,7 +18,7 @@ export default defineConfig({
lang: "zh_CN",
title: "sendevia 的小站",
titleTemplate: ":title",
description: "一个随便写写的博客",
description: "随便写写的博客",
markdown: {
anchor: {
permalink: anchor.permalink.linkAfterHeader({

View File

@@ -1,96 +1,105 @@
<script setup lang="ts">
import { computed } from "vue";
import { useBreakpoints } from "@vueuse/core";
import { usePostStore, type PostData } from "../stores/posts";
import { useGlobalData } from "../composables/useGlobalData";
import { computed, onMounted, onUnmounted, ref } from "vue";
const postsStore = usePostStore();
const articlesList = computed(() => postsStore.posts || []);
const { theme } = useGlobalData();
// 定义断点配置:屏幕宽度 -> 列数
const breakpoints = {
1600: 4,
1200: 3,
840: 3,
600: 2,
0: 1,
};
/** 响应式断点配置 */
const breakpoints = useBreakpoints({
mobile: 600,
tablet: 840,
desktop: 1200,
large: 1600,
});
const columnCount = ref(2);
/**
* 根据屏幕宽度计算当前的列数
* @returns {number} 列数(挂载前默认为 1
*/
const columnCount = computed(() => {
if (breakpoints.greaterOrEqual("large").value) return 4;
if (breakpoints.greaterOrEqual("desktop").value) return 3;
if (breakpoints.greaterOrEqual("tablet").value) return 3;
if (breakpoints.greaterOrEqual("mobile").value) return 2;
return 1;
});
// 根据屏幕宽度更新列数
const updateColumnCount = () => {
const width = window.innerWidth;
const match = Object.keys(breakpoints)
.map(Number)
.sort((a, b) => b - a)
.find((bp) => width > bp);
/**
* 获取按日期降序排列的文章列表
* @returns {PostData[]} 排序后的文章数组
*/
const articlesList = computed(() => {
const posts = [...(postsStore.posts || [])];
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
});
columnCount.value = match !== undefined ? breakpoints[match as keyof typeof breakpoints] : 1;
};
// 将文章列表拆分成 N 个数组
/**
* 将文章数据分配到不同的列数组中
* @returns {PostData[][]} 瀑布流分组数据
*/
const masonryGroups = computed(() => {
const groups: PostData[][] = Array.from({ length: columnCount.value }, () => []);
const count = columnCount.value;
const groups: PostData[][] = Array.from({ length: count }, () => []);
articlesList.value.forEach((item, index) => {
const groupIndex = index % columnCount.value;
groups[groupIndex].push(item);
groups[index % count].push(item);
});
return groups;
});
// 图片处理逻辑
/**
* 获取逻辑序号
* @param colIndex 列索引
* @param rowIndex 行索引
*/
const getLogicIndex = (colIndex: number, rowIndex: number): number => {
return rowIndex * columnCount.value + colIndex;
};
/**
* 获取文章展示图
* @param item 文章数据
*/
const getArticleImage = (item: PostData): string[] => {
if (item.impression && item.impression.length > 0) {
return item.impression;
}
const themeValue = theme.value;
if (themeValue?.defaultImpression) {
return [themeValue.defaultImpression];
}
return [];
if (item.impression?.length) return item.impression;
return theme.value?.defaultImpression ? [theme.value.defaultImpression] : [];
};
// 检查是否有可下载内容
/**
* 检查文章是否有下载内容
* @param item 文章数据
*/
const hasDownloadableContent = (item: PostData): boolean => {
if (!item.external_links || !Array.isArray(item.external_links)) {
return false;
}
return item.external_links.some((link) => link.type === "download");
return Array.isArray(item.external_links) && item.external_links.some((link) => link.type === "download");
};
onMounted(() => {
updateColumnCount();
window.addEventListener("resize", updateColumnCount);
});
onUnmounted(() => {
window.removeEventListener("resize", updateColumnCount);
});
</script>
<template>
<div class="ArticleMasonry">
<div class="masonry-column" v-for="(column, index) in masonryGroups" :key="index">
<MaterialCard
v-for="item in column"
variant="feed"
size="m"
color="outlined"
:key="item.id"
:href="item.url"
:title="item.title"
:description="item.description"
:date="item.date"
:impression="getArticleImage(item)"
:downloadable="hasDownloadableContent(item)"
/>
</div>
<ClientOnly>
<div v-for="(column, colIndex) in masonryGroups" :key="colIndex" class="masonry-column">
<MaterialCard
v-for="(item, rowIndex) in column"
:key="item.id"
class="entrance"
variant="feed"
size="m"
color="outlined"
:href="item.url"
:title="item.title"
:description="item.description"
:date="item.date"
:impression="getArticleImage(item)"
:downloadable="hasDownloadableContent(item)"
:tabindex="getLogicIndex(colIndex, rowIndex) + 1"
:style="{ '--delay': getLogicIndex(colIndex, rowIndex) }"
/>
</div>
</ClientOnly>
</div>
</template>

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

@@ -1,8 +1,11 @@
<script setup lang="ts">
// todo: 焦点选择有问题,不能正确记录打开查看器之前的焦点
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { ref, computed, onMounted, nextTick, watch } from "vue";
import { useWindowSize, useEventListener, useVModel } from "@vueuse/core";
import { handleTabNavigation } from "../utils/tabNavigation";
/**
* 组件属性定义
*/
interface Props {
images: string[];
currentIndex: number;
@@ -18,350 +21,219 @@ const emit = defineEmits<{
"update:currentIndex": [index: number];
}>();
// 缩放配置常量
const ZOOM_MIN = 0.9; // 最小缩放
const ZOOM_MAX = 2.0; // 最大缩放
const ZOOM_MAX_TOUCH = 5.0; // 触摸设备最大缩放
const ZOOM_STEP = 0.15; // 缩放步长
const TOUCH_MOVE_THRESHOLD = 10; // 触摸移动阈值 (px)
/** 响应式双向绑定当前索引 */
const activeIndex = useVModel(props, "currentIndex", emit);
// 状态
/** 常量配置 */
const ZOOM_CONFIG = {
MIN: 0.9,
MAX: 2.0,
MAX_TOUCH: 5.0,
STEP: 0.15,
TOUCH_THRESHOLD: 10,
};
/** 状态管理 */
const isVisible = ref(false);
const isAnimating = ref(false);
const imageTransition = ref(false);
const windowSize = ref({ width: 0, height: 0 });
const initialTransform = ref({ scale: 0, translateX: 0, translateY: 0 });
const imageScale = ref(1);
const minScale = ref(ZOOM_MIN);
const maxScale = ref(ZOOM_MAX);
const isZooming = ref(false);
const isDragging = ref(false);
const imagePosition = ref({ x: 0, y: 0 });
const previousActiveElement = ref<HTMLElement | null>(null);
const initialTransform = ref({ scale: 0, translateX: 0, translateY: 0 });
// 计算属性
const currentImage = computed(() => props.images[props.currentIndex]);
const hasPrevious = computed(() => props.currentIndex > 0);
const hasNext = computed(() => props.currentIndex < props.images.length - 1);
/** 存储打开前的焦点元素 */
const lastActiveElement = ref<HTMLElement | null>(null);
function calculateInitialTransform() {
if (props.originPosition && props.originPosition.width > 0 && props.originPosition.height > 0) {
const viewportCenterX = window.innerWidth / 2;
const viewportCenterY = window.innerHeight / 2;
const translateX = props.originPosition.x - viewportCenterX;
const translateY = props.originPosition.y - viewportCenterY;
const targetWidth = Math.min(window.innerWidth * 0.85, window.innerHeight * 0.75);
const scale = targetWidth > 0 ? props.originPosition.width / targetWidth : 0.01;
/** 获取窗口尺寸 */
const { width: winWidth, height: winHeight } = useWindowSize();
/** 计算属性 */
const currentImage = computed(() => props.images[activeIndex.value]);
const hasPrevious = computed(() => activeIndex.value > 0);
const hasNext = computed(() => activeIndex.value < props.images.length - 1);
/**
* 计算图片从文章位置飞出的初始变换参数
*/
const calculateInitialTransform = () => {
const { x, y, width, height } = props.originPosition;
if (width > 0 && height > 0) {
const viewportCenterX = winWidth.value / 2;
const viewportCenterY = winHeight.value / 2;
const targetWidth = Math.min(winWidth.value * 0.85, winHeight.value * 0.75);
initialTransform.value = {
scale,
translateX,
translateY,
scale: targetWidth > 0 ? width / targetWidth : 0.01,
translateX: x - viewportCenterX,
translateY: y - viewportCenterY,
};
}
}
};
function show() {
// 在显示之前立即保存当前焦点,但只保存有效的可聚焦元素
previousActiveElement.value = document.activeElement as HTMLElement | null;
console.log(previousActiveElement.value);
/**
* 重置缩放与平移位置
*/
const resetZoom = () => {
imageScale.value = 1;
imagePosition.value = { x: 0, y: 0 };
};
/**
* 显示查看器,并记录当前焦点
*/
const show = () => {
// 立即记录当前活跃元素
lastActiveElement.value = document.activeElement as HTMLElement;
isVisible.value = true;
// 每次打开都重置缩放和位置,确保从文章原位置展开
resetZoom();
calculateInitialTransform();
// 开始入场
setTimeout(() => {
nextTick(() => {
// 延迟一帧触发动画,确保 CSS 过渡生效
isAnimating.value = true;
const btn = document.querySelector<HTMLButtonElement>(".btn-close");
if (btn) {
btn.focus();
}
}, 10);
}
function hide() {
// 重新计算 initialTransform页面可能已滚动以便回到正确位置
calculateInitialTransform();
// 在下一帧触发状态改变,确保浏览器能够检测到过渡的起点
requestAnimationFrame(() => {
// 开始退场
isAnimating.value = false;
setTimeout(() => {
isVisible.value = false;
// 还原之前的焦点,如果元素仍然存在且在 DOM 中
if (
previousActiveElement.value &&
typeof previousActiveElement.value.focus === "function" &&
document.body.contains(previousActiveElement.value)
) {
previousActiveElement.value.focus();
} else {
// 如果之前的焦点元素无法还原,尝试找到页面中第一个可聚焦元素
const firstFocusable = document.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as HTMLElement;
if (firstFocusable) {
firstFocusable.focus();
}
}
emit("close");
}, 300);
// 将焦点转移到查看器内部的关闭按钮
const closeBtn = document.querySelector<HTMLElement>(".btn-close");
closeBtn?.focus();
});
}
};
function navigateTo(index: number) {
if (index >= 0 && index < props.images.length) {
emit("update:currentIndex", index);
}
}
/**
* 隐藏查看器,并还原焦点
*/
const hide = () => {
calculateInitialTransform();
isAnimating.value = false;
function previousImage() {
if (hasPrevious.value) {
navigateTo(props.currentIndex - 1);
}
}
// 等待动画结束后卸载组件并归还焦点
setTimeout(() => {
isVisible.value = false;
function nextImage() {
if (hasNext.value) {
navigateTo(props.currentIndex + 1);
}
}
// 焦点还原逻辑
if (lastActiveElement.value && document.body.contains(lastActiveElement.value)) {
lastActiveElement.value.focus();
}
function handleKeydown(event: KeyboardEvent) {
emit("close");
}, 300);
};
/** 导航动作 */
const prevImage = () => hasPrevious.value && activeIndex.value--;
const nextImage = () => hasNext.value && activeIndex.value++;
/** 键盘交互处理 */
useEventListener("keydown", (e: KeyboardEvent) => {
if (!isVisible.value) return;
if (event.key === "Tab") {
const container = document.querySelector(".image-viewer") as HTMLElement;
const focusableElements = container?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements && focusableElements.length > 0) {
event.preventDefault();
handleTabNavigation(container, focusableElements, event.shiftKey);
// 陷阱焦点逻辑
if (e.key === "Tab") {
const container = document.querySelector(".ImageViewer") as HTMLElement;
const focusable = container?.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
if (focusable?.length) {
e.preventDefault();
handleTabNavigation(container, focusable, e.shiftKey);
}
return;
}
switch (event.key) {
switch (e.key) {
case "Escape":
hide();
break;
case "ArrowLeft":
previousImage();
prevImage();
break;
case "ArrowRight":
nextImage();
break;
case "+":
case "=":
imageScale.value = Math.min(maxScale.value, imageScale.value + ZOOM_STEP);
event.preventDefault();
imageScale.value = Math.min(ZOOM_CONFIG.MAX, imageScale.value + ZOOM_CONFIG.STEP);
break;
case "-":
imageScale.value = Math.max(minScale.value, imageScale.value - ZOOM_STEP);
event.preventDefault();
break;
case "Home":
navigateTo(0);
event.preventDefault();
break;
case "End":
navigateTo(props.images.length - 1);
event.preventDefault();
imageScale.value = Math.max(ZOOM_CONFIG.MIN, imageScale.value - ZOOM_CONFIG.STEP);
break;
}
}
});
// 处理图片外围点击
function handleContentClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
hide();
}
}
// 更新窗口大小
function updateWindowSize() {
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight,
};
}
// 缩放功能
function handleWheel(event: WheelEvent) {
if (!isVisible.value) return;
event.preventDefault();
// 按住 Shift 键时,滚轮用于切换图片
if (event.shiftKey) {
if (event.deltaY > 0) {
nextImage();
} else {
previousImage();
}
/** 滚轮缩放与翻页 */
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
if (e.shiftKey) {
e.deltaY > 0 ? nextImage() : prevImage();
} else {
// 不按 Shift 键时,滚轮用于缩放
const step = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
const newScale = Math.min(Math.max(imageScale.value + step, ZOOM_MIN), ZOOM_MAX);
imageScale.value = newScale;
const step = e.deltaY > 0 ? -ZOOM_CONFIG.STEP : ZOOM_CONFIG.STEP;
imageScale.value = Math.min(Math.max(imageScale.value + step, ZOOM_CONFIG.MIN), ZOOM_CONFIG.MAX);
}
}
};
// 触摸缩放功能
let initialDistance = 0;
let initialScale = 1;
function getDistance(touch1: Touch, touch2: Touch) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
// 重置缩放和位置
function resetZoom() {
imageScale.value = 1;
imagePosition.value = { x: 0, y: 0 };
}
// 双击重置
function handleDoubleClick() {
resetZoom();
}
// 拖拽功能
const dragStartPosition = ref({ x: 0, y: 0 });
const dragStartImagePosition = ref({ x: 0, y: 0 });
function handleMouseDown(event: MouseEvent) {
event.preventDefault();
/** 拖拽逻辑 */
const dragStart = { x: 0, y: 0, imgX: 0, imgY: 0 };
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
isDragging.value = true;
dragStart.x = e.clientX;
dragStart.y = e.clientY;
dragStart.imgX = imagePosition.value.x;
dragStart.imgY = imagePosition.value.y;
};
// 记录拖拽开始时的位置
dragStartPosition.value = { x: event.clientX, y: event.clientY };
dragStartImagePosition.value = { ...imagePosition.value };
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return;
const dx = (e.clientX - dragStart.x) / imageScale.value;
const dy = (e.clientY - dragStart.y) / imageScale.value;
imagePosition.value = { x: dragStart.imgX + dx, y: dragStart.imgY + dy };
};
function handleMouseMove(event: MouseEvent) {
if (isDragging.value) {
event.preventDefault();
/** 触摸交互逻辑 (多指缩放与单指拖拽) */
let initialTouchDist = 0;
let initialTouchScale = 1;
let lastTouchPos = { x: 0, y: 0 };
// 计算光标移动的偏移量
const deltaX = (event.clientX - dragStartPosition.value.x) / imageScale.value;
const deltaY = (event.clientY - dragStartPosition.value.y) / imageScale.value;
// 直接设置图片位置,使点击点跟随光标
imagePosition.value = {
x: dragStartImagePosition.value.x + deltaX,
y: dragStartImagePosition.value.y + deltaY,
};
}
}
function handleMouseUp() {
isDragging.value = false;
}
// 触摸拖拽功能
let lastTouchPosition = { x: 0, y: 0 };
let touchStartPosition = { x: 0, y: 0 };
function handleTouchStart(event: TouchEvent) {
if (event.touches.length === 2) {
event.preventDefault();
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
isZooming.value = true;
initialDistance = getDistance(event.touches[0], event.touches[1]);
initialScale = imageScale.value;
} else if (event.touches.length === 1) {
// 不立即 preventDefault先记录起始位置由 handleTouchMove 判断是否是拖拽
isDragging.value = false; // 初始不认为是拖拽
touchStartPosition = { x: event.touches[0].clientX, y: event.touches[0].clientY };
lastTouchPosition = { ...touchStartPosition };
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
initialTouchDist = Math.sqrt(dx * dx + dy * dy);
initialTouchScale = imageScale.value;
} else if (e.touches.length === 1) {
lastTouchPos = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
}
};
function handleTouchMove(event: TouchEvent) {
if (event.touches.length === 2 && isZooming.value) {
event.preventDefault();
const currentDistance = getDistance(event.touches[0], event.touches[1]);
const scaleFactor = currentDistance / initialDistance;
const newScale = Math.min(Math.max(initialScale * scaleFactor, ZOOM_MIN), ZOOM_MAX_TOUCH);
imageScale.value = newScale;
} else if (event.touches.length === 1) {
const currentTouch = event.touches[0];
const deltaX = currentTouch.clientX - touchStartPosition.x;
const deltaY = currentTouch.clientY - touchStartPosition.y;
// 判断是否超过阈值,决定是否作为拖拽
if (Math.abs(deltaX) > TOUCH_MOVE_THRESHOLD || Math.abs(deltaY) > TOUCH_MOVE_THRESHOLD) {
if (!isDragging.value) {
// 第一次超过阈值时,标记为拖拽并阻止默认行为
isDragging.value = true;
event.preventDefault();
}
// 执行拖拽逻辑
if (isDragging.value) {
event.preventDefault();
const moveDeltaX = currentTouch.clientX - lastTouchPosition.x;
const moveDeltaY = currentTouch.clientY - lastTouchPosition.y;
imagePosition.value = {
x: imagePosition.value.x + moveDeltaX,
y: imagePosition.value.y + moveDeltaY,
};
lastTouchPosition = { x: currentTouch.clientX, y: currentTouch.clientY };
}
const handleTouchMove = (e: TouchEvent) => {
if (isZooming.value && e.touches.length === 2) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
imageScale.value = Math.min(
Math.max((dist / initialTouchDist) * initialTouchScale, ZOOM_CONFIG.MIN),
ZOOM_CONFIG.MAX_TOUCH
);
} else if (e.touches.length === 1) {
const touch = e.touches[0];
const dx = touch.clientX - lastTouchPos.x;
const dy = touch.clientY - lastTouchPos.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
isDragging.value = true;
imagePosition.value.x += dx;
imagePosition.value.y += dy;
lastTouchPos = { x: touch.clientX, y: touch.clientY };
}
}
}
};
function handleTouchEnd(event: TouchEvent) {
if (event.touches.length < 2) {
isZooming.value = false;
}
/** 监听图片切换 */
watch(activeIndex, resetZoom);
if (event.touches.length === 0) {
isDragging.value = false;
}
}
onMounted(show);
// 切换图片时重置缩放和位置
watch(
() => props.currentIndex,
() => {
resetZoom();
}
);
onMounted(() => {
show();
document.addEventListener("keydown", handleKeydown);
window.addEventListener("resize", updateWindowSize);
updateWindowSize();
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
window.removeEventListener("resize", updateWindowSize);
});
defineExpose({
show,
hide,
});
defineExpose({ show, hide });
</script>
<template>
@@ -371,74 +243,64 @@ defineExpose({
:class="{ animating: isAnimating }"
role="dialog"
aria-modal="true"
aria-label="图片查看器"
tabindex="-1"
>
<!-- 控件 -->
<MaterialButton @click="hide" aria-label="关闭图片查看器" class="btn-close" icon="close" color="text" size="m" />
<MaterialButton @click="hide" class="btn-close" icon="close" color="text" size="m" aria-label="关闭" />
<MaterialButton
v-if="hasPrevious"
@click="previousImage"
aria-label="上一张图片"
@click="prevImage"
class="btn-nav prev"
icon="chevron_left"
size="l"
aria-label="上一张"
/>
<MaterialButton
v-if="hasNext"
@click="nextImage"
aria-label="下一张图片"
class="btn-nav next"
icon="chevron_right"
size="l"
aria-label="下一张"
/>
<!-- 图片主体 -->
<div
class="content"
@click="handleContentClick"
@click.self="hide"
@wheel="handleWheel"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchend="isZooming = isDragging = false"
>
<img
:src="currentImage"
:alt="`图片 ${currentIndex + 1} / ${images.length}`"
:alt="`Image ${activeIndex + 1}`"
class="content-image"
:class="{
transitioning: imageTransition,
notransition: isDragging || isZooming,
}"
@dblclick="handleDoubleClick"
:class="{ transitioning: imageTransition, notransition: isDragging || isZooming }"
@dblclick="resetZoom"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@mouseup="isDragging = false"
@mouseleave="isDragging = false"
:style="{
transform: isAnimating
? `scale(${imageScale}) translate(${imagePosition.x}px, ${imagePosition.y}px)`
: `scale(${initialTransform.scale}) translate(${initialTransform.translateX}px, ${initialTransform.translateY}px)`,
opacity: imageTransition ? 0.6 : isAnimating ? 1 : 0,
opacity: isAnimating ? 1 : 0,
cursor: imageScale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'zoom-in',
maxWidth: `${windowSize.width * 0.85}px`,
maxHeight: `${windowSize.height * 0.75}px`,
maxWidth: `${winWidth * 0.85}px`,
maxHeight: `${winHeight * 0.75}px`,
}"
/>
</div>
<!-- 缩略图导航 -->
<p class="index-text">{{ currentIndex + 1 }} / {{ images.length }}</p>
<p class="index-text">{{ activeIndex + 1 }} / {{ images.length }}</p>
<div class="thumbnails" v-if="images.length > 1">
<button
v-for="(image, index) in images"
:key="index"
v-for="(img, idx) in images"
:key="idx"
class="thumbnail"
:class="{ 'thumbnail active': index === currentIndex }"
@click="navigateTo(index)"
:aria-label="`查看图片 ${index + 1}`"
:class="{ active: idx === activeIndex }"
@click="activeIndex = idx"
>
<img :src="image" :alt="`缩略图 ${index + 1}`" />
<img :src="img" alt="thumbnail" />
</button>
</div>
</div>

View File

@@ -1,281 +1,180 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
import { useClipboard } from "@vueuse/core";
import { ref, computed, onMounted, nextTick, watch } from "vue";
import { useClipboard, useIntersectionObserver, useResizeObserver, useEventListener, useTimeoutFn } from "@vueuse/core";
import { useGlobalData } from "../composables/useGlobalData";
import { usePostStore } from "../stores/posts";
import { useScreenWidthStore } from "../stores/screenWidth";
import { isClient } from "../utils/env";
/** 全局数据与状态 */
const { page, frontmatter } = useGlobalData();
const { copy: copyToClipboard, copied: isCopied } = useClipboard();
const screenWidthStore = useScreenWidthStore();
const pageIndicator = ref<HTMLElement | null>(null);
const indicator = ref({ top: "0px", left: "0px", width: "100%", height: "0px", opacity: 0 });
const headings = ref<Array<{ id: string; text: string; level: number }>>([]);
const headingsActiveId = ref<string>("");
const postStore = usePostStore();
let ro: ResizeObserver | null = null;
let mo: MutationObserver | null = null;
let pageIndicatorObserver: IntersectionObserver | null = null;
let pageIndicatorLockedId: string | null = null;
let pageIndicatorUnlockTimer: number | null = null;
/** 响应式引用 */
const pageIndicator = ref<HTMLElement | null>(null);
const headings = ref<Array<{ id: string; text: string; level: number }>>([]);
const headingsActiveId = ref<string>("");
const indicator = ref({ top: "0px", left: "0px", width: "100%", height: "0px", opacity: 0 });
const grouped = computed(() => headings.value || []);
/** 点击导航时临时禁用滚动监听更新 */
const isLocked = ref(false);
const { start: lockTimer } = useTimeoutFn(
() => {
isLocked.value = false;
},
1200,
{ immediate: false }
);
function scrollToId(id: string) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#${id}`);
}
}
function navigateTo(id: string) {
onNavigate(id);
scrollToId(id);
}
function onNavigate(id: string) {
pageIndicatorLockedId = id;
headingsActiveId.value = id;
if (pageIndicatorUnlockTimer) {
window.clearTimeout(pageIndicatorUnlockTimer);
}
pageIndicatorUnlockTimer = window.setTimeout(() => {
pageIndicatorLockedId = null;
pageIndicatorUnlockTimer = null;
}, 1200);
}
function collectHeadings() {
const nodes = Array.from(document.querySelectorAll("h1[id], h2[id]")) as HTMLElement[];
headings.value = nodes.map((n) => ({ id: n.id, text: n.textContent?.trim() || n.id, level: +n.tagName.replace("H", "") }));
}
function createObserver() {
if (pageIndicatorObserver) pageIndicatorObserver.disconnect();
const visible = new Map<string, IntersectionObserverEntry>();
pageIndicatorObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = (entry.target as HTMLElement).id;
if (entry.isIntersecting) {
visible.set(id, entry);
} else {
visible.delete(id);
}
});
if (visible.size === 0) return;
if (pageIndicatorLockedId) {
headingsActiveId.value = pageIndicatorLockedId;
return;
}
let bestId: string | null = null;
let bestScore = -Infinity;
visible.forEach((entry, id) => {
const ratio = entry.intersectionRatio || 0;
const top = entry.boundingClientRect.top;
const score = ratio * 10000 - top;
if (score > bestScore) {
bestScore = score;
bestId = id;
}
});
if (bestId) headingsActiveId.value = bestId;
},
{ root: null, rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.1, 0.5, 1] }
);
headings.value.forEach((h) => {
const el = document.getElementById(h.id);
if (el) pageIndicatorObserver?.observe(el);
});
}
function updateIndicator() {
const nav = pageIndicator.value;
if (!nav) return;
const id = headingsActiveId.value;
if (!id) {
indicator.value.opacity = 0;
return;
}
const spanBlock = nav.querySelector(`span[data-id="${CSS.escape(id)}"]`) as HTMLElement | null;
if (!spanBlock || !spanBlock.offsetParent) {
indicator.value.opacity = 0;
return;
}
const conRect = nav.getBoundingClientRect();
const spbRect = spanBlock.getBoundingClientRect();
const left = `${spbRect.left - conRect.left}px`;
const top = `${spbRect.top - conRect.top}px`;
const width = `${spbRect.width}px`;
const height = `${spbRect.height}px`;
indicator.value = { top, left, width, height, opacity: 0.5 };
}
function toggleMonitoring(shouldMonitor: boolean) {
if (shouldMonitor) {
collectHeadings();
createObserver();
nextTick(() => updateIndicator());
if ((window as any).ResizeObserver && pageIndicator.value) {
ro = new ResizeObserver(() => updateIndicator());
ro.observe(pageIndicator.value);
pageIndicator.value.querySelectorAll("[data-id]").forEach((el) => ro!.observe(el as Element));
}
if ((window as any).MutationObserver && pageIndicator.value) {
mo = new MutationObserver(() => {
nextTick(() => {
updateIndicator();
if (ro && pageIndicator.value) {
pageIndicator.value.querySelectorAll("[data-id]").forEach((el) => ro!.observe(el as Element));
}
});
});
mo.observe(pageIndicator.value, { childList: true, subtree: true });
}
} else {
pageIndicatorObserver?.disconnect();
pageIndicatorObserver = null;
if (ro) {
ro.disconnect();
ro = null;
}
if (mo) {
mo.disconnect();
mo = null;
}
indicator.value.opacity = 0;
}
}
const resizeHandler = () => {
if (screenWidthStore.isAboveBreakpoint) {
collectHeadings();
createObserver();
}
};
// 计算文章ID
/** 计算文章 ID 与短链 */
const articleId = computed(() => {
const relativePath = page.value?.relativePath;
if (!relativePath) return "";
const path = relativePath.replace(/\.md$/, "");
const path = page.value?.relativePath?.replace(/\.md$/, "");
if (!path) return "";
const lookupUrl = path.startsWith("/") ? path : `/${path}`;
const post = postStore.getPostByUrl(lookupUrl);
return post?.id || "";
return postStore.getPostByUrl(lookupUrl)?.id || "";
});
const shortLink = computed(() => {
if (!articleId.value) return "";
return `/p/${articleId.value}`;
});
const shortLink = computed(() => (articleId.value ? `/p/${articleId.value}` : ""));
/**
* 复制短链到剪贴板
*/
const copyShortLink = async () => {
if (!shortLink.value) return;
await copyToClipboard(`${window.location.origin}${shortLink.value}`);
};
/**
* 收集页面中的 h1 和 h2 标题
*/
const collectHeadings = () => {
if (!isClient()) return;
const nodes = Array.from(document.querySelectorAll("h1[id], h2[id]")) as HTMLElement[];
headings.value = nodes.map((n) => ({
id: n.id,
text: n.textContent?.trim() || n.id,
level: +n.tagName.replace("H", ""),
}));
};
/**
* 更新指示器(高亮块)的位置和尺寸
*/
const updateIndicator = () => {
const container = pageIndicator.value;
const id = headingsActiveId.value;
if (!container || !id) {
indicator.value.opacity = 0;
return;
}
const activeElement = container.querySelector(`span[data-id="${CSS.escape(id)}"]`) as HTMLElement | null;
if (!activeElement || !activeElement.offsetParent) {
indicator.value.opacity = 0;
return;
}
const conRect = container.getBoundingClientRect();
const elRect = activeElement.getBoundingClientRect();
indicator.value = {
top: `${elRect.top - conRect.top}px`,
left: `${elRect.left - conRect.left}px`,
width: `${elRect.width}px`,
height: `${elRect.height}px`,
opacity: 0.5,
};
};
/**
* 导航到指定标题并锁定监听
* @param id 标题 ID
*/
const navigateTo = (id: string) => {
isLocked.value = true;
headingsActiveId.value = id;
lockTimer(); // 启动/重置计时器
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#${id}`);
}
};
/** 多标题可见性观察 */
const visibleMap = new Map<string, number>();
if (isClient()) {
onMounted(() => {
screenWidthStore.init();
// 监听所有标题元素的进入/退出
watch(
headings,
(newHeadings) => {
newHeadings.forEach((h) => {
const el = document.getElementById(h.id);
if (!el) return;
toggleMonitoring(screenWidthStore.isAboveBreakpoint);
useIntersectionObserver(
el,
([entry]) => {
if (entry.isIntersecting) {
// 存储分数:交集比例 * 权重 - 距离顶部距离
const score = entry.intersectionRatio * 10000 - entry.boundingClientRect.top;
visibleMap.set(h.id, score);
} else {
visibleMap.delete(h.id);
}
window.addEventListener("resize", resizeHandler);
window.addEventListener("resize", updateIndicator, { passive: true });
window.addEventListener("hashchange", () => {
if (screenWidthStore.isAboveBreakpoint) {
collectHeadings();
createObserver();
}
});
window.addEventListener("popstate", () => {
if (screenWidthStore.isAboveBreakpoint) {
collectHeadings();
createObserver();
}
});
// 非锁定状态下更新活动 ID
if (!isLocked.value && visibleMap.size > 0) {
const bestId = [...visibleMap.entries()].reduce((a, b) => (a[1] > b[1] ? a : b))[0];
headingsActiveId.value = bestId;
}
},
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.1, 0.5, 1] }
);
});
},
{ immediate: true }
);
nextTick(() => updateIndicator());
/** 监听容器及其子元素尺寸变化 */
useResizeObserver(pageIndicator, updateIndicator);
/** 窗口事件监听 */
useEventListener("resize", updateIndicator);
useEventListener(["hashchange", "popstate"], () => {
if (screenWidthStore.isAboveBreakpoint) collectHeadings();
});
onBeforeUnmount(() => {
pageIndicatorObserver?.disconnect();
pageIndicatorObserver = null;
window.removeEventListener("resize", resizeHandler);
window.removeEventListener("resize", updateIndicator);
window.removeEventListener("hashchange", () => {
collectHeadings();
createObserver();
});
window.removeEventListener("popstate", () => {
collectHeadings();
createObserver();
});
if (pageIndicatorUnlockTimer) {
window.clearTimeout(pageIndicatorUnlockTimer);
pageIndicatorUnlockTimer = null;
}
if (ro) {
ro.disconnect();
ro = null;
}
if (mo) {
mo.disconnect();
mo = null;
}
});
watch(
() => screenWidthStore.isAboveBreakpoint,
(newValue) => {
toggleMonitoring(newValue);
}
);
watch(
() => headingsActiveId.value,
() => {
if (screenWidthStore.isAboveBreakpoint) {
nextTick(() => updateIndicator());
}
}
);
watch(
() => grouped.value,
() => {
if (screenWidthStore.isAboveBreakpoint) {
nextTick(() => updateIndicator());
}
}
);
}
/** 状态同步监听 */
watch(
() => screenWidthStore.isAboveBreakpoint,
(val) => {
if (val) {
collectHeadings();
nextTick(updateIndicator);
} else {
indicator.value.opacity = 0;
}
}
);
watch(headingsActiveId, () => {
if (screenWidthStore.isAboveBreakpoint) nextTick(updateIndicator);
});
onMounted(() => {
if (isClient()) {
screenWidthStore.init();
if (screenWidthStore.isAboveBreakpoint) {
collectHeadings();
nextTick(updateIndicator);
}
}
});
</script>
<template>
@@ -283,13 +182,11 @@ if (isClient()) {
<div class="label">
<p class="text">在此页上</p>
<p class="icon">link</p>
<p class="article-id" title="复制短链" v-if="articleId" @click="copyShortLink">
{{ isCopied ? `已复制` : articleId }}
<p class="article-id" :title="isCopied ? '已复制' : '复制短链'" v-if="articleId" @click="copyShortLink">
{{ isCopied ? "已复制" : articleId }}
</p>
</div>
<h3 class="article-title">{{ frontmatter.title ? frontmatter.title : page.title }}</h3>
<h3 class="article-title">{{ frontmatter.title || page.title }}</h3>
<div
class="indicator"
:style="{
@@ -301,16 +198,15 @@ if (isClient()) {
}"
aria-hidden="true"
></div>
<div class="indicator-container">
<span v-for="h in grouped" :key="h.id" :data-id="h.id" :class="[{ active: h.id === headingsActiveId }]">
<span v-for="h in headings" :key="h.id" :data-id="h.id" :class="{ active: h.id === headingsActiveId }">
<a
:href="`#${h.id}`"
@click.prevent="navigateTo(h.id)"
role="link"
:aria-current="h.id === headingsActiveId ? 'true' : undefined"
>{{ h.text }}</a
>
{{ h.text }}
</a>
</span>
</div>
</div>

View File

@@ -4,92 +4,101 @@ import { useGlobalData } from "../composables/useGlobalData";
import { usePostStore } from "../stores/posts";
const { page } = useGlobalData();
const postsStore = usePostStore();
const postsRef = computed(() => postsStore.posts);
function normalize(u: string | undefined | null) {
/**
* 规范化路径字符串,移除 Origin、.html 后缀及末尾斜杠
* @param {string | undefined | null} u 原始路径或 URL
* @returns {string} 处理后的规范化路径
*/
function normalize(u: string | undefined | null): string {
if (!u) return "";
try {
const url = String(u);
const withoutOrigin = url.replace(/^https?:\/\/[^/]+/, "");
return withoutOrigin.replace(/(?:\.html)?\/?$/, "");
} catch (e) {
} catch {
return String(u);
}
}
/**
* 计算当前页面的潜在匹配标识符路径、Slug、文件名等
* @returns {ComputedRef<string[]>} 规范化后的候选标识符数组
*/
const currentCandidates = computed(() => {
const p = page.value as any;
const cand: string[] = [];
if (!p) return [];
const cand = new Set<string>();
// 基础属性收集
["path", "regularPath", "url", "relativePath", "filePath", "_file"].forEach((k) => {
if (p && p[k]) cand.push(String(p[k]));
if (p[k]) cand.add(String(p[k]));
});
if (p && p.frontmatter) {
if (p.frontmatter.permalink) cand.push(String(p.frontmatter.permalink));
if (p.frontmatter.slug) cand.push(String(p.frontmatter.slug));
// Frontmatter 标识收集
if (p.frontmatter) {
if (p.frontmatter.permalink) cand.add(String(p.frontmatter.permalink));
if (p.frontmatter.slug) cand.add(String(p.frontmatter.slug));
}
const filePath = p && (p.filePath || p._file || p.relativePath || "");
if (filePath && typeof filePath === "string") {
const m = filePath.match(/posts\/(.+?)\.mdx?$/) || filePath.match(/posts\/(.+?)\.md$/);
if (m && m[1]) {
const name = m[1];
cand.push(`/posts/${encodeURIComponent(name)}`);
cand.push(`/posts/${encodeURIComponent(name)}.html`);
// 针对博客文章路径的特殊解析
const filePath = p.filePath || p._file || p.relativePath || "";
if (filePath) {
const match = filePath.match(/posts\/(.+?)\.mdx?$/);
if (match?.[1]) {
const name = match[1];
cand.add(`/posts/${encodeURIComponent(name)}`);
cand.add(`/posts/${encodeURIComponent(name)}.html`);
}
}
if (p && p.title) cand.push(String(p.title));
// 标题作为保底匹配项
if (p.title) cand.add(String(p.title));
return Array.from(new Set(cand.map((c) => normalize(c))));
return Array.from(cand).map((c) => normalize(c));
});
/**
* 在文章列表中查找当前页面的索引
* @returns {ComputedRef<number>} 当前文章的索引,未找到返回 -1
*/
const currentIndex = computed(() => {
const posts = postsRef.value || [];
const posts = postsStore.posts || [];
const candidates = currentCandidates.value;
const pTitle = (page.value as any)?.title;
for (let i = 0; i < posts.length; i++) {
const post = posts[i];
return posts.findIndex((post) => {
const postNorm = normalize(post.url);
for (const c of currentCandidates.value) {
if (!c) continue;
if (postNorm === c) return i;
if (postNorm === c + "") return i;
}
const pTitle = (page.value as any)?.title;
if (pTitle && post.title && String(post.title) === String(pTitle)) return i;
}
return -1;
// 路径/标识符匹配
if (candidates.some((c) => c && postNorm === c)) return true;
// 标题匹配(保底)
if (pTitle && post.title && String(post.title) === String(pTitle)) return true;
return false;
});
});
/** 上一篇文章对象 */
const prev = computed(() => {
const posts = postsRef.value || [];
const idx = currentIndex.value;
if (idx > 0) return posts[idx - 1];
return null;
return idx > 0 ? postsStore.posts[idx - 1] : null;
});
/** 下一篇文章对象 */
const next = computed(() => {
const posts = postsRef.value || [];
const idx = currentIndex.value;
if (idx >= 0 && idx < posts.length - 1) return posts[idx + 1];
return null;
return idx >= 0 && idx < postsStore.posts.length - 1 ? postsStore.posts[idx + 1] : null;
});
</script>
<template>
<div class="PrevNext">
<a class="prev" :href="prev.url" v-if="prev">
<a v-if="prev" class="prev" :href="prev.url">
<span class="label">上一篇</span>
<span class="title">{{ prev.title }}</span>
</a>
<a class="next" :href="next.url" v-if="next">
<a v-if="next" class="next" :href="next.url">
<span class="label">下一篇</span>
<span class="title">{{ next.title }}</span>
</a>
@@ -98,5 +107,6 @@ const next = computed(() => {
<style lang="scss" scoped>
@use "sass:meta";
/* 引用现有的导航组件样式 */
@include meta.load-css("../styles/components/PrevNext");
</style>

View File

@@ -1,16 +1,14 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
import { useClipboard, useTimestamp, useDateFormat } from "@vueuse/core";
import { ref, computed, onMounted, nextTick } from "vue";
import { useClipboard, useDateFormat, useTimeAgo, useEventListener, useMutationObserver } from "@vueuse/core";
import { useGlobalData } from "../composables/useGlobalData";
import { isClient } from "../utils/env";
/** 全局状态与剪贴板 */
const { page, frontmatter } = useGlobalData();
const { copy: copyToClipboard, copied: isCopied } = useClipboard();
const { copy: copyToClipboard } = useClipboard();
// 获取当前时间戳
const now = useTimestamp({ interval: 30000 });
// 计算发布时间和最后修改时间
/** 时间处理逻辑 */
const publishTime = computed(() => frontmatter.value?.date);
const lastUpdatedTime = computed(() => page.value?.lastUpdated);
const formattedPublishDate = computed(() =>
@@ -20,161 +18,145 @@ const lastUpdatedRawTime = computed(() =>
lastUpdatedTime.value ? useDateFormat(lastUpdatedTime.value, "YYYY-MM-DD HH:mm:ss").value : ""
);
// 格式化相对时间函数
function formatTimeAgo(date: string | number | Date) {
const diff = new Date(date).getTime() - now.value; // 计算差值
const absDiff = Math.abs(diff);
// 定义时间单位阈值 (从大到小)
const units = [
{ max: Infinity, val: 31536000000, name: "year" }, // 年
{ max: 31536000000, val: 2592000000, name: "month" }, // 月
{ max: 2592000000, val: 86400000, name: "day" }, // 天
{ max: 86400000, val: 3600000, name: "hour" }, // 时
{ max: 3600000, val: 60000, name: "minute" }, // 分
] as const;
// 使用 Intl.RelativeTimeFormat 返回对应语言的格式
const rtf = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" });
for (const { val, name } of units) {
if (absDiff >= val || (name === "minute" && absDiff >= 60000)) {
// 超过该单位的基准值,则使用该单位
// 例如:差值是 2小时 (7200000ms),匹配到 hour (3600000ms)
return rtf.format(Math.round(diff / val), name);
}
/**
* 相对时间显示配置
*/
const timeAgo = useTimeAgo(
computed(() => lastUpdatedTime.value || 0),
{
messages: {
justNow: "刚刚",
invalid: "未知时间",
past: (n: string) => `${n}`,
future: (n: string) => `${n}`,
month: (n: number) => `${n}个月`,
year: (n: number) => `${n}`,
day: (n: number) => `${n}`,
week: (n: number) => `${n}`,
hour: (n: number) => `${n}小时`,
minute: (n: number) => `${n}分钟`,
second: (n: number) => `${n}`,
} as any,
}
return "刚刚";
}
);
// 最终显示的最后修改时间
/** 计算最终显示的编辑时间文本 */
const formattedLastUpdated = computed(() => {
const uDate = lastUpdatedTime.value ? new Date(lastUpdatedTime.value) : null;
const pDate = publishTime.value ? new Date(publishTime.value) : null;
if (!lastUpdatedTime.value) return "";
const uDate = new Date(lastUpdatedTime.value).getTime();
const pDate = publishTime.value ? new Date(publishTime.value).getTime() : null;
if (!uDate) return "";
// 如果没有发布时间或发布时间与修改时间极其接近1分钟内显示绝对日期
if (!pDate || Math.abs(uDate.getTime() - pDate.getTime()) < 60000) {
// 如果没有发布时间或修改时间与发布时间在1分钟内显示绝对日期
if (!pDate || Math.abs(uDate - pDate) < 60000) {
return useDateFormat(uDate, "YYYY年M月D日").value;
}
// 否则显示相对时间 (依赖 now.value会自动更新)
return `${formatTimeAgo(uDate)}编辑`;
return `${timeAgo.value}编辑`;
});
// 图片查看器相关逻辑
/** 图片查看器状态 */
const showImageViewer = ref(false);
const currentImageIndex = ref(0);
const articleImages = ref<string[]>([]);
const imageOriginPosition = ref({ x: 0, y: 0, width: 0, height: 0 });
const articleContentRef = ref<HTMLElement | null>(null);
// 打开图片查看器
function openImageViewer(index: number, event: MouseEvent) {
const contentElement = document.querySelector("#article-content");
if (contentElement) {
const images = Array.from(contentElement.querySelectorAll("img"))
.map((img) => img.src)
.filter((src) => src && !src.includes("data:"));
articleImages.value = images;
currentImageIndex.value = index;
const target = event.target as HTMLElement;
const rect = target.getBoundingClientRect();
imageOriginPosition.value = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
width: rect.width,
height: rect.height,
};
showImageViewer.value = true;
}
}
/**
* 初始化查看器参数并打开
* @param index 点击图片的索引
* @param event 点击事件对象,用于计算动画起点
*/
const openImageViewer = (index: number, event: MouseEvent) => {
const container = articleContentRef.value;
if (!container) return;
// 关闭图片查看器
function closeImageViewer() {
showImageViewer.value = false;
}
const images = Array.from(container.querySelectorAll("img"))
.map((img) => img.src)
.filter((src) => src && !src.includes("data:"));
// 更新当前图片索引
function updateCurrentImageIndex(index: number) {
articleImages.value = images;
currentImageIndex.value = index;
}
// 复制锚点链接函数
function copyAnchorLink(this: HTMLElement) {
const anchor = this as HTMLAnchorElement;
const target = event.target as HTMLElement;
const rect = target.getBoundingClientRect();
imageOriginPosition.value = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
width: rect.width,
height: rect.height,
};
showImageViewer.value = true;
};
/**
* 复制锚点链接到剪贴板
*/
const handleAnchorClick = (event: MouseEvent) => {
const anchor = (event.target as HTMLElement).closest("a.title-anchor") as HTMLAnchorElement;
if (!anchor) return;
const href = anchor.getAttribute("href");
const fullUrl = `${window.location.origin}${window.location.pathname}${href}`;
copyToClipboard(fullUrl);
const label = anchor.querySelector("span.visually-hidden") as HTMLSpanElement;
if (isCopied) {
const label = anchor.querySelector("span.visually-hidden");
if (label) {
const originalText = label.textContent;
label.textContent = "已复制";
setTimeout(() => {
label.textContent = originalText;
}, 1000);
}
}
};
// 自定义无序列表样式函数
function ulCustomBullets() {
const listItems = document.querySelectorAll("ul li") as NodeListOf<HTMLElement>;
listItems.forEach((li, index) => {
const stableRotation = ((index * 137) % 360) - 180;
const computedStyle = window.getComputedStyle(li);
const lineHeight = parseFloat(computedStyle.lineHeight);
const bulletTop = lineHeight / 2 - 8;
li.style.setProperty("--random-rotation", `${stableRotation}deg`);
li.style.setProperty("--bullet-top", `${bulletTop}px`);
});
}
/**
* 处理列表 Bullet 旋转和有序列表对齐
*/
const enhanceDomStyles = () => {
if (!articleContentRef.value) return;
// 序列表数字对齐函数
function olCountAttributes() {
const orderedLists = document.querySelectorAll("ol") as NodeListOf<HTMLElement>;
orderedLists.forEach((ol) => {
const liCount = ol.querySelectorAll("li").length;
const startAttr = ol.getAttribute("start");
const startValue = startAttr ? parseInt(startAttr, 10) : 1;
const effectiveCount = liCount + (startValue - 1);
ol.removeAttribute("data-count-range");
const digitCount = Math.max(1, Math.floor(Math.log10(effectiveCount)) + 1);
const paddingValue = 24 + (digitCount - 1) * 10;
ol.style.setProperty("padding-inline-start", `${paddingValue}px`);
// 序列表 Bullet 随机旋转
articleContentRef.value.querySelectorAll("ul li").forEach((li, index) => {
const el = li as HTMLElement;
el.style.setProperty("--random-rotation", `${((index * 137) % 360) - 180}deg`);
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight);
el.style.setProperty("--bullet-top", `${lineHeight / 2 - 8}px`);
});
}
// 有序列表数字对齐
articleContentRef.value.querySelectorAll("ol").forEach((ol) => {
const el = ol as HTMLElement;
const startValue = parseInt(el.getAttribute("start") || "1", 10);
const totalItems = el.querySelectorAll("li").length + (startValue - 1);
const digitCount = Math.max(1, Math.floor(Math.log10(totalItems)) + 1);
el.style.setProperty("padding-inline-start", `${24 + (digitCount - 1) * 10}px`);
});
};
/**
* 绑定文章图片点击监听
*/
const bindImageEvents = () => {
articleContentRef.value?.querySelectorAll("img").forEach((img, index) => {
(img as HTMLElement).onclick = (e) => openImageViewer(index, e);
});
};
if (isClient()) {
useEventListener("resize", enhanceDomStyles);
useMutationObserver(
articleContentRef,
() => {
enhanceDomStyles();
bindImageEvents();
},
{ childList: true, subtree: true, characterData: true }
);
onMounted(() => {
const anchors = document.querySelectorAll("a.title-anchor");
anchors.forEach((anchor) => {
anchor.addEventListener("click", copyAnchorLink);
});
function setupImageClickListeners() {
const contentElement = document.querySelector("#article-content");
if (contentElement) {
const images = contentElement.querySelectorAll("img") as NodeListOf<HTMLElement>;
images.forEach((img, index) => {
img.onclick = (event: MouseEvent) => openImageViewer(index, event);
});
}
}
ulCustomBullets();
olCountAttributes();
setupImageClickListeners();
window.addEventListener("resize", ulCustomBullets);
const observer = new MutationObserver(() => {
ulCustomBullets();
olCountAttributes();
setupImageClickListeners();
});
const contentElement = document.querySelector("#article-content");
if (contentElement) {
observer.observe(contentElement, { childList: true, subtree: true, characterData: true });
}
onBeforeUnmount(() => {
observer.disconnect();
window.removeEventListener("resize", ulCustomBullets);
nextTick(() => {
enhanceDomStyles();
bindImageEvents();
});
});
}
@@ -182,20 +164,18 @@ if (isClient()) {
<template>
<Header />
<main id="article-content">
<main id="article-content" ref="articleContentRef" @click="handleAnchorClick">
<hgroup>
<h1>{{ frontmatter.title || page.title }}</h1>
<div>
<div v-if="frontmatter.description">
<hr />
<h6 v-if="frontmatter.description">
{{ frontmatter.description }}
</h6>
<h6>{{ frontmatter.description }}</h6>
</div>
</hgroup>
<Content />
<PrevNext />
</main>
<div id="article-aside">
<aside id="article-aside">
<div class="post-info">
<p class="date-publish" v-if="formattedPublishDate">发布于 {{ formattedPublishDate }}</p>
<ClientOnly>
@@ -206,14 +186,14 @@ if (isClient()) {
</div>
<ButtonGroup v-if="frontmatter?.external_links" :links="frontmatter.external_links" size="m" layout="vertical" />
<PageIndicator />
</div>
</aside>
<ImageViewer
v-if="showImageViewer"
:images="articleImages"
:current-index="currentImageIndex"
:origin-position="imageOriginPosition"
@close="closeImageViewer"
@update:current-index="updateCurrentImageIndex"
@close="showImageViewer = false"
@update:current-index="currentImageIndex = $event"
/>
</template>

View File

@@ -1,22 +1,53 @@
<script setup lang="ts">
import ArticleLayout from "./Article.vue";
import NotFoundLayout from "./NotFound.vue";
import { ref, computed, watch, onMounted, nextTick } from "vue";
import { useRoute } from "vitepress";
import { useTitle, useMutationObserver } from "@vueuse/core";
import { argbFromHex } from "@material/material-color-utilities";
import { generateColorPalette } from "../utils/colorPalette";
import { onMounted, nextTick, computed, ref, watch } from "vue";
import { useRoute } from "vitepress";
import { getFormattedRandomPhrase } from "../utils/phrases";
import { useGlobalData } from "../composables/useGlobalData";
import { usePostStore } from "../stores/posts";
import { isClient } from "../utils/env";
// 导入布局组件
import ArticleLayout from "./Article.vue";
import NotFoundLayout from "./NotFound.vue";
/**
* 全局数据与路由状态
*/
const { site, page, frontmatter, theme } = useGlobalData();
const route = useRoute();
const postStore = usePostStore();
const isRedirecting = ref(false);
const pageTitle = useTitle();
/** 随机问候语 */
const randomGreeting = ref(getFormattedRandomPhrase());
/** 布局映射表 */
const layoutMap = {
article: ArticleLayout,
} as const;
type LayoutKey = keyof typeof layoutMap;
/**
* 检查并执行重定向
* 计算当前应该渲染的布局组件
* @returns {Component | null} 布局组件或空
*/
const currentLayout = computed(() => {
if (isRedirecting.value) return null;
if (frontmatter.value.home) return null;
if (page.value.isNotFound) return NotFoundLayout;
const key = (frontmatter.value.layout ?? "article") as LayoutKey;
return layoutMap[key] ?? ArticleLayout;
});
/**
* 检查路径是否符合短链格式并执行重定向
* @param {string} path 待检测的路径
* @returns {boolean} 是否正在执行重定向
*/
function checkAndRedirect(path: string): boolean {
if (isRedirecting.value) return true;
@@ -29,12 +60,10 @@ function checkAndRedirect(path: string): boolean {
if (post) {
isRedirecting.value = true;
if (isClient()) {
document.title = `跳转中 | ${site.value.title}`;
pageTitle.value = `跳转中 | ${site.value.title}`;
window.location.replace(post.url);
}
return true;
}
}
@@ -43,78 +72,89 @@ function checkAndRedirect(path: string): boolean {
let isProcessingPalette = false;
/**
* 根据当前页面环境更新 Material Design 全局色板
* 逻辑:优先尝试从 Header 的 impression-color 属性提取,否则使用主题默认色
*/
async function updatePalette() {
if (isRedirecting.value || route.path.startsWith("/p/")) return;
if (isProcessingPalette) return;
isProcessingPalette = true;
isProcessingPalette = true;
try {
await nextTick();
// 基础色:来自主题配置
const defaultColor = theme.value.defaultColor;
const defaultArgb = argbFromHex(defaultColor);
await generateColorPalette(defaultArgb);
// 尝试寻找文章头部的动态色彩属性
const el = document.querySelector(".Header div.carousel-container");
if (el) {
const colorAttr = el.getAttribute("impression-color");
if (colorAttr && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(colorAttr)) {
const argb = argbFromHex(colorAttr);
await generateColorPalette(argb);
}
const colorAttr = el?.getAttribute("impression-color");
if (colorAttr && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(colorAttr)) {
await generateColorPalette(argbFromHex(colorAttr));
} else {
await generateColorPalette(defaultArgb);
}
} finally {
isProcessingPalette = false;
}
}
// 布局映射
const layoutMap = {
article: ArticleLayout,
} as const;
/**
* 监听 Header 元素的属性变化
* 当文章轮播图切换导致 impression-color 改变时,实时更新色板
*/
if (isClient()) {
useMutationObserver(
// 明确指定 querySelector 返回的是 HTMLElement 类型
() => document.querySelector<HTMLElement>(".Header div.carousel-container"),
() => updatePalette(),
{
attributes: true,
attributeFilter: ["impression-color"],
}
);
}
type LayoutKey = keyof typeof layoutMap;
const currentLayout = computed(() => {
if (isRedirecting.value) return null;
if (frontmatter.value.home) return null;
if (page.value.isNotFound) return NotFoundLayout;
const key = (frontmatter.value.layout ?? "article") as LayoutKey;
return layoutMap[key] ?? ArticleLayout;
});
// 监听路由变化以处理重定向
/**
* 路由变化监听:处理短链重定向
*/
watch(
() => route.path,
(newPath) => {
if (checkAndRedirect(newPath)) {
return;
}
// 如果之前是重定向状态,清理状态
if (isRedirecting.value) {
if (!checkAndRedirect(newPath)) {
isRedirecting.value = false;
}
},
{ immediate: true }
);
function onAfterEnter() {
if (isRedirecting.value) return;
updatePalette();
}
function onBeforeLeave() {
if (isRedirecting.value) return;
}
if (isClient()) {
onMounted(() => {
if (!route.path.startsWith("/p/")) {
updatePalette();
/**
* 监听首页状态变化,进入首页时更新随机问候语
*/
watch(
() => frontmatter.value.home,
(isHome) => {
if (isHome) {
randomGreeting.value = getFormattedRandomPhrase();
}
});
}
);
/**
* 过渡动画钩子:进入后更新色板
*/
function onAfterEnter() {
if (!isRedirecting.value) updatePalette();
}
onMounted(() => {
if (isClient() && !route.path.startsWith("/p/")) {
updatePalette();
}
});
</script>
<template>
@@ -122,12 +162,21 @@ if (isClient()) {
<template v-if="!isRedirecting">
<NavBar />
<AppBar />
<Transition name="layout" mode="out-in" @before-leave="onBeforeLeave" @after-enter="onAfterEnter">
<Transition name="layout" mode="out-in" @after-enter="onAfterEnter">
<div class="content-flow" :key="route.path">
<main v-if="frontmatter.home" class="home-content">
<ClientOnly>
<div class="avatar-box">
<h3>
{{ randomGreeting }}
</h3>
<img src="/assets/images/avatar_transparent.png" alt="" />
<span></span>
</div>
</ClientOnly>
<hgroup class="title">
<h1>{{ site.title }}</h1>
<h6>{{ site.description }}</h6>
<h1>欢迎访问 {{ site.title }}</h1>
<h4>这是一个{{ site.description }}</h4>
</hgroup>
<ArticleMasonry />
</main>

View File

@@ -24,3 +24,23 @@
opacity: 1;
}
}
@keyframes card-entrance {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0px);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -19,6 +19,3 @@
}
}
}
@media screen and (max-width: 840px) {
}

View File

@@ -3,7 +3,7 @@
.MaterialButton {
display: inline-flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
position: relative;

View File

@@ -8,7 +8,7 @@
text-decoration: none;
overflow: hidden;
outline: none;
.content {
display: flex;
@@ -19,6 +19,8 @@
height: 100%;
width: 100%;
overflow: hidden;
.impression-area {
z-index: 1;
}
@@ -28,6 +30,24 @@
}
}
&.entrance {
animation: card-entrance var(--md-sys-motion-spring-slow-spatial-duration) calc(min(var(--delay), 20) * 0.08s)
var(--md-sys-motion-spring-slow-spatial) forwards;
opacity: 0;
transform: translateY(30px);
will-change: transform, opacity;
}
&:focus-visible {
.content {
@include mixin.focus-ring($thickness: 4);
&::after {
background-color: var(--md-sys-state-focus-state-layer);
}
}
}
&.feed {
border-radius: var(--md-sys-shape-corner-large);
@@ -36,6 +56,8 @@
flex-direction: column;
gap: 12px;
border-radius: var(--md-sys-shape-corner-large);
.impression-area {
display: flex;
flex-direction: column-reverse;
@@ -103,14 +125,6 @@
}
}
&:hover {
&:has(img:nth-child(2)) {
img:nth-child(2) {
opacity: 1;
}
}
}
img {
position: absolute;
top: 0px;
@@ -162,6 +176,16 @@
text-decoration: none;
}
}
&:hover {
.impression-area .image-container {
&:has(img:nth-child(2)) {
img:nth-child(2) {
opacity: 1;
}
}
}
}
}
}
@@ -191,3 +215,11 @@
}
}
}
@media (prefers-reduced-motion: reduce) {
.MaterialCard.entrance {
animation: none;
opacity: 1;
transform: none;
}
}

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%;

View File

@@ -41,11 +41,11 @@
.indicator {
position: absolute;
outline: 1px solid var(--md-sys-color-primary);
outline: 2px solid var(--md-sys-color-primary);
border-radius: var(--md-sys-shape-corner-extra-large);
pointer-events: none;
transition: var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
transition: var(--md-sys-motion-spring-default-spatial-duration) var(--md-sys-motion-spring-default-spatial);
z-index: 1;
}
@@ -93,13 +93,20 @@
padding-inline: 18px;
color: var(--md-sys-color-on-surface);
font-variation-settings: "wght" 200;
font-variation-settings: "wght" 400;
text-decoration: none;
border-radius: var(--md-sys-shape-corner-extra-large);
opacity: 0.5;
transition: font-variation-settings var(--md-sys-motion-spring-fast-effect-duration)
var(--md-sys-motion-spring-fast-effect-duration),
opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect-duration);
&:focus-visible {
background-color: var(--md-sys-color-surface-container);
opacity: 1;
}
}
@@ -108,6 +115,8 @@
color: var(--md-sys-color-primary);
font-variation-settings: "wght" 700;
opacity: 1;
&:focus-visible {
color: var(--md-sys-color-on-primary);

View File

@@ -33,44 +33,89 @@
.home-content {
display: flex;
flex-flow: column wrap;
gap: 42px;
align-items: center;
flex-flow: column nowrap;
grid-column: span 12;
height: 100%;
padding: 24px;
.avatar-box {
position: relative;
hgroup.title {
height: 420px;
width: 420px;
text-align: center;
h3 {
position: absolute;
top: 280px;
left: 50%;
max-width: 420px;
width: max-content;
padding-block: 6px;
padding-inline: 12px;
color: var(--md-sys-color-on-primary-container);
text-align: justify;
background-color: var(--md-sys-color-primary-container);
border-radius: var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-large);
z-index: 2;
}
img {
position: relative;
bottom: 50%;
top: 50%;
height: 300px;
width: 300px;
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
mask: var(--via-svg-mask) no-repeat 0 / 100%;
translate: 0 -50%;
z-index: 1;
}
span {
position: absolute;
left: 70px;
top: 170px;
height: 200px;
width: 200px;
background: linear-gradient(90deg, var(--md-sys-color-purple), var(--md-sys-color-purple-container));
animation: spin 60s linear infinite;
mask: var(--via-svg-mask) no-repeat 0 / 100%;
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
opacity: 0.5;
z-index: 0;
}
}
.title {
display: flex;
align-items: flex-start;
align-items: center;
flex-direction: column;
gap: 12px;
justify-content: left;
width: 100%;
margin-block-end: 84px;
h1 {
@include mixin.typescale-style("display-large");
grid-column: span 9;
}
h6 {
grid-column: span 9;
text-align: end;
}
img {
grid-column: 11 / span 2;
grid-row: 2 / span 2;
height: 120px;
width: 120px;
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
mask: var(--via-svg-mask) no-repeat 0 / 100%;
text-align: center;
word-break: keep-all;
}
}
}
@@ -114,6 +159,14 @@
.home-content {
grid-column: span 6;
.avatar-box {
zoom: 0.7;
}
.title {
zoom: 0.8;
}
}
}
}
@@ -127,6 +180,14 @@
.home-content {
grid-column: span 4;
.avatar-box {
zoom: 0.6;
}
.title {
zoom: 0.7;
}
}
}
}

View File

@@ -156,7 +156,7 @@ span {
}
.MaterialButton,
.MaterialCard,
.MaterialCard .content,
.PrevNext a {
&::after {
content: "";

View File

@@ -0,0 +1,111 @@
/**
* 短语库 - 用于在主页显示随机问候语
*/
export interface Phrase {
text: string;
timeOfDay?: "morning" | "afternoon" | "evening" | "any";
}
/**
* 获取当前时间段
* @returns {'morning' | 'afternoon' | 'evening'} 当前时间段
*/
export function getCurrentTimeOfDay(): "morning" | "afternoon" | "evening" {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) {
return "morning"; // 早上: 5:00 - 11:59
} else if (hour >= 12 && hour < 18) {
return "afternoon"; // 中午: 12:00 - 17:59
} else {
return "evening"; // 晚上: 18:00 - 4:59
}
}
/**
* 短语库 - 按时间段分类
*/
export const phrasesByTime: Record<"morning" | "afternoon" | "evening" | "any", Phrase[]> = {
morning: [
{ text: "早上好!新的一天开始啦", timeOfDay: "morning" },
{ text: "早安,今天也要元气满满哦", timeOfDay: "morning" },
{ text: "早上好,我们赶快出发吧,这世上有太多的东西都是「过时不候」的呢", timeOfDay: "morning" },
],
afternoon: [
{ text: "中午好!休息一下吧", timeOfDay: "afternoon" },
{ text: "午后时光,适合放松一下", timeOfDay: "afternoon" },
{ text: "午休时间到,我想喝树莓薄荷饮。用两个和太阳有关的故事和你换,好不好", timeOfDay: "afternoon" },
],
evening: [
{ text: "晚上好!", timeOfDay: "evening" },
{ text: "夜幕降临,该休息了", timeOfDay: "evening" },
{ text: "星空下的问候", timeOfDay: "evening" },
{ text: "太阳落山啦,我们也该把舞台让给夜行的大家族了", timeOfDay: "evening" },
{ text: "快去睡吧,放心,我已经为你准备好甜甜的梦啦", timeOfDay: "evening" },
],
any: [
{ text: "欢迎回来", timeOfDay: "any" },
{ text: "今天过得怎么样", timeOfDay: "any" },
{ text: "很高兴见到你", timeOfDay: "any" },
{ text: "愿你有个美好的一天", timeOfDay: "any" },
{ text: "放松一下", timeOfDay: "any" },
{ text: "今天也要加油哦", timeOfDay: "any" },
{ text: "不知道干什么的话,要不要我带你去转转呀", timeOfDay: "any" },
{ text: "又有心事吗?我来陪你一起想吧", timeOfDay: "any" },
{ text: "果然要亲眼去看,才能感受到世界的美", timeOfDay: "any" },
{ text: "变聪明啦~", timeOfDay: "any" },
{ text: "手牵手~", timeOfDay: "any" },
{ text: "知识,与你分享", timeOfDay: "any" },
{ text: "好奇心值得嘉奖哦", timeOfDay: "any" },
{ text: "我等你好久啦", timeOfDay: "any" },
],
};
/**
* 获取当前时间段可用的所有短语
* @returns {Phrase[]} 当前时间段可用的短语数组
*/
export function getPhrasesForCurrentTime(): Phrase[] {
const timeOfDay = getCurrentTimeOfDay();
const timeSpecificPhrases = phrasesByTime[timeOfDay];
const anyTimePhrases = phrasesByTime.any;
return [...timeSpecificPhrases, ...anyTimePhrases];
}
/**
* 从当前时间段的短语库中随机获取一个短语
* @returns {Phrase} 随机短语对象
*/
export function getRandomPhrase(): Phrase {
const availablePhrases = getPhrasesForCurrentTime();
const randomIndex = Math.floor(Math.random() * availablePhrases.length);
return availablePhrases[randomIndex];
}
/**
* 获取随机短语文本
* @returns {string} 随机短语文本
*/
export function getFormattedRandomPhrase(): string {
const phrase = getRandomPhrase();
return phrase.text;
}
/**
* 获取指定数量的随机短语(不重复)
* @param count 需要获取的短语数量
* @returns {Phrase[]} 随机短语数组
*/
export function getRandomPhrases(count: number): Phrase[] {
const availablePhrases = getPhrasesForCurrentTime();
if (count >= availablePhrases.length) {
// 如果需要的数量大于等于总数量,直接返回打乱后的所有短语
return [...availablePhrases].sort(() => Math.random() - 0.5);
}
const shuffled = [...availablePhrases].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}

View File

@@ -1,5 +1,5 @@
{
"version": "26.1.13(240)",
"version": "26.1.14(252)",
"scripts": {
"update-version": "bash ./scripts/update-version.sh",
"docs:dev": "vitepress dev",

View File

@@ -5,8 +5,7 @@ color: ""
impression: ""
categories:
tags:
date: time
date: 2007-08-31T00:00:00Z
---
# 语法高亮

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB