1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-07 16:22:34 +08:00

37 Commits

Author SHA1 Message Date
07db1778c3 chore(package): update to version 26.1.18(262) 2026-01-18 23:18:34 +08:00
a5621168ad feat(ArticleMasonry): enhance article filtering and pagination features 2026-01-18 23:17:18 +08:00
d9fe9ae1c4 fix(Button): update transition properties and improve child selector specificity 2026-01-18 22:51:35 +08:00
b547d81000 article: add tags 2026-01-18 22:51:15 +08:00
49f1911cfa fix(DefaultLayout): correct syntax errors 2026-01-18 22:50:37 +08:00
794eb5dea5 feat(Chip): add new MaterialChip component with styles and functionality 2026-01-18 22:49:33 +08:00
aaa506b394 feat(mixin): add G3 box 2026-01-16 17:17:45 +08:00
d5d7b34814 refactor: improve code comments for better clarity and maintainability 2026-01-14 22:40:18 +08:00
01d2ecba5b refactor(DefaultLayout): enhance avatar box styles and animations 2026-01-14 22:08:17 +08:00
76ba78bce1 refactor(styles): remove redundant -webkit-mask properties from various components 2026-01-14 21:11:40 +08:00
93dbbee3e1 chore(package): update to version 26.1.14(252) 2026-01-14 01:17:55 +08:00
3b61388272 refactor(PrevNext): improve path normalization and candidate matching logic 2026-01-14 01:16:50 +08:00
336fbb34fc feat(Header): enhance code stability 2026-01-14 01:15:57 +08:00
3a6810c436 refactor(Article): optimize time handling and enhance image viewer functionality 2026-01-14 01:10:30 +08:00
7844d3ba47 fix(DefaultLayout): restructure template for fixed layout rendering 2026-01-14 01:05:19 +08:00
9797bdf4dd fix(Button): change button alignment from center to flex-start 2026-01-14 00:48:47 +08:00
8df0464354 feat(PageIndicator): enhance indicator styles and improve accessibility 2026-01-14 00:48:34 +08:00
53b7479bea misc: correct date format and add transparent avatar image 2026-01-14 00:46:35 +08:00
c9806f812a feat(config): update blog description 2026-01-14 00:45:39 +08:00
77d368dfdf feat(DefaultLayout): enhance homepage layout and add random greeting functionality 2026-01-14 00:45:09 +08:00
efea73968a feat(ArticleMasonry): enhance card animations and accessibility 2026-01-13 16:46:56 +08:00
5b13683d2b feat(ArticleMasonry): refactor responsive layout and improve code stability 2026-01-13 15:52:04 +08:00
b17fbaf443 chore(package): update to version 26.1.13(240) 2026-01-13 01:07:38 +08:00
ba03ac1764 feat(useGlobalScroll): refactor scroll handling and improve state management 2026-01-13 01:06:54 +08:00
e1943e94bd feat(AppBar): enhance search functionality and improve styles 2026-01-13 01:06:46 +08:00
304eb88b85 fix(PageIndicator): responsive styles 2026-01-12 23:14:28 +08:00
1b74ac5964 refactor(AppBar): remove commented-out code and clean up styles 2026-01-12 16:41:36 +08:00
651713067c feat(PageIndicator): add short link copy functionality and update styles 2026-01-12 16:41:15 +08:00
788bfc543e chore(package): update to version 26.1.9(234) 2026-01-09 15:27:04 +08:00
03c217fb0e feat(version): add bash script to update package version and create tags 2026-01-09 15:26:57 +08:00
38bbc797b2 chore(package): update to version 26.1.9(232) 2026-01-09 15:07:31 +08:00
e520af9578 version: enhance version update logic to account for staged changes 2026-01-09 15:07:11 +08:00
04f543897b chore(package): update to version 26.1.9(230) 2026-01-09 15:05:48 +08:00
089f224781 chore(package): update to version 26.1.9(229) 2026-01-09 15:05:35 +08:00
38d6d8c4a7 feat(version): add script to automatically update package version 2026-01-09 14:59:37 +08:00
06a257f6db fix(Article): update selectors 2026-01-09 14:59:02 +08:00
41486c834a fix(Footer): update version link to use releases instead of tags 2026-01-08 22:23:16 +08:00
40 changed files with 2385 additions and 1432 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,55 +1,118 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { storeToRefs } from "pinia";
import { useGlobalScroll } from "../composables/useGlobalScroll";
import { storeToRefs } from "pinia";
import { usePostStore, type PostData } from "../stores/posts";
import { useSearchStateStore } from "../stores/searchState";
import { useScreenWidthStore } from "../stores/screenWidth";
import { handleTabNavigation } from "../utils/tabNavigation";
import { useRoute } from "vitepress";
const { isScrolled } = useGlobalScroll({ threshold: 100 });
/** 初始化 store 实例 */
const searchStateStore = useSearchStateStore();
const screenWidthStore = useScreenWidthStore();
const postsStore = usePostStore();
/** 解构出 posts 响应式数据 */
const { posts } = storeToRefs(postsStore);
const query = ref("");
const appbar = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null);
const isTabFocusable = computed(() => !screenWidthStore.isAboveBreakpoint);
/** 初始化路由实例 */
const route = useRoute();
// 计算过滤后的文章,使用 PostData 类型
/** DOM 元素引用 */
const scrollTarget = ref<HTMLElement | null>(null); // 滚动容器的 DOM 引用
const appbar = ref<HTMLElement | null>(null); // AppBar 自身的 DOM 引用
const searchInput = ref<HTMLInputElement | null>(null); // 搜索输入框的 DOM 引用
/** 本地响应式状态 */
const query = ref(""); // 搜索输入框的绑定值
const isHidden = ref(false); // 控制 AppBar 是否隐藏的状态
const { scrollResult, isScrolled: globalIsScrolled } = useGlobalScroll({ threshold: 100 });
const { y, directions } = scrollResult;
/** 计算属性 */
const isScrolled = computed(() => globalIsScrolled.value);
const isTabFocusable = computed(() => !screenWidthStore.isAboveBreakpoint); // 判断当前屏幕宽度下,元素是否应该可被 Tab 键聚焦
/** 获取滚动容器 */
const getScrollContainer = () => document.querySelector<HTMLElement>(".content-flow");
/**
* 监听滚动位置 y 的变化,控制 AppBar 的显示和隐藏。
*/
watch(y, (currentY) => {
// 隐藏:当检测到向下滚动且滚动距离超过指定像素时
if (directions.bottom && currentY > 300) {
isHidden.value = true;
}
// 显示:当检测到向上滚动且当前 AppBar 处于隐藏状态时
else if (directions.top && isHidden.value) {
isHidden.value = false;
}
// 额外条件:如果滚动到非常靠近顶部,无论方向如何都确保显示 AppBar
if (currentY <= 50) {
isHidden.value = false;
}
});
/**
* 计算属性:根据当前搜索查询 `query` 过滤文章列表 `posts`。
* 搜索范围包括文章标题、描述、日期、标签和分类。
* @returns {PostData[]} 过滤后的文章列表。
*/
const filteredPosts = computed<PostData[]>(() => {
const q = query.value.trim().toLowerCase();
if (!q || !posts.value.length) return [];
return posts.value.filter((post) => {
return (
post.title.includes(q) ||
post.description.includes(q) ||
post.date.includes(q) ||
post.tags.some((t) => t.includes(q)) ||
post.categories.some((t) => t.includes(q))
);
});
return posts.value.filter(
(post) =>
post.date.toLowerCase().includes(q) ||
post.title.toLowerCase().includes(q) ||
post.description.toLowerCase().includes(q) ||
post.tags.some((t) => t.toLowerCase().includes(q)) ||
post.categories.some((t) => t.toLowerCase().includes(q))
);
});
// 处理输入框焦点 (仅激活,不处理关闭)
/**
* 清除搜索状态:清空查询、取消打字状态、停用搜索模式并使输入框失焦。
*/
const clearSearchState = () => {
query.value = "";
searchStateStore.setTyping(false);
searchStateStore.deactivate();
searchInput.value?.blur();
};
/**
* 重置 AppBar 状态:隐藏状态和搜索状态。
*/
const resetAppBarState = () => {
isHidden.value = false;
clearSearchState();
};
/**
* 处理搜索输入框获得焦点事件。
* 如果搜索未激活,则激活搜索模式并设置焦点状态。
*/
const handleFocus = () => {
if (!searchStateStore.isSearchActive) {
searchStateStore.activate();
}
if (!searchStateStore.isSearchActive) searchStateStore.activate();
searchStateStore.setFocus(true);
};
// 处理输入框失焦 (只改变焦点状态,不关闭搜索,解决双击问题)
/**
* 处理搜索输入框失去焦点事件。
*/
const handleBlur = () => {
searchStateStore.setFocus(false);
};
// 处理输入
/**
* 处理搜索输入框的输入事件。
* 根据输入内容更新打字状态,如果输入框有内容且搜索未激活,则激活搜索模式。
*/
const handleInput = () => {
const hasContent = query.value.trim().length > 0;
searchStateStore.setTyping(hasContent);
@@ -58,87 +121,155 @@ const handleInput = () => {
}
};
// 清除搜索状态
const clearSearchState = () => {
query.value = "";
searchStateStore.setTyping(false);
searchStateStore.deactivate();
if (searchInput.value) {
searchInput.value.blur();
}
};
// 点击搜索结果
/**
* 处理搜索结果点击事件。
* 在短延迟后清除搜索状态,以允许导航发生。
*/
const handleResultClick = () => {
setTimeout(() => {
clearSearchState();
}, 200);
setTimeout(clearSearchState, 200);
};
// 处理外部点击
const handleDocumentClick = (event: Event) => {
if (!searchStateStore.isSearchActive) return;
const target = event.target as HTMLElement;
const isClickInsideInput = searchInput.value && searchInput.value.contains(target);
const isClickInsideResults = target.closest(".searchResult");
if (!isClickInsideInput && !isClickInsideResults && query.value.trim() === "") {
clearSearchState();
}
};
// 监听状态自动聚焦
/**
* 监听搜索激活状态的变化。
* 如果搜索被激活,则在下一个 DOM 更新周期后,尝试使搜索输入框获得焦点。
* 如果搜索被停用且 `query` 不为空,则清空 `query` 并重置打字状态。
*/
watch(
() => searchStateStore.isSearchActive,
async (isActive) => {
if (isActive && searchInput.value) {
await nextTick();
setTimeout(() => {
searchInput.value?.focus();
}, 100);
} else if (!isActive) {
if (query.value !== "") {
query.value = "";
searchStateStore.setTyping(false);
}
if (isActive) {
await nextTick(); // 确保 DOM 已更新
setTimeout(() => searchInput.value?.focus(), 100); // 延迟聚焦以防其他 DOM 操作干扰
} else if (query.value !== "") {
query.value = "";
searchStateStore.setTyping(false);
}
}
);
// 键盘事件
/** 检查是否在搜索激活状态 */
const isSearchActive = computed(() => searchStateStore.isSearchActive);
/**
* 检查点击是否在搜索区域内
*/
const isClickInsideSearchArea = (target: HTMLElement): boolean => {
const isClickInsideInput = searchInput.value?.contains(target);
const isClickResultArea = appbar.value?.closest(".result-area");
return !!(isClickInsideInput || isClickResultArea);
};
/**
* 处理全局文档点击事件。
* 如果点击发生在搜索输入框或搜索结果区域之外,且搜索查询为空,则关闭搜索状态。
* @param {Event} event 点击事件对象。
*/
const handleDocumentClick = (event: Event) => {
if (!isSearchActive.value) return;
const target = event.target as HTMLElement;
// 如果点击在搜索区域内或查询不为空,则不关闭搜索状态
if (isClickInsideSearchArea(target) || query.value.trim() !== "") {
return;
}
clearSearchState();
};
/**
* 处理全局键盘按下事件。
* 主要用于处理 Escape 键关闭搜索和 Tab 键导航。
* @param {KeyboardEvent} event 键盘事件对象。
*/
const handleKeydown = (event: KeyboardEvent) => {
if (!searchStateStore.isSearchActive) return;
if (!isSearchActive.value) return;
const container = appbar.value;
const items = container?.querySelectorAll(".searchInput, .item") || null;
// 获取所有可聚焦的元素:搜索输入框和每个搜索结果项
const items = container?.querySelectorAll<HTMLElement>(".search-input, .result-items") || null;
if (event.key === "Escape") {
event.preventDefault();
handleTabNavigation(container, items, true);
handleTabNavigation(container, items, true); // 将焦点移回输入框或 Appbar
clearSearchState();
}
if (event.key === "Tab") {
} else if (event.key === "Tab") {
event.preventDefault();
handleTabNavigation(container, items, event.shiftKey);
// 当搜索框没有内容时Tab 键取消搜索激活状态
handleTabNavigation(container, items, event.shiftKey); // 处理自定义 Tab 导航
// 如果 Tab 键导致焦点离开搜索区域且查询为空,则关闭搜索
if (query.value.trim() === "") {
searchStateStore.deactivate();
}
}
};
onMounted(() => {
/** 处理页面切换后的状态重置和滚动绑定 */
watch(
() => route.path,
async () => {
// 重置 AppBar 状态
resetAppBarState();
// 等待 DOM 更新,确保新的 .content-flow 元素已渲染
await nextTick();
// 重新获取滚动容器并赋值给 scrollTarget
const newTarget = getScrollContainer();
if (newTarget) {
scrollTarget.value = newTarget;
newTarget.scrollTop = 0;
} else {
scrollTarget.value = null;
}
}
);
/** 事件处理函数引用 */
const eventHandlers = {
click: handleDocumentClick,
keydown: handleKeydown,
} as const;
/** 添加事件监听器 */
const addEventListeners = () => {
document.addEventListener("click", eventHandlers.click);
document.addEventListener("keydown", eventHandlers.keydown);
};
/** 移除事件监听器 */
const removeEventListeners = () => {
document.removeEventListener("click", eventHandlers.click);
document.removeEventListener("keydown", eventHandlers.keydown);
};
/** 初始化函数 */
const initializeAppBar = () => {
screenWidthStore.init();
document.addEventListener("click", handleDocumentClick);
document.addEventListener("keydown", handleKeydown);
// 初始化滚动容器
const newTarget = getScrollContainer();
if (newTarget) {
scrollTarget.value = newTarget;
}
// 添加事件监听器
addEventListeners();
};
/** 清理函数 */
const cleanupAppBar = () => {
// 移除事件监听器
removeEventListeners();
// 重置状态
resetAppBarState();
};
onMounted(() => {
initializeAppBar();
});
onUnmounted(() => {
document.removeEventListener("click", handleDocumentClick);
document.removeEventListener("keydown", handleKeydown);
cleanupAppBar();
});
</script>
@@ -150,14 +281,11 @@ onUnmounted(() => {
scroll: isScrolled,
searching: searchStateStore.isSearchActive,
typing: searchStateStore.isSearchTyping,
hidden: isHidden,
}"
:tabindex="isTabFocusable ? 0 : -1"
>
<div class="action-area">
<!-- <div class="leading-button">
<MaterialButton color="text" icon="menu" size="xs" :tabindex="isTabFocusable ? 0 : -1" />
</div> -->
<input
ref="searchInput"
v-model="query"
@@ -169,29 +297,39 @@ onUnmounted(() => {
@input="handleInput"
/>
<div class="author-avatar" :tabindex="isTabFocusable ? 0 : -1">
<img src="/assets/images/avatar.webp" alt="logo" />
<div class="author-avatar" tabindex="-1">
<img src="/assets/images/avatar.webp" alt="作者头像" />
</div>
</div>
<div v-if="filteredPosts.length > 0" class="result-area">
<div class="result-area" v-if="searchStateStore.isSearchActive && filteredPosts.length > 0">
<a
v-for="(post, index) in filteredPosts"
:key="post.url"
:href="post.url"
class="item"
:tabindex="isTabFocusable ? 0 : -1"
class="result-items"
v-for="post in filteredPosts"
@click="handleResultClick"
>
<div class="title">
<h3>{{ post.title }}</h3>
<p v-if="post.date" class="date">{{ post.date }}</p>
<div class="chips">
<p class="segments date item" v-if="post.date">{{ post.date }}</p>
<div class="segments categories" v-if="post.categories.length > 0">
<p class="item" v-for="item in post.categories">{{ item }}</p>
</div>
<div class="segments tags" v-if="post.tags.length > 0">
<p class="item" v-for="item in post.tags">{{ item }}</p>
</div>
</div>
</div>
<p v-if="post.description" class="description">{{ post.description }}</p>
<!-- 只有不是最后一项时才显示分割线 -->
<hr v-if="index !== filteredPosts.length - 1" />
</a>
</div>
<div class="no-results" v-else-if="searchStateStore.isSearchActive && query.length > 0 && filteredPosts.length === 0">
<span class="icon">search_off</span>
<p class="label">未找到相关文章</p>
</div>
</div>
</template>

View File

@@ -1,96 +1,405 @@
<script setup lang="ts">
import { computed, ref, watch, nextTick } from "vue";
import { useBreakpoints, onClickOutside, useSorted } 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 isSettingsOpen = ref(false);
/** 设置面板的 DOM 引用 */
const settingsPanelRef = ref<HTMLElement | null>(null);
/** 触发按钮的 DOM 引用 */
const settingsTriggerRef = ref<HTMLElement | null>(null);
/** 选中的分类(单选,空字符串代表全部) */
const selectedCategory = ref("");
/** 选中的标签(多选,空数组代表全部) */
const selectedTags = ref<string[]>([]);
/** 排序字段: `date` | `title` */
const sortField = ref<"date" | "title">("date");
/** 排序方向: `desc` (降序/Z-A) | `asc` (正序/A-Z) */
const sortOrder = ref<"desc" | "asc">("desc");
/** 默认每页文章数量 */
const pageSize = ref(20);
/** 下拉框可选择的每页文章数量 */
const pageSizeOptions = [20, 40, 60, 100];
/** 当前页 */
const currentPage = ref(1);
/** 页码跳转输入框的引用 */
const pageInputRef = ref<HTMLInputElement | null>(null);
/** 是否正在编辑页码 */
const isEditingPage = ref(false);
/** 编辑时的临时页码绑定值 */
const inputPageNum = ref<number | "">("");
const columnCount = ref(2);
/**
* 监听排序字段变化,自动调整排序方向
* 标题:默认正序 (A-Z)
* 时间:默认倒序 (最新在前)
*/
watch(sortField, (val) => {
if (val === "title") {
sortOrder.value = "asc";
} else {
sortOrder.value = "desc";
}
});
// 根据屏幕宽度更新列数
const updateColumnCount = () => {
const width = window.innerWidth;
const match = Object.keys(breakpoints)
.map(Number)
.sort((a, b) => b - a)
.find((bp) => width > bp);
/**
* 监听筛选或排序条件变化
* 重置页码
*/
watch(
[pageSize, selectedCategory, selectedTags, sortField, sortOrder],
() => {
currentPage.value = 1;
},
{ deep: true },
);
columnCount.value = match !== undefined ? breakpoints[match as keyof typeof breakpoints] : 1;
};
/** 响应式断点配置 */
const breakpoints = useBreakpoints({
mobile: 600,
tablet: 840,
desktop: 1200,
large: 1600,
});
// 将文章列表拆分成 N 个数组
/**
* 根据屏幕宽度计算当前的列数
* @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 filteredRawList = computed(() => {
let posts = [...(postsStore.posts || [])];
// 分类筛选
if (selectedCategory.value) {
posts = posts.filter((p) => p.categories.includes(selectedCategory.value));
}
// 标签筛选
if (selectedTags.value.length > 0) {
posts = posts.filter((p) => {
return selectedTags.value.every((tag) => p.tags.includes(tag));
});
}
return posts;
});
/**
* 对筛选后的列表进行排序
* 逻辑:根据 sortField 和 sortOrder 排序
*/
const sortedArticlesList = useSorted(filteredRawList, (a, b) => {
let comparison = 0;
if (sortField.value === "date") {
// 时间比较
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
} else {
// 标题比较
comparison = String(a.title).localeCompare(String(b.title));
}
// 处理正序/倒序
return sortOrder.value === "asc" ? comparison : -comparison;
});
/**
* 点击外部关闭面板
* 忽略点击触发按钮本身,防止点击按钮时立即关闭
*/
onClickOutside(
settingsPanelRef,
() => {
isSettingsOpen.value = false;
},
{ ignore: [settingsTriggerRef] },
);
/** 计算总页数 */
const totalPages = computed(() => {
const count = Math.ceil(sortedArticlesList.value.length / pageSize.value);
return count > 0 ? count : 1;
});
/** 计算每页显示的文章(基于排序后的列表) */
const displayArticles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return sortedArticlesList.value.slice(start, end);
});
/**
* 将文章数据分配到不同的列数组中
* @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);
displayArticles.value.forEach((item, index) => {
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);
});
/**
* 切换页码并滚动到顶部
* @param page 目标页码
*/
const changePage = (page: number) => {
if (page < 1 || page > totalPages.value) return;
currentPage.value = page;
if (typeof window !== "undefined") {
const container = document.querySelector(".content-flow");
if (container) {
(container as HTMLElement).scrollTo({ top: 0, behavior: "smooth" });
} else {
window.scrollTo({ top: 0, behavior: "smooth" });
}
}
};
onUnmounted(() => {
window.removeEventListener("resize", updateColumnCount);
});
/** 激活页码编辑模式 */
const startEditPage = async () => {
inputPageNum.value = currentPage.value;
isEditingPage.value = true;
await nextTick();
pageInputRef.value?.focus();
};
/** 处理页码输入回车跳转 */
const handlePageJump = () => {
const val = Number(inputPageNum.value);
if (!isNaN(val) && val >= 1 && val <= totalPages.value) {
changePage(val);
}
isEditingPage.value = false;
};
/** 切换分类选择 */
const toggleCategory = (cat: string) => {
selectedCategory.value = selectedCategory.value === cat ? "" : cat;
};
/** 切换标签选择 (多选) */
const toggleTag = (tag: string) => {
const index = selectedTags.value.indexOf(tag);
if (index > -1) {
selectedTags.value.splice(index, 1);
} else {
selectedTags.value.push(tag);
}
};
/** 清除所有标签筛选 */
const clearTags = () => {
selectedTags.value = [];
};
</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 class="toolbar">
<div class="filter">
<div ref="settingsTriggerRef">
<MaterialButton size="s" color="text" icon="page_info" @click="isSettingsOpen = !isSettingsOpen">
列表设置
</MaterialButton>
</div>
<Transition name="expand" mode="out-in">
<aside v-if="isSettingsOpen" ref="settingsPanelRef" class="panel">
<div class="section">
<div class="section-header">
<h6>排序</h6>
</div>
<div class="page-size-options">
<MaterialButton
:color="sortField === 'date' ? 'filled' : 'tonal'"
size="s"
class="group horizontal"
icon="acute"
@click="sortField = 'date'"
>
时间
</MaterialButton>
<MaterialButton
:color="sortField === 'title' ? 'filled' : 'tonal'"
size="s"
class="group horizontal"
icon="match_case"
@click="sortField = 'title'"
>
标题
</MaterialButton>
<div>
<MaterialButton
:icon="sortOrder === 'asc' ? 'arrow_upward' : 'arrow_downward'"
color="tonal"
size="s"
@click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'"
>
{{ sortOrder === "asc" ? "正序" : "倒序" }}
</MaterialButton>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h6>每页显示</h6>
</div>
<div class="page-size-options">
<MaterialButton
v-for="opt in pageSizeOptions"
:key="opt"
:color="pageSize === opt ? 'filled' : 'tonal'"
:icon="pageSize === opt ? 'check' : ''"
class="group horizontal"
size="s"
@click="pageSize = opt"
>
{{ opt }}
</MaterialButton>
</div>
</div>
<div class="section">
<div class="section-header">
<h6>分类 <span v-if="selectedCategory" @click="selectedCategory = ''">clear</span></h6>
</div>
<div class="chip-container">
<MaterialChip
v-for="cat in postsStore.allCategories"
:key="cat"
:color="selectedCategory === cat ? 'tonal' : 'outlined'"
:icon="selectedCategory === cat ? 'check' : ''"
@click="toggleCategory(cat)"
>
{{ cat }}
</MaterialChip>
</div>
</div>
<div class="section">
<div class="section-header">
<h6>标签 <span v-if="selectedTags.length" @click="clearTags">clear</span></h6>
</div>
<div class="chip-container">
<MaterialChip
v-for="tag in postsStore.allTags"
:key="tag"
:color="selectedTags.includes(tag) ? 'tonal' : 'outlined'"
:icon="selectedTags.includes(tag) ? 'check' : ''"
@click="toggleTag(tag)"
>
{{ tag }}
</MaterialChip>
</div>
</div>
</aside>
</Transition>
</div>
</div>
<div class="masonry-wrapper">
<div v-if="displayArticles.length === 0" class="empty-state">
<span class="icon">filter_list_off</span>
<p class="label">没有找到匹配的文章</p>
</div>
<div v-for="(column, colIndex) in masonryGroups" :key="colIndex" class="masonry-column">
<MaterialCard
v-for="(item, rowIndex) in column"
:key="item.id"
: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) }"
class="entrance"
variant="feed"
size="m"
color="outlined"
/>
</div>
</div>
<div v-if="totalPages > 1" class="page-navigator">
<MaterialButton
:disabled="currentPage === 1"
:color="currentPage === 1 ? 'text' : 'filled'"
@click="changePage(currentPage - 1)"
>上一页</MaterialButton
>
<div @click="startEditPage" class="page-info-wrapper" title="点击跳转页码">
<p v-if="!isEditingPage" class="page-info-text">{{ currentPage }} / {{ totalPages }}</p>
<input
v-else
ref="pageInputRef"
v-model="inputPageNum"
@keydown.enter="handlePageJump"
@blur="isEditingPage = false"
type="text"
min="1"
class="page-jump-input"
/>
</div>
<MaterialButton
:disabled="currentPage === totalPages"
:color="currentPage === totalPages ? 'text' : 'filled'"
@click="changePage(currentPage + 1)"
>下一页</MaterialButton
>
</div>
</ClientOnly>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
interface Props {
shape?: "round" | "square";
color?: "elevated" | "filled" | "tonal" | "outlined" | "standard" | "text";
icon?: string;
href?: string;
target?: "_blank" | "_self" | "_parent" | "_top";
}
const props = withDefaults(defineProps<Props>(), {
shape: "round",
color: "filled",
target: "_blank",
});
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href"
class="MaterialChip"
:class="[props.shape, props.color, props.icon ? 'icon' : '']"
:target="props.target"
>
<span v-if="props.icon">
{{ props.icon }}
</span>
<slot></slot>
</component>
</template>
<style lang="scss" scoped>
@use "sass:meta";
@include meta.load-css("../styles/components/Chip");
</style>

View File

@@ -22,7 +22,9 @@ const siteVersion = theme.value.siteVersion;
<a href="https://github.com/sendevia/website" target="_blank">sendevia's material theme</a>
主题
</p>
<a :href="'https://github.com/sendevia/website/tags/v' + siteVersion" target="_blank"> 版本:{{ siteVersion }}</a>
<a :href="'https://github.com/sendevia/website/releases/tag/' + siteVersion" target="_blank">
版本{{ siteVersion }}</a
>
</div>
<div class="beian-info">
<div class="beian-gongan">

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,108 @@ 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 +135,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 +169,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 +178,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 +217,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,6 +1,6 @@
<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 {
@@ -18,350 +18,211 @@ 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 +232,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

@@ -129,7 +129,7 @@ function onAnimationEnd(el: EventTarget | null) {
}
}
// 监听状态变化,手动触发宽度计算
/** 监听状态变化,触发宽度计算 */
watch(
() => [navStateStore.isNavExpanded],
() => {

View File

@@ -1,264 +1,186 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
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 postStore = usePostStore();
/** 响应式引用 */
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 indicator = ref({ top: "0px", left: "0px", width: "100%", height: "0px", opacity: 0 });
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 isLocked = ref(false);
const { start: lockTimer } = useTimeoutFn(
() => {
isLocked.value = false;
},
1200,
{ immediate: false }
);
const grouped = computed(() => headings.value || []);
/** 计算文章 ID 与短链 */
const articleId = computed(() => {
const path = page.value?.relativePath?.replace(/\.md$/, "");
if (!path) return "";
const lookupUrl = path.startsWith("/") ? path : `/${path}`;
return postStore.getPostByUrl(lookupUrl)?.id || "";
});
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(); // 启动/重置计时器
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();
}
};
/** 多标题可见性观察 */
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>
<div ref="pageIndicator" class="PageIndicator" aria-label="页面目录">
<p>在此页上</p>
<h3>{{ frontmatter.title ? frontmatter.title : page.title }}</h3>
<div ref="pageIndicator" class="PageIndicator">
<div class="label">
<p class="text">在此页上</p>
<p class="icon">link</p>
<p class="article-id" :title="isCopied ? '已复制' : '复制短链'" v-if="articleId" @click="copyShortLink">
{{ isCopied ? "已复制" : articleId }}
</p>
</div>
<h3 class="article-title">{{ frontmatter.title || page.title }}</h3>
<div
class="indicator"
:style="{
@@ -270,16 +192,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>

View File

@@ -6,9 +6,7 @@
import { onMounted, onBeforeUnmount } from "vue";
import { isClient } from "../utils/env";
/**
* 元素宽度观察器配置
*/
/** 元素宽度观察器配置 */
interface ElementWidthObserverConfig {
/** CSS选择器 */
selector: string;
@@ -22,9 +20,7 @@ interface ElementWidthObserverConfig {
ignoreParentLimit?: boolean;
}
/**
* 生成有效的CSS变量名
*/
/** 生成有效的CSS变量名 */
function generateVariableName(selector: string): string {
// 移除特殊字符,用连字符连接
let name = selector
@@ -44,9 +40,7 @@ function generateVariableName(selector: string): string {
return name + "-width";
}
/**
* 格式化宽度值,保留指定精度
*/
/** 格式化宽度值,保留指定精度 */
function formatWidth(width: number, precision: number = 2): string {
// 使用toFixed确保精度但移除不必要的尾随零
const fixed = width.toFixed(precision);
@@ -54,9 +48,7 @@ function formatWidth(width: number, precision: number = 2): string {
return parseFloat(fixed).toString();
}
/**
* 设置元素宽度CSS变量到父级元素
*/
/** 设置元素宽度CSS变量到父级元素 */
export function setupWidthObserver(config: ElementWidthObserverConfig, targetElements?: HTMLElement[]): () => void {
const { selector, variableName, parentSelector, precision = 2, ignoreParentLimit = false } = config;
@@ -148,10 +140,3 @@ export function useElementWidthObserver(configs: ElementWidthObserverConfig[]) {
cleanupFunctions.forEach((cleanup) => cleanup());
});
}
/**
* 简化的单元素宽度观察器
*/
export function useSingleElementWidthObserver(config: ElementWidthObserverConfig) {
return useElementWidthObserver([config]);
}

View File

@@ -1,3 +1,7 @@
/**
* 再包装的全局数据
*/
import { useData } from "vitepress";
export function useGlobalData() {

View File

@@ -1,100 +1,52 @@
import { ref, computed, onMounted } from "vue";
/**
* 适用本项目的再包装 useScroll 工具函数
*/
import { ref, computed, watch, onMounted } from "vue";
import { useScroll } from "@vueuse/core";
import { isClient } from "../utils/env";
let container: HTMLElement | Window | null = null;
let isInitialized = false;
let isScrolled = ref(false);
let precision = 1;
let scrollPosition = ref(0);
let targetScrollable: string = ".content-flow";
let threshold = 80;
const componentCallbacks = new Map<symbol, { threshold: number; callback: (isScrolled: boolean) => void }>();
/** 全局状态 */
const globalThreshold = ref(80);
const globalPrecision = ref(1);
const globalTargetScrollable = ref(".content-flow");
const globalContainer = ref<HTMLElement | Window | null>(null);
const globalIsScrolled = ref(false);
const globalScrollPosition = ref(0);
const globalScrollPercentage = ref(0);
/**
* 容器能否滚动
* @param el 容器
*/
function isScrollable(el: HTMLElement) {
const style = window.getComputedStyle(el);
const overflowY = style.overflowY;
return overflowY === "auto" || overflowY === "scroll" || el.scrollHeight > el.clientHeight;
}
function detectContainer() {
/**
* 检测容器
* @param targetScrollable 目标容器
* @returns 判断通过的滚动容器
*/
function detectContainer(targetScrollable: string) {
if (!isClient()) return window;
const el = document.querySelector(targetScrollable);
if (el && el instanceof HTMLElement && isScrollable(el)) return el;
return window;
}
function handleGlobalScroll() {
/**
* 计算滚动百分比
* @param scrollTop 顶部距离
* @param scrollContainer 滚动容器
* @param precision 浮点精度
* @returns 百分比
*/
function calculatePercentage(scrollTop: number, scrollContainer: HTMLElement | Window, precision: number): number {
try {
const scrollTop = container === window ? window.scrollY || window.pageYOffset : (container as HTMLElement).scrollTop;
scrollPosition.value = scrollTop;
isScrolled.value = scrollTop > threshold;
componentCallbacks.forEach(({ threshold: componentThreshold, callback }) => {
callback(scrollTop > componentThreshold);
});
} catch (e) {
scrollPosition.value = 0;
isScrolled.value = false;
componentCallbacks.forEach(({ callback }) => {
callback(false);
});
}
}
function initGlobalScrollListener(initialThreshold: number = threshold, scrollContainer: string = targetScrollable) {
if (isInitialized) return;
threshold = initialThreshold;
targetScrollable = scrollContainer;
if (isClient()) {
const updateContainer = () => {
if (container) {
const target: any = container;
target.removeEventListener("scroll", handleGlobalScroll);
}
container = detectContainer();
const target: any = container;
target.addEventListener("scroll", handleGlobalScroll, { passive: true });
handleGlobalScroll();
};
updateContainer();
const checkContainerInterval = setInterval(() => {
const newContainer = detectContainer();
if (newContainer !== container) {
updateContainer();
}
}, 500);
isInitialized = true;
if ((window as any).__cleanup) {
(window as any).__cleanup.push(() => {
clearInterval(checkContainerInterval);
if (container) {
const target: any = container;
target.removeEventListener("scroll", handleGlobalScroll);
}
componentCallbacks.clear();
});
}
}
}
function calculatePercentage(precisionValue: number = precision): number {
try {
const el = document.querySelector(targetScrollable);
const scrollContainer = el && el instanceof HTMLElement && isScrollable(el as HTMLElement) ? el : window;
const scrollTop =
scrollContainer === window ? window.scrollY || window.pageYOffset : (scrollContainer as HTMLElement).scrollTop;
let scrollHeight: number, clientHeight: number;
if (scrollContainer === window) {
@@ -114,62 +66,131 @@ function calculatePercentage(precisionValue: number = precision): number {
if (maxScroll <= 0) return 0;
const percentage = Math.min(scrollTop / maxScroll, 1) * 100;
return Number(percentage.toFixed(precisionValue));
return Number(percentage.toFixed(precision));
} catch (e) {
return 0;
}
}
/** 更新全局状态 */
function updateGlobalState(y: number, container: HTMLElement | Window, threshold: number, precision: number) {
globalScrollPosition.value = y;
globalIsScrolled.value = y > threshold;
globalScrollPercentage.value = calculatePercentage(y, container, precision);
}
/** 导出 */
export function useGlobalScroll(options?: { threshold?: number; container?: string; precision?: number }) {
const localThreshold = options?.threshold ?? threshold;
const localPrecision = options?.precision ?? precision;
const localThreshold = options?.threshold ?? globalThreshold.value;
const localPrecision = options?.precision ?? globalPrecision.value;
const localTargetScrollable = options?.container ?? globalTargetScrollable.value;
const containerRef = ref<HTMLElement | Window | null>(null);
const localIsScrolled = ref(false);
const componentId = Symbol();
const updateComponentState = (isScrolled: boolean) => {
localIsScrolled.value = isScrolled;
const localScrollPosition = ref(0);
const localScrollPercentage = ref(0);
// 初始化容器
const initContainer = () => {
if (!isClient()) return;
const container = detectContainer(localTargetScrollable);
containerRef.value = container;
// 更新全局容器引用(如果是第一个实例)
if (!globalContainer.value) {
globalContainer.value = container;
}
};
onMounted(() => {
if (!isInitialized) {
initGlobalScrollListener(80);
}
componentCallbacks.set(componentId, {
threshold: localThreshold,
callback: updateComponentState,
});
handleGlobalScroll();
return () => {
componentCallbacks.delete(componentId);
};
const scrollResult = useScroll(containerRef, {
throttle: 0,
idle: 200,
eventListenerOptions: { passive: true },
});
return {
isScrolled: computed(() => localIsScrolled.value),
scrollPosition: computed(() => {
if (!container) return 0;
try {
return container === window ? window.scrollY || window.pageYOffset : (container as HTMLElement).scrollTop;
} catch (e) {
return 0;
// 监听滚动位置变化
watch(
() => scrollResult.y.value,
(y) => {
if (containerRef.value) {
const yValue = y || 0;
// 更新本地状态
localScrollPosition.value = yValue;
localIsScrolled.value = yValue > localThreshold;
localScrollPercentage.value = calculatePercentage(yValue, containerRef.value, localPrecision);
// 更新全局状态
updateGlobalState(yValue, containerRef.value, globalThreshold.value, globalPrecision.value);
}
}),
scrollPercentage: computed(() => {
scrollPosition.value;
return calculatePercentage(localPrecision);
}),
},
{ immediate: true }
);
// 容器检测和初始化
onMounted(() => {
if (isClient()) {
initContainer();
// 定期检查容器是否变化
const checkContainerInterval = setInterval(() => {
const newContainer = detectContainer(localTargetScrollable);
if (newContainer !== containerRef.value) {
containerRef.value = newContainer;
}
}, 1000);
// 清理函数
return () => {
clearInterval(checkContainerInterval);
};
}
});
// 监听选项变化
watch(
() => options?.container,
() => {
if (isClient()) {
initContainer();
}
}
);
watch(
() => options?.threshold,
(newThreshold) => {
if (newThreshold !== undefined && containerRef.value) {
localIsScrolled.value = localScrollPosition.value > newThreshold;
}
}
);
return {
// 本地状态
isScrolled: computed(() => localIsScrolled.value),
scrollPosition: computed(() => localScrollPosition.value),
scrollPercentage: computed(() => localScrollPercentage.value),
// 原始 useScroll 结果
scrollResult,
// 容器引用
container: containerRef,
// 阈值和精度
threshold: localThreshold,
precision: localPrecision,
};
}
/** 全局滚动状态 */
export const globalScrollState = {
isScrolled: isScrolled,
threshold: computed(() => threshold),
scrollPosition: computed(() => scrollPosition.value),
scrollPercentage: computed(() => {
scrollPosition.value;
return calculatePercentage(precision);
}),
precision: computed(() => precision),
isScrolled: computed(() => globalIsScrolled.value),
threshold: computed(() => globalThreshold.value),
scrollPosition: computed(() => globalScrollPosition.value),
scrollPercentage: computed(() => globalScrollPercentage.value),
precision: computed(() => globalPrecision.value),
container: computed(() => globalContainer.value),
};

View File

@@ -11,13 +11,14 @@ import ArticleMasonry from "./components/ArticleMasonry.vue";
import Button from "./components/Button.vue";
import ButtonGroup from "./components/ButtonGroup.vue";
import Card from "./components/Card.vue";
import Chip from "./components/Chip.vue";
import Footer from "./components/Footer.vue";
import Header from "./components/Header.vue";
import ImageViewer from "./components/ImageViewer.vue";
import NavBar from "./components/NavBar.vue";
import PageIndicator from "./components/PageIndicator.vue";
import PrevNext from "./components/PrevNext.vue";
import ScrollToTop from "./components/ScrollToTop.vue";
import NavBar from "./components/NavBar.vue";
// Styles
import "./styles/main.scss";
@@ -40,6 +41,7 @@ export default {
app.component("ImageViewer", ImageViewer);
app.component("MaterialButton", Button);
app.component("MaterialCard", Card);
app.component("MaterialChip", Chip);
app.component("NavBar", NavBar);
app.component("PageIndicator", PageIndicator);
app.component("PrevNext", PrevNext);

View File

@@ -1,18 +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 { usePostStore } from "../stores/posts";
import { isClient } from "../utils/env";
const postStore = usePostStore();
/** 全局状态与剪贴板 */
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(() =>
@@ -22,181 +18,137 @@ 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}编辑`;
});
// 计算文章ID
const articleId = computed(() => {
const relativePath = page.value?.relativePath;
if (!relativePath) return "";
const path = relativePath.replace(/\.md$/, "");
const lookupUrl = path.startsWith("/") ? path : `/${path}`;
const post = postStore.getPostByUrl(lookupUrl);
return post?.id || "";
});
const shortLink = computed(() => {
if (!articleId.value) return "";
return `/p/${articleId.value}`;
});
const copyShortLink = async () => {
if (!shortLink.value) return;
await copyToClipboard(`${window.location.origin}${shortLink.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();
});
});
}
@@ -204,20 +156,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>
@@ -225,21 +175,17 @@ if (isClient()) {
{{ formattedLastUpdated }}
</p>
</ClientOnly>
<p class="id" v-if="articleId">文章ID {{ articleId }}</p>
</div>
<ButtonGroup v-if="frontmatter?.external_links" :links="frontmatter.external_links" size="m" layout="vertical" />
<PageIndicator />
<MaterialButton v-if="articleId" :color="'text'" :icon="'content_copy'" @click="copyShortLink">
复制短链
</MaterialButton>
</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,47 @@
<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 +54,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 +66,83 @@ 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 }
{ 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 +150,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

@@ -1,20 +1,20 @@
/**
* 侧边导航栏状态管理
*/
import { defineStore } from "pinia";
import { ref, watch } from "vue";
import { isClient } from "../utils/env";
import { getCookie, setCookie } from "../utils/cookie";
import { useScreenWidthStore } from "./screenWidth";
/**
* 侧边导航栏状态管理
*/
/** 导出 */
export const useNavStateStore = defineStore("navState", () => {
const isNavExpanded = ref<boolean>(false);
const cookieName = "nav-expanded";
const screenWidthStore = useScreenWidthStore();
/**
* 初始从 Cookie 读取状态
*/
/** 初始从 Cookie 读取状态 */
function init() {
if (!isClient()) return;
screenWidthStore.update();
@@ -51,34 +51,26 @@ export const useNavStateStore = defineStore("navState", () => {
}
}
/**
* 保存状态到 Cookie
*/
/** 保存状态到 Cookie */
function saveToCookie() {
if (!isClient()) return;
setCookie(cookieName, isNavExpanded.value.toString());
}
/**
* 展开导航栏
*/
/** 展开导航栏 */
function expand() {
if (screenWidthStore.isAboveBreakpoint) {
isNavExpanded.value = true;
}
}
/**
* 折叠导航栏
*/
/** 折叠导航栏 */
function collapse() {
isNavExpanded.value = false;
}
/**
* 切换导航栏状态
*/
/** 切换导航栏状态 */
function toggle() {
if (isNavExpanded.value) {
collapse();

View File

@@ -1,3 +1,8 @@
/**
* 文章数据获取
* 生成文章 ID格式化头部数据
*/
import { createContentLoader, type ContentData } from "vitepress";
export interface PostData {

View File

@@ -1,3 +1,7 @@
/**
* 文章数据存储处理
*/
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { data as postsData, type PostData } from "./posts.data";

View File

@@ -1,10 +1,12 @@
/**
* 屏幕宽度响应式状态管理
*/
import { defineStore } from "pinia";
import { ref, watch, onUnmounted } from "vue";
import { isClient } from "../utils/env";
/**
* 屏幕宽度响应式状态管理
*/
/** 导出 */
export const useScreenWidthStore = defineStore("screenWidth", () => {
// 响应式状态
const screenWidth = ref<number>(0);
@@ -15,9 +17,7 @@ export const useScreenWidthStore = defineStore("screenWidth", () => {
let resizeHandler: (() => void) | null = null;
let isInitialized = false;
/**
* 更新屏幕宽度状态
*/
/** 更新屏幕宽度状态 */
function update() {
if (!isClient()) return;
@@ -25,9 +25,7 @@ export const useScreenWidthStore = defineStore("screenWidth", () => {
isAboveBreakpoint.value = screenWidth.value > breakpoint.value;
}
/**
* 初始化监听器
*/
/** 初始化监听器 */
function init() {
if (!isClient() || isInitialized) return;
@@ -43,9 +41,7 @@ export const useScreenWidthStore = defineStore("screenWidth", () => {
isInitialized = true;
}
/**
* 清理监听器
*/
/** 清理监听器 */
function cleanup() {
if (resizeHandler && isClient()) {
window.removeEventListener("resize", resizeHandler);

View File

@@ -1,25 +1,23 @@
import { defineStore } from "pinia";
import { ref, watch } from "vue";
/**
* 搜索状态管理
*/
import { defineStore } from "pinia";
import { ref, watch } from "vue";
/** 导出 */
export const useSearchStateStore = defineStore("searchState", () => {
// 响应式状态
const isSearchActive = ref<boolean>(false);
const isSearchFocused = ref<boolean>(false);
const isSearchTyping = ref<boolean>(false);
/**
* 激活搜索
*/
/** 激活搜索 */
function activate() {
isSearchActive.value = true;
}
/**
* 停用搜索
*/
/** 停用搜索 */
function deactivate() {
isSearchActive.value = false;
isSearchFocused.value = false;
@@ -46,9 +44,7 @@ export const useSearchStateStore = defineStore("searchState", () => {
isSearchTyping.value = typing;
}
/**
* 切换搜索状态
*/
/** 切换搜索状态 */
function toggle() {
if (isSearchActive.value) {
deactivate();

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

@@ -5,9 +5,8 @@
align-items: center;
flex-direction: column;
flex-wrap: nowrap;
gap: 8px;
grid-column: 2 / 3;
justify-content: space-between;
justify-content: start;
position: absolute;
right: 0px;
@@ -16,7 +15,7 @@
height: 64px;
width: 100%;
padding-inline: 4px;
padding-inline: 8px;
color: var(--md-sys-color-on-surface);
@@ -29,7 +28,6 @@
.action-area {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
position: relative;
@@ -37,34 +35,15 @@
height: 64px;
width: 100%;
.leading-button {
display: flex;
flex-direction: column;
flex-shrink: 0;
justify-content: center;
position: absolute;
left: 0px;
height: 48px;
width: 48px;
margin-inline: 4px 8px;
opacity: 1;
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
z-index: 0;
}
.search-input {
@include mixin.typescale-style("title-medium");
flex-grow: 1;
height: 56px;
height: 52px;
min-width: 0px;
margin-inline-start: 0px;
margin-inline-end: 50px;
padding-block: 0px;
padding-inline: 24px;
@@ -88,18 +67,20 @@
flex-wrap: wrap;
justify-content: center;
height: 48px;
width: 48px;
position: absolute;
right: 0px;
height: 42px;
width: 42px;
overflow: hidden;
z-index: 0;
img {
height: 32px;
width: 32px;
height: 42px;
width: 42px;
object-fit: cover;
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
mask: var(--via-svg-mask) no-repeat 0 / 100%;
}
}
@@ -109,25 +90,101 @@
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 8px;
gap: 24px;
position: absolute;
top: 64px;
top: 76px;
height: calc(100% - 76px);
width: 100%;
padding-inline: 24px;
padding-block-start: 12px;
padding-block-start: 24px;
padding-inline: 12px;
overflow: scroll;
.item {
.result-items {
padding-inline: 12px;
padding-block: 12px;
width: 100%;
text-decoration: none;
border-radius: var(--md-sys-shape-corner-medium);
.title {
display: flex;
align-items: center;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
h3 {
color: var(--md-sys-color-on-surface);
}
.chips {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
.segments {
display: flex;
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
gap: 4px;
.item,
&.item {
padding-inline: 8px;
border-radius: var(--md-sys-shape-corner-medium);
}
&::before {
@include mixin.material-symbols($size: 16);
}
&.date {
color: var(--md-sys-color-on-tertiary-container);
background-color: var(--md-sys-color-tertiary-container);
&::before {
content: "calendar_today";
}
}
&.categories {
.item {
color: var(--md-sys-color-on-purple-container);
background-color: var(--md-sys-color-purple-container);
}
&::before {
content: "category";
}
}
&.tags {
.item {
color: var(--md-sys-color-on-yellow-container);
background-color: var(--md-sys-color-yellow-container);
}
&::before {
content: "tag";
}
}
}
}
}
.description {
margin-block-end: 12px;
}
@@ -138,6 +195,25 @@
}
}
.no-results {
display: flex;
align-items: center;
flex-direction: column;
flex-wrap: nowrap;
gap: 42px;
justify-content: center;
height: calc(100% - 76px);
width: 100%;
user-select: none;
-moz-user-select: none;
.icon {
@include mixin.material-symbols($size: 100);
}
}
&.searching {
top: 0px;
@@ -146,16 +222,16 @@
padding: 12px;
.action-area {
.leading-button {
opacity: 0;
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
}
.search-input {
margin-inline-start: 0px;
margin-inline-end: 0px;
transition: margin var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
.author-avatar {
opacity: 0;
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
}
}
}
@@ -166,6 +242,10 @@
background-color: var(--md-sys-color-surface-container-highest);
}
}
&.hidden {
top: -64px;
}
}
@media screen and (max-width: 840px) {
@@ -177,14 +257,12 @@
opacity: 1;
visibility: visible;
// .action-area {
// .search-input {
// margin-inline-start: 56px;
// }
// }
.result-area {
height: calc(100% - (80px + 64px));
gap: 8px;
}
.no-results {
height: calc(100% - (64px + 64px));
}
}
}

View File

@@ -1,24 +1,167 @@
@use "../mixin";
.ArticleMasonry {
display: flex;
align-items: flex-start;
flex-direction: row;
flex-direction: column;
gap: 12px;
width: 100%;
.masonry-column {
.toolbar {
display: flex;
flex-direction: column;
flex: 1;
align-items: center;
gap: 12px;
justify-content: flex-end;
min-width: 0px;
position: relative;
.MaterialCard {
width: 100%;
.filter {
position: relative;
.panel {
display: flex;
flex-direction: column;
gap: 24px;
position: absolute;
left: -12px;
top: -12px;
padding: 24px;
max-width: 520px;
min-width: 340px;
border-radius: var(--md-sys-shape-corner-extra-large);
background-color: var(--md-sys-color-surface-container-low);
box-shadow: 0px 1px 6px -3px var(--md-sys-color-shadow);
overflow-y: overlay;
transform-origin: 0px 0px;
z-index: 100;
.section {
h6 {
display: inline-flex;
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
gap: 4px;
margin-block-end: 12px;
span {
@include mixin.material-symbols($size: 16);
cursor: pointer;
}
}
.page-size-options {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 2px;
justify-content: center;
}
.chip-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
}
}
}
}
}
@media screen and (max-width: 840px) {
.masonry-wrapper {
display: flex;
gap: 12px;
width: 100%;
.empty-state {
display: flex;
align-items: center;
flex-direction: column;
flex-wrap: nowrap;
gap: 42px;
justify-content: center;
width: 100%;
user-select: none;
-moz-user-select: none;
.icon {
@include mixin.material-symbols($size: 100);
}
}
.masonry-column {
display: flex;
flex-direction: column;
flex: 1;
gap: 12px;
min-width: 0px;
.MaterialCard {
width: 100%;
}
}
}
.page-navigator {
display: flex;
align-items: center;
gap: 16px;
justify-content: center;
width: 100%;
.page-info-wrapper {
p {
cursor: pointer;
}
input {
@include mixin.typescale-style("body-large");
padding: 6px;
width: 48px;
color: var(--md-sys-color-on-surface);
text-align: center;
border-radius: var(--md-sys-shape-corner-large);
border: none;
background-color: transparent;
}
}
}
.expand-enter-active,
.expand-leave-active {
transition:
transform var(--md-sys-motion-spring-default-spatial-duration) var(--md-sys-motion-spring-default-spatial),
opacity var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
pointer-events: none;
transform: scale(0.8);
}
.expand-enter-to,
.expand-leave-from {
transform: scale(1);
}
}

View File

@@ -3,7 +3,7 @@
.MaterialButton {
display: inline-flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
position: relative;
@@ -18,7 +18,9 @@
cursor: pointer;
overflow: hidden;
transition: border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
transition:
border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
background-color var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
user-select: none;
-moz-user-select: none;
@@ -55,14 +57,14 @@
}
&.horizontal {
&:first-child {
&:first-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-small)
var(--md-sys-shape-corner-large);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-large)
var(--md-sys-shape-corner-small);
@@ -73,14 +75,14 @@
&.vertical {
width: 100%;
&:first-child {
&:first-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-small)
var(--md-sys-shape-corner-small);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-large)
var(--md-sys-shape-corner-large);
@@ -127,14 +129,14 @@
}
&.horizontal {
&:first-child {
&:first-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-extra-large) var(--md-sys-shape-corner-medium)
var(--md-sys-shape-corner-medium) var(--md-sys-shape-corner-extra-large);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-medium) var(--md-sys-shape-corner-extra-large)
var(--md-sys-shape-corner-extra-large) var(--md-sys-shape-corner-medium);
@@ -145,14 +147,14 @@
&.vertical {
width: 100%;
&:first-child {
&:first-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-large)
var(--md-sys-shape-corner-medium) var(--md-sys-shape-corner-medium);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-medium) var(--md-sys-shape-corner-medium)
var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-large);
@@ -160,6 +162,7 @@
}
}
&.selected,
&:active {
border-radius: var(--md-sys-shape-corner-extra-large) !important;
}
@@ -199,14 +202,14 @@
}
&.horizontal {
&:first-child {
&:first-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-extra-large) var(--md-sys-shape-corner-large)
var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-extra-large);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-extra-large)
var(--md-sys-shape-corner-extra-large) var(--md-sys-shape-corner-large);
@@ -217,14 +220,14 @@
&.vertical {
width: 100%;
&:first-child {
&:first-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-extra-large) var(--md-sys-shape-corner-extra-large)
var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-small);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-small)
var(--md-sys-shape-corner-extra-large) var(--md-sys-shape-corner-extra-large);
@@ -271,14 +274,14 @@
}
&.horizontal {
&:first-child {
&:first-of-type {
&.round {
border-radius: calc(var(--md-sys-shape-corner-full) / 2) var(--md-sys-shape-corner-large)
var(--md-sys-shape-corner-large) calc(var(--md-sys-shape-corner-full) / 2);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-large) calc(var(--md-sys-shape-corner-full) / 2)
calc(var(--md-sys-shape-corner-full) / 2) var(--md-sys-shape-corner-large);
@@ -289,14 +292,14 @@
&.vertical {
width: 100%;
&:first-child {
&:first-of-type {
&.round {
border-radius: calc(var(--md-sys-shape-corner-full) / 2) calc(var(--md-sys-shape-corner-full) / 2)
var(--md-sys-shape-corner-medium) var(--md-sys-shape-corner-medium);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-medium) var(--md-sys-shape-corner-medium)
calc(var(--md-sys-shape-corner-full) / 2) calc(var(--md-sys-shape-corner-full) / 2);
@@ -343,14 +346,14 @@
}
&.horizontal {
&:first-child {
&:first-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-full) var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-large)
var(--md-sys-shape-corner-full);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-full) var(--md-sys-shape-corner-full)
var(--md-sys-shape-corner-large);
@@ -361,14 +364,14 @@
&.vertical {
width: 100%;
&:first-child {
&:first-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-full) var(--md-sys-shape-corner-full) var(--md-sys-shape-corner-medium)
var(--md-sys-shape-corner-medium);
}
}
&:last-child {
&:last-of-type {
&.round {
border-radius: var(--md-sys-shape-corner-medium) var(--md-sys-shape-corner-medium)
var(--md-sys-shape-corner-full) var(--md-sys-shape-corner-full);

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

@@ -0,0 +1,77 @@
@use "../mixin";
.MaterialChip {
@include mixin.typescale-style("label-large");
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
position: relative;
height: 32px;
padding-inline: 12px;
text-decoration: none !important;
vertical-align: middle;
border-color: transparent;
border-radius: var(--md-sys-shape-corner-small);
border-style: solid;
border-width: 1px;
background-color: transparent;
cursor: pointer;
overflow: hidden;
transition:
border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
background-color var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
user-select: none;
-moz-user-select: none;
span {
@include mixin.material-symbols($size: 20);
}
&:active {
border-radius: var(--md-sys-shape-corner-medium);
}
&.icon {
padding-inline: 8px 12px;
}
&.elevated {
color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-surface-container-low);
box-shadow: 0px 1px 6px -3px var(--md-sys-color-shadow);
}
&.filled {
color: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
}
&.tonal {
color: var(--md-sys-color-on-secondary-container);
background-color: var(--md-sys-color-secondary-container);
}
&.outlined {
color: var(--md-sys-color-on-surface-variant);
border-color: var(--md-sys-color-outline-variant);
}
&.standard,
&.text {
color: var(--md-sys-color-primary);
}
}

View File

@@ -38,7 +38,6 @@
height: 50px;
width: 50px;
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
mask: var(--via-svg-mask) no-repeat 0 / 100%;
}

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

@@ -6,15 +6,31 @@
user-select: none;
-moz-user-select: none;
p {
@include mixin.typescale-style("label-small");
.label {
display: flex;
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
gap: 4px;
margin-inline-start: 18px;
z-index: 1;
p {
@include mixin.typescale-style("label-small");
&.icon {
@include mixin.material-symbols($size: 16);
}
&.article-id {
text-transform: uppercase;
cursor: pointer;
}
}
}
h3 {
.article-title {
margin-inline-start: 18px;
padding-block-end: 18px;
@@ -25,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;
}
@@ -77,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;
}
}
@@ -92,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);
@@ -121,6 +146,10 @@
@media screen and (max-width: 840px) {
.PageIndicator {
display: none;
.article-title,
.indicator,
.indicator-container {
display: none;
}
}
}

View File

@@ -1,6 +1,6 @@
@use "../mixin";
main#article-content {
#article-content {
display: flex;
flex-direction: column;
grid-column: 1 / 10;
@@ -784,7 +784,6 @@ main#article-content {
background-color: var(--md-sys-color-on-surface);
-webkit-mask: var(--via-svg-list-bullet) 0 0/100% no-repeat;
mask: var(--via-svg-list-bullet) 0 0/100% no-repeat;
transform: rotate(var(--random-rotation, 0deg));
@@ -793,7 +792,7 @@ main#article-content {
}
}
div#article-aside {
#article-aside {
display: flex;
flex-direction: column;
gap: 24px;
@@ -831,26 +830,22 @@ div#article-aside {
&.date-update::before {
content: "update";
}
&.id::before {
content: "flag";
}
}
}
}
@media screen and (max-width: 1600px) {
main#article-content {
#article-content {
grid-column: 1 / 10;
}
div#article-beside {
#article-aside {
grid-column: 10 / 13;
}
}
@media screen and (max-width: 1200px) {
main#article-content {
#article-content {
grid-column: 1 / 7;
.headline-block a.title-anchor {
@@ -861,13 +856,13 @@ div#article-aside {
}
}
div#article-beside {
#article-aside {
grid-column: 7 / 9;
}
}
@media screen and (max-width: 840px) {
main#article-content {
#article-content {
grid-column: 1 / 7;
.custom-block > * {
@@ -879,13 +874,13 @@ div#article-aside {
}
}
div#article-beside {
#article-aside {
grid-column: 1 / 7;
}
}
@media screen and (max-width: 600px) {
main#article-content {
#article-content {
grid-column: 1 / 5;
p:has(img) {
@@ -893,7 +888,7 @@ div#article-aside {
}
}
div#article-beside {
#article-aside {
grid-column: 1 / 5;
}
}

View File

@@ -33,44 +33,144 @@
.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 24px;
color: var(--md-sys-color-on-primary-container);
text-align: justify;
background-color: var(--md-sys-color-primary-container);
animation: avatar-box-h3 var(--md-sys-motion-spring-slow-spatial-duration) var(--md-sys-motion-spring-slow-spatial)
var(--md-sys-motion-spring-fast-spatial-duration) both;
overflow: hidden;
transition: color var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect),
background-color var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
transform-origin: 0px 0px;
z-index: 2;
@keyframes avatar-box-h3 {
0% {
clip-path: inset(
0% 100% 0% 0% round var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-large)
var(--md-sys-shape-corner-large)
);
opacity: 0;
transform: scale(0.3) translateY(42px) rotateZ(24deg);
}
25% {
opacity: 1;
}
100% {
clip-path: inset(
0% calc(0% + 12px) 0% 0% round var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-large)
var(--md-sys-shape-corner-large)
);
transform: scale(1) translateY(0px) rotateZ(0deg);
}
}
}
img {
position: relative;
bottom: 50%;
top: 50%;
height: 300px;
width: 300px;
mask: var(--via-svg-mask) no-repeat 0 / 100%;
translate: 0px -50%;
user-select: none;
-moz-user-select: none;
z-index: 1;
}
span {
position: absolute;
left: 70px;
top: 170px;
height: 200px;
width: 200px;
animation: avatar-box-span 60s linear infinite;
mask: linear-gradient(45deg, black, transparent), var(--via-svg-mask) 0 / 100% no-repeat;
mask-composite: intersect;
mask-mode: alpha;
opacity: 0.5;
user-select: none;
-moz-user-select: none;
z-index: 0;
@keyframes avatar-box-span {
0% {
background-color: var(--md-sys-color-blue);
transform: rotate(0deg);
}
25% {
background-color: var(--md-sys-color-red);
}
50% {
background-color: var(--md-sys-color-yellow);
}
75% {
background-color: var(--md-sys-color-purple);
}
100% {
background-color: var(--md-sys-color-green);
transform: rotate(360deg);
}
}
}
}
.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 +214,18 @@
.home-content {
grid-column: span 6;
.avatar-box {
zoom: 0.7;
h3 {
max-width: calc(840px / 2 - 12px);
}
}
.title {
zoom: 0.8;
}
}
}
}
@@ -127,6 +239,18 @@
.home-content {
grid-column: span 4;
.avatar-box {
zoom: 0.6;
h3 {
max-width: 80vw;
}
}
.title {
zoom: 0.7;
}
}
}
}

View File

@@ -145,7 +145,6 @@ hr {
background: var(--md-sys-color-outline-variant);
-webkit-mask: var(--via-svg-wave) repeat;
mask: var(--via-svg-wave) repeat;
opacity: 0.3;
}
@@ -156,7 +155,7 @@ span {
}
.MaterialButton,
.MaterialCard,
.MaterialCard .content,
.PrevNext a {
&::after {
content: "";

View File

@@ -1,3 +1,6 @@
@use "sass:math";
@use "sass:string";
@mixin typescale-style(
$type-scale,
$font-family: var(--md-sys-typescale-#{$type-scale}-family),
@@ -57,3 +60,90 @@
var(--md-sys-motion-spring-fast-spatial-standard);
z-index: $z-index;
}
@function svg-enc($svg) {
$svg: string-replace($svg, "#", "%23");
$svg: string-replace($svg, "<", "%3C");
$svg: string-replace($svg, ">", "%3E");
$svg: string-replace($svg, '"', "'");
@return "data:image/svg+xml," + $svg;
}
@function string-replace($string, $search, $replace: "") {
$index: string.index($string, $search);
@if $index {
@return string.slice($string, 1, $index - 1) + $replace +
string-replace(string.slice($string, $index + string.length($search)), $search, $replace);
}
@return $string;
}
@mixin g3-mask($r, $k: 0.72) {
// 基础形状路径
$offset: 100 * (1 - $k);
$path: "M0,100 C0,#{$offset} #{$offset},0 100,0 L100,100 Z";
// SVG 模板
$svg-start: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none"><path transform="rotate(';
$svg-end: ' 50 50)" fill="black" d="#{$path}"/></svg>';
// 生成四个角的 Data URI
$tl: url('#{svg-enc($svg-start + "0" + $svg-end)}');
$tr: url('#{svg-enc($svg-start + "90" + $svg-end)}');
$br: url('#{svg-enc($svg-start + "180" + $svg-end)}');
$bl: url('#{svg-enc($svg-start + "270" + $svg-end)}');
// 布局计算
$fill: linear-gradient(#000, #000);
$h-fill: calc(100% - 2 * #{$r} + 1px);
$v-fill: calc(100% - 2 * #{$r} + 1px);
mask-image: $tl, $tr, $br, $bl, $fill, $fill;
mask-position: top left, top right, bottom right, bottom left, center center, center center;
mask-repeat: no-repeat;
mask-size: #{$r} #{$r}, #{$r} #{$r}, #{$r} #{$r}, #{$r} #{$r}, 100% $h-fill, $v-fill 100%;
}
// $radius: 圆角大小 (必填)
// $border-width: 边框宽度 (可选,默认 0)
// $border-color: 边框颜色 (可选)
// $bg-color: 背景颜色 (如果有边框,建议填此项以遮挡边框内部,默认白色)
// $k: 平滑度 (可选,默认 0.72)
@mixin g3-box($radius, $border-width: 0, $border-color: transparent, $bg-color: #fff, $k: 0.72) {
@if $border-width == 0 {
@include g3-mask($radius, $k);
} @else {
position: relative;
background-color: transparent;
z-index: 0;
// 边框层
&::before {
@include g3-mask($radius, $k);
content: "";
position: absolute;
background-color: $border-color;
inset: 0;
z-index: -2;
}
// 背景层
&::after {
$inner-r: calc(#{$radius} - #{$border-width});
@include g3-mask($inner-r, $k);
content: "";
position: absolute;
background-color: $bg-color;
inset: $border-width;
z-index: -1;
}
}
}

View File

@@ -0,0 +1,109 @@
/**
* 用于在主页显示随机问候语的短语库
*/
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,6 +1,7 @@
{
"version": "26.1.8(2)",
"version": "26.1.18(262)",
"scripts": {
"update-version": "bash ./scripts/update-version.sh",
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:build-cf": "git fetch --unshallow && vitepress build",

View File

@@ -10,6 +10,8 @@ categories:
tags:
- readme
- osu!
- 示例文章
- 作品介绍
date: 2022-07-04T04:00:00Z
external_links:
- type: download

View File

@@ -5,8 +5,10 @@ color: ""
impression: ""
categories:
tags:
date: time
- markdown 语法
- 示例文章
- 组件示例
date: 2007-08-31T00:00:00Z
---
# 语法高亮

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

28
scripts/update-version.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# 获取当前提交数
COMMIT_COUNT=$(git rev-list --count HEAD)
# 计算最终提交数
NEXT_COMMIT_COUNT=$((COMMIT_COUNT + 1))
# 获取当前日期 YY.M.D
YEAR=$(date +%y)
MONTH=$(date +%-m)
DAY=$(date +%-d)
NEW_VERSION="${YEAR}.${MONTH}.${DAY}(${NEXT_COMMIT_COUNT})"
# 使用 sed 更新 package.json 中的版本号
# 匹配 "version": "..." 模式
sed -i "s/\"version\": \".*\"/\"version\": \"${NEW_VERSION}\"/" package.json
echo "Version updated to: ${NEW_VERSION}"
# Git 操作
git add package.json
git commit -m "chore(package): update to version ${NEW_VERSION}"
echo "Committed: chore(package): update to version ${NEW_VERSION}"
git tag "${NEW_VERSION}"
echo "Tagged: ${NEW_VERSION}"