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

66 Commits

Author SHA1 Message Date
1bb864cf24 chore(package): update to version 26.2.25(298) 2026-02-25 23:24:57 +08:00
68448d29fe feat(ImageViewer): enhance image viewer with button group navigation 2026-02-25 23:24:46 +08:00
cad4130789 feat(ButtonGroup): enhance button group component with new props and event handling 2026-02-25 23:24:07 +08:00
b4df562522 style(tokens): update color scheme for light mode 2026-02-25 23:22:26 +08:00
0a4f340e88 chore(NavBar): remove theme state initialization on mount 2026-02-25 23:17:22 +08:00
6a0cc5f5cb style(Article): optimize image display 2026-02-25 23:17:02 +08:00
008160b3c9 article(Jekylmt): add new article 2026-02-25 23:15:53 +08:00
416426d769 refactor(theme): simplify theme management using useColorMode 2026-02-25 23:12:08 +08:00
e07acc8e35 chore(config): update markdown-it plugins and add markdown-it imgMark 2026-02-25 23:11:41 +08:00
b1f8d6f15b chore(package): update to version 26.2.22(289) 2026-02-22 21:09:12 +08:00
c7af2eeb0f feat(Dockerfile): add initial Dockerfile 2026-02-22 21:08:50 +08:00
1fbac018f9 article: update color scheme and replace impression image 2026-02-21 21:31:39 +08:00
cbd3979476 fix(images): replace PNG images with WEBP format 2026-02-09 23:44:31 +08:00
5bcec415b3 fix(dependencies): downgrade @material/material-color-utilities to version 0.3.0 and update vue to version 3.5.28 2026-02-09 23:35:49 +08:00
1ada9579ce style(layout): improve transition effects 2026-02-09 23:30:43 +08:00
1215d1ca22 style(Header): enhance header transition effects 2026-02-09 23:29:33 +08:00
56264d3504 chore(package): update dependencies to latest versions 2026-02-09 16:20:19 +08:00
251b22f1b5 chore(package): update to version 26.2.8(281) 2026-02-08 23:43:53 +08:00
2c5cbfad01 style(update-version): improve output formatting for version update process 2026-02-08 23:43:43 +08:00
42fedf3c3d article(Markdown 扩展示例): add color and impression 2026-02-08 23:40:16 +08:00
ecd4fb2886 style(Header): enhance title with gradient background and overlay effect 2026-02-08 23:39:39 +08:00
fff5f3ba2b fix(Header): hide SVG element 2026-02-08 21:25:26 +08:00
5faa7e4220 article(设置开机自启动的 Jekyll 服务): add color and impression 2026-02-08 21:18:32 +08:00
568c7a7427 style: update text style for improved visibility 2026-02-08 21:16:34 +08:00
bef3d5ce77 style: update link decoration 2026-02-08 21:15:42 +08:00
00b034f02b fix(PageIndicator): correct syntax and update text for short link display 2026-02-08 21:14:09 +08:00
f27da216e0 chore(package): update to version 26.2.6(272) 2026-02-06 16:17:35 +08:00
051aadc565 feat(update-version): enhance version update script with remote configuration and improved logging 2026-02-06 16:16:59 +08:00
26ad7fed76 feat(Header): add gradient image handling and improve layout structure 2026-02-06 16:11:41 +08:00
73a1b4044d feat(images): add new gradient images and remove obsolete image 2026-02-06 15:13:35 +08:00
1a6a60d5cc chore(package): update to version 26.1.30(268) 2026-01-30 22:05:21 +08:00
8cdbd059bb fix(navState): update cookie name 2026-01-30 22:04:07 +08:00
9ec1a05160 feat(NavBar): add theme toggle button 2026-01-30 22:03:03 +08:00
07ad900625 chore(package): update @mdit/plugin-align to version 0.23.0 2026-01-21 21:19:51 +08:00
3a986b01eb style(ArticleMasonry): improve panel layout and responsiveness, enhance transition effects 2026-01-21 14:54:06 +08:00
f9203e5815 project: add MIT License 2026-01-19 14:38:50 +08:00
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
75 changed files with 3349 additions and 1848 deletions

View File

@@ -1,15 +1,17 @@
import { defineConfig } from "vitepress";
import packageJson from "../package.json";
// markdown-it plugins
// https://mdit-plugins.github.io/zh/align.html
import { align } from "@mdit/plugin-align";
// https://github.com/valeriangalliat/markdown-it-anchor
import anchor from "markdown-it-anchor";
// markdown-it plugins
// https://mdit-plugins.github.io/align.html
import { align } from "@mdit/plugin-align";
// https://mdit-plugins.github.io/footnote.html
import { footnote } from "@mdit/plugin-footnote";
// https://mdit-plugins.github.io/tasklist.html
import { tasklist } from "@mdit/plugin-tasklist";
// https://mdit-plugins.github.io/img-mark.html
import { imgMark } from "@mdit/plugin-img-mark";
import { wrapHeadingsAsSections } from "./theme/utils/sectionWrapper";
export default defineConfig({
@@ -18,7 +20,7 @@ export default defineConfig({
lang: "zh_CN",
title: "sendevia 的小站",
titleTemplate: ":title",
description: "一个随便写写的博客",
description: "随便写写的博客",
markdown: {
anchor: {
permalink: anchor.permalink.linkAfterHeader({
@@ -42,6 +44,7 @@ export default defineConfig({
md.use(footnote);
md.use(wrapHeadingsAsSections);
md.use(tasklist, { label: true });
md.use(imgMark);
},
image: {
lazyLoading: true,
@@ -94,6 +97,7 @@ export default defineConfig({
navSegment: [
{ text: "首页", icon: "home", link: "/" },
{ text: "AincradMix", icon: "borg", link: "/posts/AincradMix" },
{ text: "组件", icon: "code_blocks", link: "/posts/组件" },
{
text: "作品集",
icon: "auto_awesome_mosaic",

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,407 @@
<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" class="panel">
<div ref="settingsPanelRef" class="container">
<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>
</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

@@ -1,33 +1,62 @@
<script setup lang="ts">
/** 外部链接类型定义 */
interface ExternalLink {
type: string;
label: string;
link: string;
ariaLabel?: string;
color?: string;
icon?: string;
label?: string;
link?: string;
size?: "xs" | "s" | "m" | "l" | "xl";
target?: string;
type?: string;
onClick?: (e?: Event) => void;
}
/** 组件属性定义 */
interface Props {
ariaLabel?: string;
color?: string;
icon?: string;
layout?: "horizontal" | "vertical";
links?: ExternalLink[];
size?: "xs" | "s" | "m" | "l" | "xl";
layout?: "horizontal" | "vertical";
target?: string;
}
/** 组件属性默认值 */
const props = withDefaults(defineProps<Props>(), {
layout: "horizontal",
links: () => [],
size: "s",
layout: "horizontal",
});
const getButtonColor = (type: string) => {
/** 组件事件定义 */
const emit = defineEmits<{
(e: "click", event: Event, item: ExternalLink, index: number): void;
}>();
/**
* 根据按钮类型获取对应的颜色
* @param type 按钮类型
* @returns 对应的颜色
*/
const getButtonColor = (type?: string): string => {
switch (type) {
case "download":
return "tonal";
return "filled";
case "normal":
default:
return "tonal";
default:
return "text";
}
};
const getButtonIcon = (type: string) => {
/**
* 根据按钮类型获取对应的图标
* @param type 按钮类型
* @returns 对应的图标名称
*/
const getButtonIcon = (type?: string): string => {
switch (type) {
case "download":
return "download";
@@ -37,22 +66,37 @@ const getButtonIcon = (type: string) => {
return "";
}
};
/**
* 处理按钮点击事件
* @param e 事件对象
* @param item 链接对象
* @param index 索引
*/
const handleClick = (e: Event, item: ExternalLink, index: number) => {
if (item.onClick) {
item.onClick(e);
}
emit("click", e, item, index);
};
</script>
<template>
<div v-if="links && links.length > 0" class="ButtonGroup" :class="[props.size, props.layout]">
<div class="ButtonGroup" :class="[props.size, props.layout]" :aria-label="props.ariaLabel">
<MaterialButton
v-for="(item, index) in links"
:key="index"
class="group"
:class="props.layout"
:key="index"
:href="item.link"
:size="props.size"
:color="getButtonColor(item.type)"
:icon="getButtonIcon(item.type)"
:target="'_blank'"
:size="item.size || props.size"
:color="item.color || props.color || getButtonColor(item.type)"
:icon="item.icon || props.icon || getButtonIcon(item.type)"
:target="item.target || props.target || (item.link ? '_blank' : undefined)"
:aria-label="item.ariaLabel || props.ariaLabel || item.label"
@click="handleClick($event, item, index)"
>
{{ item.label }}
<template v-if="item.label">{{ item.label }}</template>
</MaterialButton>
</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

@@ -28,7 +28,7 @@ const siteVersion = theme.value.siteVersion;
</div>
<div class="beian-info">
<div class="beian-gongan">
<img src="/assets/images/beian.png" loading="eager" />
<img src="/assets/images/beian.webp" loading="eager" />
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=23020002230215" target="_blank"
>黑公网安备23020002230215</a
>

View File

@@ -1,143 +1,153 @@
<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,
animFast: 300,
});
const { frontmatter, theme } = useGlobalData();
const { frontmatter, theme, page } = 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;
});
/**
* 获取带 _mesh-gradient 后缀的图片地址
* @param url 原始图片地址
*/
const getGradientUrl = (url: string): string => {
if (!url) return "";
// 在扩展名前插入 _mesh-gradient
return url.replace(/(\.[^.]+)$/, "_mesh-gradient$1");
};
/**
* 解析 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;
// 生成所有需要缓存的 URL原图 + 梯度图)
const allUrls = urls.flatMap((url) => [url, getGradientUrl(url)]);
const uncached = allUrls.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); // 失败时回退到原始 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];
const rawGradientUrl = getGradientUrl(rawUrl);
return {
id: slotId,
className: cls,
imgUrl: blobCache.get(rawUrl) || rawUrl,
gradientUrl: blobCache.get(rawGradientUrl) || rawGradientUrl,
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;
@@ -146,42 +156,35 @@ const { pause, resume } = useRafFn(
step(1).then(() => (remainingTime.value = config.duration));
}
},
{ immediate: false }
{ 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,90 +193,111 @@ 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 }
{ immediate: true },
);
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)">
<div class="carousel-container" :impression-color="frontmatter.color">
<template v-if="hasMultiple">
<div class="stage" :style="{ '--anim-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,
}"
></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" />
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="var(--md-sys-color-tertiary)"
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',
}"
/>
</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>
</template>
<template v-else>
<div class="single" :style="{ backgroundImage: `url('${blobCache.get(rawImgList[0]) || rawImgList[0]}')` }"></div>
</template>
</div>
</header>
<Transition name="header" mode="in-out" :duration="10000" appear>
<header ref="headerRef" class="Header">
<div class="carousel-container" :impression-color="frontmatter.color">
<template v-if="hasMultiple">
<div class="stage" :style="{ '--carousel-duration': `${animDuration}ms` }">
<div
v-for="slot in slotStates"
:key="slot.id"
class="item"
:class="slot.className"
:style="{ order: slot.order }"
>
<img :src="slot.imgUrl" />
</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" />
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="var(--md-sys-color-tertiary)"
stroke-width="5"
stroke-linecap="round"
:style="{
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 }"
@click="jumpTo(idx)"
></button>
</div>
</template>
<template v-else>
<ClientOnly>
<svg width="0" height="0" style="display: none">
<defs>
<filter id="noise-filter" x="0" y="0" width="100%" height="100%">
<feTurbulence
:seed="frontmatter.date ? new Date(frontmatter.date).getTime() : 0"
type="turbulence"
baseFrequency="0.15"
numOctaves="2"
stitchTiles="stitch"
></feTurbulence>
<feColorMatrix type="saturate" values="1"></feColorMatrix>
<feComponentTransfer>
<feFuncA type="discrete" tableValues="0 0.1"></feFuncA>
</feComponentTransfer>
<feBlend mode="multiply" in2="SourceGraphic"></feBlend>
</filter>
</defs>
</svg>
</ClientOnly>
<div class="single">
<h1 class="overlay">{{ frontmatter.title || page.title }}</h1>
<h1 :style="`background-image: url(${getGradientUrl(rawImgList[0])})`">
{{ frontmatter.title || page.title }}
</h1>
<img :src="getGradientUrl(rawImgList[0])" />
<img :src="rawImgList[0]" />
</div>
</template>
</div>
</header>
</Transition>
</template>
<style lang="scss" scoped>

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,238 @@ 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>(".ImageViewer .close");
closeBtn?.focus();
});
}
};
function navigateTo(index: number) {
if (index >= 0 && index < props.images.length) {
emit("update:currentIndex", index);
/** 隐藏查看器,并还原焦点 */
const hide = () => {
calculateInitialTransform();
isAnimating.value = false;
// 等待动画结束后卸载组件并归还焦点
setTimeout(() => {
isVisible.value = false;
// 焦点还原逻辑
if (lastActiveElement.value && document.body.contains(lastActiveElement.value)) {
lastActiveElement.value.focus();
}
emit("close");
}, 300);
};
/** 导航动作 */
const prevImage = () => hasPrevious.value && activeIndex.value--;
const nextImage = () => hasNext.value && activeIndex.value++;
/** 导航 ButtonGroup 按钮配置 */
const BUTTONS_NAV_CONFIG = computed(() => [
{ id: "prev", icon: "chevron_left", ariaLabel: "上一张", type: "normal" },
{
id: "index",
label: `${activeIndex.value + 1} / ${props.images.length}`,
color: "tonal",
ariaLabel: "当前页码",
},
{ id: "next", icon: "chevron_right", ariaLabel: "下一张", type: "normal" },
]);
/** 处理按钮组点击事件 */
const handleButtonGroupClick = (e: Event, item: any) => {
switch (item.id) {
case "prev":
prevImage();
break;
case "next":
nextImage();
break;
}
}
};
function previousImage() {
if (hasPrevious.value) {
navigateTo(props.currentIndex - 1);
}
}
function nextImage() {
if (hasNext.value) {
navigateTo(props.currentIndex + 1);
}
}
function handleKeydown(event: KeyboardEvent) {
/** 处理键盘快捷键 */
const handleKeyboardShortcuts = (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();
}
}
/** 键盘交互处理 */
useEventListener("keydown", handleKeyboardShortcuts);
// 更新窗口大小
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,76 +259,46 @@ 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
v-if="hasPrevious"
@click="previousImage"
aria-label="上一张图片"
class="btn-nav prev"
icon="chevron_left"
size="l"
/>
<MaterialButton
v-if="hasNext"
@click="nextImage"
aria-label="下一张图片"
class="btn-nav next"
icon="chevron_right"
size="l"
<ButtonGroup
:links="BUTTONS_NAV_CONFIG"
layout="horizontal"
size="m"
class="nav-group"
@click="handleButtonGroupClick"
/>
<MaterialButton color="tonal" icon="close" aria-label="关闭" class="close" @click="hide"></MaterialButton>
<!-- 图片主体 -->
<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>
<div class="thumbnails" v-if="images.length > 1">
<button
v-for="(image, index) in images"
:key="index"
class="thumbnail"
:class="{ 'thumbnail active': index === currentIndex }"
@click="navigateTo(index)"
:aria-label="`查看图片 ${index + 1}`"
>
<img :src="image" :alt="`缩略图 ${index + 1}`" />
</button>
</div>
</div>
</template>

View File

@@ -6,14 +6,19 @@ import { useGlobalData } from "../composables/useGlobalData";
import { useNavStateStore } from "../stores/navState";
import { useScreenWidthStore } from "../stores/screenWidth";
import { useSearchStateStore } from "../stores/searchState";
import { useThemeStateStore } from "../stores/themeState";
const { page, theme } = useGlobalData();
const screenWidthStore = useScreenWidthStore();
const searchStateStore = useSearchStateStore();
const navStateStore = useNavStateStore();
const themeStateStore = useThemeStateStore();
/** 标签动画状态 */
const isLabelAnimating = ref(false);
/** 清理函数列表 */
const cleanupFunctions: Array<() => void> = [];
/** 已观察元素集合 */
const observedElements = new WeakSet<HTMLElement>();
/**
@@ -32,17 +37,18 @@ function observeWidth(el: HTMLElement, parentSelector: string) {
parentSelector,
ignoreParentLimit: true, // 允许撑开父级
},
[el]
[el],
);
cleanupFunctions.push(cleanup);
}
// 计算 Segments
/** 计算导航分段数据 */
const navSegment = computed(() => {
const items = theme.value.navSegment;
return Array.isArray(items) && items.length > 0 ? items : [];
});
/** 计算导航栏容器类名 */
const navClass = computed(() => {
let baseClass = "";
if (screenWidthStore.screenWidth > 840) {
@@ -56,14 +62,15 @@ const navClass = computed(() => {
return `${baseClass} ${expansionClass}`;
});
/** 计算标签类名 */
const labelClass = computed(() => [
navStateStore.isNavExpanded ? "right" : "bottom",
isLabelAnimating.value ? "animating" : "",
]);
/**
* 规范化路径,去除 /index.md、.md、.html 后缀及末尾斜杠
* @param path
* 规范化路径,去除后缀及末尾斜杠
* @param path 原始路径
*/
function normalizePath(path: string): string {
return path.replace(/(\/index)?\.(md|html)$/, "").replace(/\/$/, "");
@@ -71,7 +78,7 @@ function normalizePath(path: string): string {
/**
* 检查链接是否为当前活动页面的链接
* @param link
* @param link 目标链接
*/
function isActive(link: string): boolean {
const currentPath = normalizePath(page.value.relativePath);
@@ -81,7 +88,7 @@ function isActive(link: string): boolean {
/**
* 检查链接是否为外部链接
* @param link
* @param link 目标链接
*/
function isExternalLink(link: string): boolean {
return /^https?:\/\//.test(link);
@@ -89,7 +96,7 @@ function isExternalLink(link: string): boolean {
/**
* 切换搜索栏状态
* @param event
* @param event 鼠标事件
*/
function toggleSearch(event: MouseEvent) {
event.stopPropagation();
@@ -97,21 +104,29 @@ function toggleSearch(event: MouseEvent) {
}
/**
* 切换导航栏状态
* @param event
* 切换导航栏展开状态
* @param event 鼠标事件
*/
function toggleNav(event: MouseEvent) {
event.stopPropagation();
// 暂时只有在屏幕宽度大于断点时才能切换导航栏状态
if (screenWidthStore.isAboveBreakpoint) {
navStateStore.toggle();
}
}
/**
* 切换颜色偏好 (自动/亮/暗)
* @param event 鼠标事件
*/
function toggleTheme(event: MouseEvent) {
event.stopPropagation();
themeStateStore.cycleTheme();
}
/**
* 设置 label 引用并初始化观察器
* @param el
* @param parentSelector
* @param el DOM 元素
* @param parentSelector 父级选择器
*/
function setLabelRef(el: any, parentSelector: string) {
if (el instanceof HTMLElement) {
@@ -120,8 +135,8 @@ function setLabelRef(el: any, parentSelector: string) {
}
/**
* 处理动画结束
* @param el
* 处理动画结束事件
* @param el 事件目标
*/
function onAnimationEnd(el: EventTarget | null) {
if (el) {
@@ -129,7 +144,7 @@ function onAnimationEnd(el: EventTarget | null) {
}
}
// 监听状态变化,手动触发宽度计算
/** 监听导航栏展开状态,触发重绘和动画 */
watch(
() => [navStateStore.isNavExpanded],
() => {
@@ -137,7 +152,7 @@ watch(
nextTick(() => {
window.dispatchEvent(new Event("resize"));
});
}
},
);
if (isClient()) {
@@ -185,6 +200,18 @@ if (isClient()) {
</a>
</div>
</div>
<div class="actions">
<MaterialButton
class="theme-btn"
size="m"
color="text"
:title="themeStateStore.currentLabel"
:icon="themeStateStore.currentIcon"
@click="toggleTheme"
>
</MaterialButton>
</div>
</nav>
</template>

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,202 +1,154 @@
<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(() =>
publishTime.value ? useDateFormat(publishTime.value, "YYYY年M月D日").value : ""
publishTime.value ? useDateFormat(publishTime.value, "YYYY年M月D日").value : "",
);
const lastUpdatedRawTime = computed(() =>
lastUpdatedTime.value ? useDateFormat(lastUpdatedTime.value, "YYYY-MM-DD HH:mm:ss").value : ""
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 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,
},
);
// 定义时间单位阈值 (从大到小)
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);
}
}
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,42 +156,30 @@ if (isClient()) {
<template>
<Header />
<main id="article-content">
<hgroup>
<h1>{{ frontmatter.title || page.title }}</h1>
<div>
<hr />
<h6 v-if="frontmatter.description">
{{ frontmatter.description }}
</h6>
</div>
</hgroup>
<main id="article-content" ref="articleContentRef" @click="handleAnchorClick">
<Content />
<PrevNext />
</main>
<div id="article-aside">
<aside id="article-aside">
<div class="post-info">
<p class="date-publish" v-if="formattedPublishDate">发布于 {{ formattedPublishDate }}</p>
<p v-if="frontmatter.description" class="description">{{ frontmatter.description }}</p>
<p v-if="formattedPublishDate" class="date-publish">发布于 {{ formattedPublishDate }}</p>
<ClientOnly>
<p class="date-update" :title="lastUpdatedRawTime" v-if="formattedLastUpdated">
<p v-if="formattedLastUpdated" :title="lastUpdatedRawTime" class="date-update">
{{ 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.webp" 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 cookieName = "navbar_expanded";
const screenWidthStore = useScreenWidthStore();
/**
* 初始从 Cookie 读取状态
*/
/** 初始从 Cookie 读取状态 */
function init() {
if (!isClient()) return;
screenWidthStore.update();
@@ -36,7 +36,7 @@ export const useNavStateStore = defineStore("navState", () => {
isNavExpanded.value = true;
stop();
}
}
},
);
}
} else {
@@ -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();
@@ -99,7 +91,7 @@ export const useNavStateStore = defineStore("navState", () => {
if (!isAbove) {
isNavExpanded.value = false;
}
}
},
);
return {

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

@@ -0,0 +1,63 @@
/**
* 颜色主题偏好管理
*/
import { defineStore } from "pinia";
import { computed } from "vue";
import { useColorMode, useCycleList } from "@vueuse/core";
import { setCookie, getCookie, deleteCookie } from "../utils/cookie";
export type ThemePreference = "auto" | "light" | "dark";
/** 偏好映射配置 */
const THEME_MAP = {
auto: { icon: "brightness_auto", label: "跟随系统" },
light: { icon: "light_mode", label: "亮色模式" },
dark: { icon: "dark_mode", label: "深色模式" },
} as const;
export const useThemeStateStore = defineStore("themeState", () => {
const mode = useColorMode({
emitAuto: true,
storageKey: "theme_preference",
storage: {
getItem: (key) => getCookie(key) || "auto",
setItem: (key, value) => setCookie(key, value),
removeItem: (key) => deleteCookie(key),
},
onChanged: (val, defaultHandler) => {
defaultHandler(val);
// 确保同时切换 light/dark 类
if (typeof document !== "undefined") {
document.documentElement.classList.toggle("dark", val === "dark");
document.documentElement.classList.toggle("light", val === "light");
}
},
});
/** 循环切换列表 */
const { next } = useCycleList(["auto", "light", "dark"] as ThemePreference[], {
initialValue: mode.value as ThemePreference,
});
/** 切换主题模式 */
const cycleTheme = () => {
mode.value = next();
};
/** 当前偏好设置 */
const preference = computed(() => mode.value as ThemePreference);
/** 当前状态对应的图标 */
const currentIcon = computed(() => THEME_MAP[preference.value].icon);
/** 当前状态对应的文本标签 */
const currentLabel = computed(() => THEME_MAP[preference.value].label);
return {
preference,
currentIcon,
currentLabel,
cycleTheme,
};
});

View File

@@ -1,20 +1,3 @@
.layout-enter-active {
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial)
var(--md-sys-motion-duration-short1);
}
.layout-leave-active {
transition: opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
.layout-enter-from,
.layout-leave-to {
opacity: 0;
transform: translateY(10px);
}
@keyframes fade-out {
0% {
opacity: 0;
@@ -24,3 +7,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,211 @@
@use "../mixin";
.ArticleMasonry {
display: flex;
align-items: flex-start;
flex-direction: row;
flex-direction: column;
gap: 12px;
width: 100%;
.masonry-column {
.toolbar {
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
position: relative;
.filter {
position: relative;
.panel {
position: absolute;
left: -12px;
top: -12px;
transform-origin: top left;
z-index: 100;
.container {
display: flex;
flex-direction: column;
gap: 24px;
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: top left;
}
.section {
h6 {
display: inline-flex;
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
gap: 4px;
margin-block-end: 12px;
user-select: none;
-moz-user-select: none;
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: 1600px) {
left: 0px;
top: 0px;
}
@media screen and (max-width: 840px) {
position: fixed;
left: 0px;
top: 0px;
height: 100%;
width: 100%;
backdrop-filter: brightness(0.5);
transform-origin: center center;
z-index: 999;
.container {
position: absolute;
left: 50%;
top: 50%;
max-width: 380px;
min-width: 330px;
transform-origin: center center;
translate: -50% -50%;
}
}
}
}
}
.masonry-wrapper {
display: flex;
flex-direction: column;
flex: 1;
gap: 12px;
min-width: 0px;
width: 100%;
.empty-state {
display: flex;
align-items: center;
flex-direction: column;
flex-wrap: nowrap;
gap: 42px;
justify-content: center;
.MaterialCard {
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: opacity var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
.container {
transition: transform var(--md-sys-motion-spring-default-spatial-duration) var(--md-sys-motion-spring-default-spatial);
}
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
pointer-events: none;
.container {
transform: scale(0.8);
}
}
.expand-enter-to,
.expand-leave-from {
.container {
transform: scale(1);
}
}
}
@media screen and (max-width: 840px) {
}

View File

@@ -3,7 +3,7 @@
.MaterialButton {
display: inline-flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
position: relative;
@@ -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

@@ -16,6 +16,12 @@
z-index: 0;
.single {
display: grid;
align-items: center;
grid-template-columns: 50% 50%;
position: relative;
height: 100%;
width: 100%;
@@ -24,7 +30,116 @@
background-size: cover;
overflow: hidden;
z-index: 1;
h1 {
@include mixin.typescale-style(
"display-large",
$font-size: 72rem,
$line-height: 78rem,
$font-variation-settings: "wght" 900
);
grid-column: 1 / 2;
grid-row: 1;
position: relative;
margin-inline-start: 48px;
color: transparent;
background-clip: text;
background-position: 0% 0%;
background-repeat: no-repeat;
background-size: 100%;
animation: title-gradient 5s var(--md-sys-motion-spring-slow-effect) infinite alternate-reverse;
z-index: 2;
&.overlay {
color: var(--md-ref-palette-primary20);
background: none;
animation: none;
mix-blend-mode: luminosity;
z-index: 3;
}
@keyframes title-gradient {
0% {
background-position: 0% 0%;
background-size: 100%;
}
50% {
background-position: 100% 0%;
}
100% {
background-position: 100% 100%;
background-size: 200%;
}
}
@media screen and (max-width: 1200px) {
@include mixin.typescale-style(
"display-large",
$font-size: 72rem,
$line-height: 78rem,
$font-variation-settings: "wght" 800
);
margin-inline: 36px;
}
@media screen and (max-width: 840px) {
@include mixin.typescale-style(
"display-large",
$font-size: 48rem,
$line-height: 54rem,
$font-variation-settings: "wght" 900
);
grid-column: 1 / 3;
margin-inline: 24px;
}
}
img {
grid-column: 1 / 3;
grid-row: 1;
height: 100%;
width: 100%;
object-fit: cover;
overflow: hidden;
pointer-events: none;
user-select: none;
-moz-user-select: none;
&:nth-of-type(1) {
backdrop-filter: blur(10px);
filter: url(#noise-filter);
mask-image: linear-gradient(to right, black 0%, transparent 60%);
mix-blend-mode: screen;
z-index: 1;
}
&:nth-of-type(2) {
z-index: 0;
}
@media screen and (max-width: 840px) {
&:nth-of-type(1) {
mask-image: linear-gradient(to right, black 0%, transparent 100%);
}
}
}
}
.stage {
@@ -52,7 +167,14 @@
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);
img {
height: 100%;
width: 100%;
object-fit: cover;
}
&.current {
width: 90%;
@@ -89,6 +211,30 @@
opacity: 0;
z-index: 0;
}
@media screen and (max-width: 840px) {
&.current {
width: 85%;
}
&.next {
width: calc(15% - 12px);
}
}
@media screen and (max-width: 600px) {
&.current {
width: 100%;
border-radius: var(--md-sys-shape-corner-large);
}
&.next {
width: 0%;
border-radius: var(--md-sys-shape-corner-large);
}
}
}
}
@@ -169,56 +315,79 @@
}
}
}
}
@media screen and (max-width: 1600px) {
.Header {
@media screen and (max-width: 1600px) {
grid-column: span 12;
}
}
@media screen and (max-width: 1200px) {
.Header {
@media screen and (max-width: 1200px) {
grid-column: span 8;
height: 46vw;
min-height: 270px;
}
}
@media screen and (max-width: 840px) {
.Header {
@media screen and (max-width: 840px) {
grid-column: span 6;
min-height: 270px;
.carousel-container .stage .item {
&.current {
width: 85%;
}
&.next {
width: calc(15% - 12px);
}
}
}
}
@media screen and (max-width: 600px) {
.Header {
@media screen and (max-width: 600px) {
grid-column: span 4;
}
}
.carousel-container .stage .item {
&.current {
width: 100%;
.header-enter-active {
transition:
opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial)
var(--md-sys-motion-duration-short1);
border-radius: var(--md-sys-shape-corner-large);
.carousel-container .single {
h1 {
transition: var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
}
img {
&:nth-of-type(1) {
transition: 5s var(--md-sys-motion-spring-slow-effect);
}
&.next {
width: 0%;
border-radius: var(--md-sys-shape-corner-large);
&:nth-of-type(2) {
transition: var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-default-effect)
var(--md-sys-motion-spring-fast-effect-duration);
}
}
}
}
.header-leave-active {
transition:
opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
.header-enter-from,
.header-leave-to {
opacity: 0;
transform: scale(0.985);
.carousel-container .single {
h1 {
opacity: 0;
transform: translateX(5%);
}
img {
&:nth-of-type(1) {
opacity: 0;
transform: translateX(-25%);
}
&:nth-of-type(2) {
opacity: 0;
transform: scale(1.005);
}
}
}

View File

@@ -22,47 +22,26 @@
-moz-user-select: none;
z-index: 9999;
.index-text {
padding-block: 3px;
padding-inline: 9px;
.nav-group {
position: absolute;
bottom: 0px;
color: var(--md-sys-color-surface-variant);
border-radius: var(--md-sys-shape-corner-medium);
background-color: var(--md-sys-color-on-surface-variant);
margin-block-end: 12px;
z-index: 3;
}
.btn-close,
.btn-nav {
display: flex;
align-items: center;
justify-content: center;
.close {
position: absolute;
right: 0px;
top: 0px;
position: absolute !important;
margin-inline-end: 12px;
margin-block-start: 12px;
z-index: 3;
}
.btn-close {
right: 20px;
top: 20px;
}
.btn-nav {
top: 50%;
&.prev {
left: 20px;
}
&.next {
right: 20px;
}
}
.content {
display: flex;
align-items: center;
@@ -74,70 +53,21 @@
padding-block-start: 5vh;
z-index: 2;
}
.content-image {
border-radius: var(--md-sys-shape-corner-full);
.content-image {
background-color: var(--md-sys-color-surface-variant);
background-color: var(--md-sys-color-surface-variant);
clip-path: circle(10%);
object-fit: contain;
transition: var(--md-sys-motion-spring-slow-spatial-duration) var(--md-sys-motion-spring-slow-spatial);
clip-path: circle(10%);
object-fit: contain;
transition: var(--md-sys-motion-spring-slow-spatial-duration) var(--md-sys-motion-spring-slow-spatial);
&.transitioning {
opacity: 0.6;
}
&.transitioning {
opacity: 0.6;
}
&.notransition {
transition: none !important;
}
}
.thumbnails {
display: flex;
gap: 8px;
max-width: calc(100% - 66px);
padding: 24px;
overflow-x: auto;
overflow-y: hidden;
z-index: 3;
}
.thumbnail {
flex-shrink: 0;
width: 66px;
height: 66px;
padding: 0px;
border: 0px;
border-radius: var(--md-sys-shape-corner-large);
cursor: pointer;
overflow: hidden;
transition: transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
&.active {
@include mixin.focus-ring($thickness: 1, $offset: 2);
outline-color: var(--md-sys-color-on-surface-variant) !important;
transition: transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
&:hover {
transform: scale(0.9);
}
img {
height: 100%;
width: 100%;
object-fit: cover;
&.notransition {
transition: none !important;
}
}
}
@@ -146,8 +76,6 @@
opacity: 1;
.content-image {
border-radius: var(--md-sys-shape-corner-large);
clip-path: circle(100%);
}
}

View File

@@ -44,7 +44,8 @@
background-color: var(--md-sys-color-secondary-container);
cursor: pointer;
transition: grid var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
transition:
grid var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
gap var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
overflow: hidden;
@@ -57,7 +58,9 @@
width: 56px;
text-align: center;
font-variation-settings: "FILL" 1, "wght" 300;
font-variation-settings:
"FILL" 1,
"wght" 300;
transition: font-variation-settings var(--md-sys-motion-spring-fast-spatial-duration)
var(--md-sys-motion-spring-fast-spatial);
@@ -77,11 +80,15 @@
}
&:hover span {
font-variation-settings: "FILL" 1, "wght" 600;
font-variation-settings:
"FILL" 1,
"wght" 600;
}
&:active span {
font-variation-settings: "FILL" 1, "wght" 200;
font-variation-settings:
"FILL" 1,
"wght" 200;
}
}
}
@@ -104,7 +111,8 @@
position: relative;
transition: grid var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
transition:
grid var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial),
gap var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect);
}
@@ -122,7 +130,8 @@
border-radius: var(--md-sys-shape-corner-full);
overflow: hidden;
transition: height var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transition:
height var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
width var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect);
z-index: 1;
@@ -135,7 +144,9 @@
span {
@include mixin.material-symbols();
font-variation-settings: "FILL" 1, "wght" 300;
font-variation-settings:
"FILL" 1,
"wght" 300;
transition: font-variation-settings var(--md-sys-motion-spring-fast-spatial-duration)
var(--md-sys-motion-spring-fast-spatial);
@@ -195,7 +206,8 @@
border-radius: var(--md-sys-shape-corner-full);
pointer-events: none;
transition: background-color var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transition:
background-color var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
height var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
width var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
z-index: 0;
@@ -220,19 +232,40 @@
}
&.inactive .accent .icon span {
font-variation-settings: "FILL" 0, "wght" 200;
font-variation-settings:
"FILL" 0,
"wght" 200;
}
&:hover .accent .icon span {
font-variation-settings: "FILL" 1, "wght" 400;
font-variation-settings:
"FILL" 1,
"wght" 400;
}
&:active .accent .icon span {
font-variation-settings: "FILL" 1, "wght" 200;
font-variation-settings:
"FILL" 1,
"wght" 200;
}
}
}
.actions {
display: flex;
align-items: center;
flex-direction: column;
flex-wrap: nowrap;
margin-block: 24px;
width: 100%;
@media screen and (max-width: 840px) {
display: none;
}
}
&.bar {
flex-direction: row;
@@ -310,6 +343,12 @@
height: 40px;
width: 100%;
}
@media screen and (max-width: 840px) {
.label {
@include mixin.typescale-style("label-large");
}
}
}
}
}
@@ -474,11 +513,9 @@
}
}
}
}
}
@media screen and (max-width: 840px) {
.NavBar.rail {
display: none;
@media screen and (max-width: 840px) {
display: none;
}
}
}

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

@@ -13,59 +13,6 @@
margin-block-start: 0px;
}
}
> hgroup {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 12px;
position: relative;
width: 100%;
margin-block-end: 24px;
h1 {
@include mixin.typescale-style("display-large");
text-align: center;
}
div {
display: flex;
align-items: center;
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
width: 100%;
hr {
flex-grow: 1;
margin: 0px;
}
h6 {
display: grid;
align-items: start;
gap: 6px;
grid-template-columns: max-content auto;
width: max-content;
line-height: 18px;
text-align: justify;
&::before {
@include mixin.material-symbols($name: "message", $size: 18);
vertical-align: middle;
}
}
}
}
}
*[class^="language-"] {
@@ -96,7 +43,8 @@
cursor: pointer;
opacity: 0;
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),
opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect);
visibility: hidden;
z-index: 2;
@@ -389,7 +337,7 @@
}
&:first-child .title-with-achor {
margin-block-start: 0px;
margin-block-start: 24px;
}
.title-with-achor {
@@ -402,6 +350,7 @@
display: inline-block;
line-height: 54px;
font-variation-settings: "wght" 600;
border-radius: var(--md-sys-shape-corner-medium);
@@ -503,7 +452,12 @@
}
a {
text-decoration: underline solid;
text-decoration: underline 2px dotted;
text-underline-offset: 3px;
&:hover {
text-decoration-style: solid;
}
}
blockquote {
@@ -671,24 +625,18 @@
font-variation-settings: "wght" 700;
}
&:has(img) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
img {
display: inline-block;
img {
display: inline-block;
width: 100%;
width: 100%;
border-radius: var(--md-sys-shape-corner-medium);
border-radius: var(--md-sys-shape-corner-medium);
cursor: pointer;
transition: border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
cursor: pointer;
transition: border-radius var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
&:hover {
border-radius: var(--md-sys-shape-corner-extra-large);
}
&:hover {
border-radius: var(--md-sys-shape-corner-extra-large);
}
}
}
@@ -784,7 +732,6 @@
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));
@@ -822,6 +769,12 @@
&::before {
@include mixin.material-symbols($size: 14);
margin-block: 1px auto;
}
&.description::before {
content: "message";
}
&.date-publish::before {
@@ -831,10 +784,6 @@
&.date-update::before {
content: "update";
}
&.id::before {
content: "flag";
}
}
}
}
@@ -887,10 +836,6 @@
@media screen and (max-width: 600px) {
#article-content {
grid-column: 1 / 5;
p:has(img) {
grid-template-columns: 1fr;
}
}
#article-aside {

View File

@@ -33,44 +33,148 @@
.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%;
font-variation-settings: "wght" 600;
text-align: center;
word-break: keep-all;
}
}
}
@@ -114,6 +218,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 +243,37 @@
.home-content {
grid-column: span 4;
.avatar-box {
zoom: 0.6;
h3 {
max-width: 80vw;
}
}
.title {
zoom: 0.7;
}
}
}
}
.layout-enter-active {
transition:
opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial)
var(--md-sys-motion-duration-short1);
}
.layout-leave-active {
transition:
opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
.layout-enter-from,
.layout-leave-to {
opacity: 0;
transform: scale(0.99);
}

View File

@@ -30,8 +30,6 @@
h2,
a {
@include mixin.typescale-style("display-small");
text-decoration: none;
}
}

View File

@@ -52,6 +52,18 @@ body {
background-color: var(--md-sys-color-on-surface-variant);
}
&.light {
img[data-mode="darkmode-only"] {
display: none !important;
}
}
&.dark {
img[data-mode="lightmode-only"] {
display: none !important;
}
}
}
#app {
@@ -122,7 +134,8 @@ a {
color: var(--md-sys-color-primary);
letter-spacing: 0px;
text-underline-offset: 5px;
text-decoration: underline solid;
text-underline-offset: 6px;
code {
color: var(--md-sys-color-inverse-primary) !important;
@@ -145,7 +158,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 +168,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;
}
}
}

File diff suppressed because one or more lines are too long

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);
}

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:lts-alpine
WORKDIR /app
COPY . /app
RUN npm install
EXPOSE 4173
CMD ["npm", "run", "docs:preview"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sendevia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,7 @@
{
"version": "26.1.9(232)",
"version": "26.2.25(298)",
"scripts": {
"update-version": "node scripts/update-version.js",
"update-version": "bash ./scripts/update-version.sh",
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:build-cf": "git fetch --unshallow && vitepress build",
@@ -9,16 +9,17 @@
},
"dependencies": {
"@material/material-color-utilities": "^0.3.0",
"@vueuse/core": "^14.1.0",
"@vueuse/core": "^14.2.1",
"pinia": "^3.0.4"
},
"devDependencies": {
"@mdit/plugin-align": "^0.22.2",
"@mdit/plugin-footnote": "^0.22.3",
"@mdit/plugin-tasklist": "^0.22.2",
"@mdit/plugin-align": "^0.24.0",
"@mdit/plugin-footnote": "^0.23.0",
"@mdit/plugin-img-mark": "^0.23.0",
"@mdit/plugin-tasklist": "^0.23.0",
"markdown-it-anchor": "^9.2.0",
"sass-embedded": "^1.93.0",
"vitepress": "^2.0.0-alpha.15",
"vue": "^3.5.0"
"sass-embedded": "^1.97.3",
"vitepress": "^2.0.0-alpha.16",
"vue": "^3.5.29"
}
}

View File

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

75
posts/Jekylmt.md Normal file
View File

@@ -0,0 +1,75 @@
---
title: "关于 Jekylmt"
description: "一个简洁美观的 Jekyll 主题"
color: "#0084ff"
impression: "/assets/images/131559307_p0.webp"
categories:
- 博客主题
tags:
- readme
date: 2026-02-25T21:44:00Z
external_links:
- type: download
icon: rocket_launch
label: 使用主题模板
link: https://github.com/new?template_name=jekylmt&template_owner=sendevia
- type: normal
icon: code
label: Github 仓库
link: https://github.com/sendevia/jekylmt
- type: normal
icon: arrow_outward
label: 在线 Demo
link: https://jekylmt.sendevia.top
---
# 关于主题
不太擅长介绍,既然这个页面存在了,就简单写一下吧。 这是一个遵循 Material3 设计,并且使用了 Material Web 项目,轻量化的 Jekyll 主题。
由于 M3 的设计太好看了,一直想亲自动手设计一款使用这种设计的前端项目,作为练手,这个主题便诞生了。
我借助 Material Foundation 的 [material-color-utilities](https://github.com/material-foundation/material-color-utilities) 实现了 monet 取色。
你可以在每篇文章的头信息 `impression` 配置中指定头图,并通过 `color` 配置指定颜色来让主题生成调色板。
这个主题的参考来源主要是 [material.io](https://material.io) 官网,其次是 Google 提供的设计规范。
代码高亮支持的语言可以在 [rouge-ruby](https://rouge-ruby.github.io/docs/file.Languages.html) 的网站上找到。
# 主题截图
![桌面端第一张亮色截图](/assets/images/26/jekylmt_1.webp#light)
![桌面端第一张暗色截图](/assets/images/26/jekylmt_2.webp#dark)
![桌面端第二张亮色截图](/assets/images/26/jekylmt_3.webp#light)
![桌面端第二张暗色截图](/assets/images/26/jekylmt_4.webp#dark)
![移动端第一张亮色截图](/assets/images/26/jekylmt_5.webp#light)
![移动端第一张暗色截图](/assets/images/26/jekylmt_6.webp#dark)
![移动端第二张亮色截图](/assets/images/26/jekylmt_7.webp#light)
![移动端第二张暗色截图](/assets/images/26/jekylmt_8.webp#dark)
# 主要功能
1. Material 3 风格;
2. 支持根据提供的 HEX 颜色动态生成调色板并应用颜色主题;
3. 支持多种 Material 3 样式的组件;
4. 响应式布局。
# 头信息
下面是所有头信息的详解:
| name | | description | type | default |
| ------------ | ------------- | ---------------- | ------- | --------------------------- |
| 文章相关 | title | 文章标题 | text | 使用 `_config.yml` 中的配置 |
| ^^ | description | 文章简介 | ^^ | ^^ |
| ^^ | author | 文章作者 | ^^ | ^^ |
| ^^ | color | 文章主题颜色 | ^^ | ^^ |
| ^^ | impression | 文章头图 | ^^ | ^^ |
| ^^ | categories | 目录分类 | list | 未定义 |
| ^^ | tags | 文章标签 | ^^ | ^^ |
| ^^ | published | 是否发布文章 | boolean | true |
| ^^ | toc | 是否生成文章目录 | ^^ | true |
| 页面导航相关 | segment_icon | 导航栏中的图标 | text | - |
| ^^ | segment_title | 导航栏中的标题 | ^^ | ^^ |
| ^^ | navigation | 是否在导航中显示 | boolean | ^^ |

View File

@@ -1,12 +1,14 @@
---
title: "Markdown 扩展示例"
description: "本页面展示了 VitePress 提供的一些内置 markdown 扩展功能。"
color: ""
impression: ""
color: "#f53283"
impression: "/assets/images/138124971_p0.webp"
categories:
tags:
date: time
- markdown 语法
- 示例文章
- 组件示例
date: 2007-08-31T00:00:00Z
---
# 语法高亮

View File

@@ -1,41 +0,0 @@
---
title: "关于这个主题的一些事"
description: ""
color: "#59f3b3"
impression: "/assets/images/133925125_p0.webp"
categories:
- 随笔
tags:
- readme
date: 2023-05-28T04:00:00Z
---
# 关于主题
由于 M3 的设计太好看了,一直想亲自动手设计一款使用这种设计的前端项目,作为练手,这个主题便诞生了。
我借助 Material Foundation 的 [material-color-utilities](https://github.com/material-foundation/material-color-utilities) 实现了 monet 取色。
你可以在每篇文章的头信息 `impression` 配置中指定头图,并通过 `color` 配置指定颜色来让主题生成调色板。
这个主题的参考来源主要是 [material.io](https://material.io) 官网,其次是 Google 提供的设计规范。
# 主要功能
1. Material 3 Expressive 设计风格;
1. 支持根据提供的 HEX 颜色/输入的图片动态生成调色板并应用颜色主题;
1. 响应式布局。
# 头信息
下面是所有头信息的详解:
| name || description | type | default |
| ---------- | ------------- | ---------------- | ------- | --------------------------- |
| 文章相关 | title | 文章标题 | text | 使用 `_config.yml` 中的配置 |
| ^^ | description | 文章简介 | ^^ | ^^ |
| ^^ | author | 文章作者 | ^^ | ^^ |
| ^^ | color | 文章主题颜色 | ^^ | ^^ |
| ^^ | impression | 文章头图 | ^^ | ^^ |
| ^^ | categories | 目录分类 | list | 未定义 |
| ^^ | tags | 文章标签 | ^^ | ^^ |

144
posts/组件/ButtonGroup.md Normal file
View File

@@ -0,0 +1,144 @@
---
title: "Button group"
description: "按钮组组件"
color: "#42b883"
categories:
- 开发文档
tags:
- Vue 3
- 组件
- UI
date: 2026-02-25T21:20:00Z
---
# ButtonGroup 按钮组组件
`ButtonGroup` 是一个用于聚合多个 `MaterialButton` 的容器组件。它支持灵活的布局切换、统一的属性默认值配置以及全局事件委托监听。
## 核心特性
- **布局自适应**支持水平Horizontal和垂直Vertical两种排列方式。
- **属性继承与覆盖**:可以在组级别设置默认的尺寸、颜色、图标等,也可以在具体的按钮项中进行个性化覆盖。
- **智能类型识别**:内置了对 `download``normal` 类型的样式及图标自动匹配逻辑。
- **事件委托**:支持统一监听 `@click` 事件,便捷获取点击的项信息及索引。
## 组件属性 (Props)
| 属性名 | 类型 | 默认值 | 可选值 | 说明 |
| :--- | :--- | :--- | :--- | :--- |
| `links` | `ExternalLink[]` | `[]` | - | 按钮配置数组 |
| `layout` | `string` | `"horizontal"` | `"horizontal"`, `"vertical"` | 布局方向 |
| `size` | `string` | `"s"` | `"xs"`, `"s"`, `"m"`, `"l"`, `"xl"` | 默认按钮尺寸 |
| `color` | `string` | - | - | 默认按钮颜色样式 |
| `icon` | `string` | - | - | 默认按钮图标 |
| `target` | `string` | - | - | 默认链接打开方式 |
| `ariaLabel` | `string` | - | - | 组的无障碍标签 |
## 组件事件 (Emits)
| 事件名 | 回调参数 | 说明 |
| :--- | :--- | :--- |
| `@click` | `(event: Event, item: ExternalLink, index: number)` | 当组内任意按钮被点击时触发 |
## 按钮配置项 (ExternalLink)
每一项 `links` 中的对象支持以下配置:
| 属性名 | 类型 | 说明 |
| :--- | :--- | :--- |
| `label` | `string` | 按钮显示的文本内容 |
| `id` | `string` | (可选) 自定义标识符,方便在事件处理中识别 |
| `link` | `string` | (可选) 点击跳转的链接地址 |
| `type` | `string` | (可选) 预设类型:`download` (充满色), `normal` (色调色) |
| `icon` | `string` | (可选) 覆盖组设置的图标 |
| `color` | `string` | (可选) 覆盖组设置的颜色 |
| `size` | `string` | (可选) 覆盖组设置的尺寸 |
| `target` | `string` | (可选) 覆盖组设置的打开方式 |
| `ariaLabel` | `string` | (可选) 按钮的无障碍标签 |
| `onClick` | `Function` | (可选) 单体独立的点击回调函数 |
## 使用示例
### 1. 基础用法 (水平排列)
```vue
<ButtonGroup
:links="[
{ label: '查看详情', id: 'detail' },
{ label: '下载资源', id: 'download', type: 'download' }
]"
@click="(e, item) => console.log('点击了:', item.id)"
/>
```
<ButtonGroup
:links="[
{ label: '查看详情', id: 'detail' },
{ label: '下载资源', id: 'download', type: 'download' }
]"
@click="(e, item) => console.log('点击了:', item.id)"
/>
### 2. 垂直排列与尺寸控制
```vue
<ButtonGroup
layout="vertical"
size="l"
:links="[
{ label: '选项一', icon: 'settings' },
{ label: '选项二', icon: 'person' }
]"
/>
```
<ButtonGroup
layout="vertical"
size="l"
:links="[
{ label: '选项一', icon: 'settings' },
{ label: '选项二', icon: 'person' }
]"
/>
### 3. 混合图标与文本
```vue
<ButtonGroup
size="m"
:links="[
{ id: 'prev', icon: 'chevron_left', ariaLabel: '上一页' },
{ id: 'index', label: '1 / 5', color: 'tonal' },
{ id: 'next', icon: 'chevron_right', ariaLabel: '下一页' }
]"
/>
```
<ButtonGroup
size="m"
:links="[
{ id: 'prev', icon: 'chevron_left', ariaLabel: '上一页' },
{ id: 'index', label: '1 / 5', color: 'tonal' },
{ id: 'next', icon: 'chevron_right', ariaLabel: '下一页' }
]"
/>
### 4. 链接跳转
```vue
<ButtonGroup
target="_blank"
:links="[
{ label: 'GitHub', link: 'https://github.com', icon: 'code' },
{ label: '首页', link: '/', icon: 'home' }
]"
/>
```
<ButtonGroup
target="_blank"
:links="[
{ label: 'GitHub', link: 'https://github.com', icon: 'code' },
{ label: '首页', link: '/', icon: 'home' }
]"
/>

View File

@@ -1,8 +1,8 @@
---
title: "设置开机自启动的 Jekyll 服务"
description: "通过 systemd 实现一个开机自启的 Jekyll 服务,通常来说,这对使用 Jekyll 作为服务后端的网站很有用。"
color: ""
impression: ""
color: "#aa0c2b"
impression: "/assets/images/120678678_p0.webp"
categories:
- 随笔
tags:

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,33 +0,0 @@
import fs from "node:fs";
import { execSync } from "node:child_process";
const pkg = JSON.parse(fs.readFileSync("./package.json", "utf-8"));
// 获取提交数
const commitCount = parseInt(execSync("git rev-list --count HEAD").toString().trim(), 10);
// 检查是否有暂存的更改
const hasStagedChanges = execSync("git diff --cached --quiet || echo 'changed'").toString().trim() === "changed";
// 版本号中的提交数 = 当前提交数 + 1 (即将进行的提交) + (如果有暂存更改则额外+1)
const nextCommitCount = commitCount + 1 + (hasStagedChanges ? 1 : 0);
// 获取当前日期
const now = new Date();
const year = now.getFullYear().toString().slice(-2);
const month = now.getMonth() + 1;
const date = now.getDate();
const newVersion = `${year}.${month}.${date}(${nextCommitCount})`;
pkg.version = newVersion;
fs.writeFileSync("./package.json", JSON.stringify(pkg, null, 2) + "\n");
console.log(`Version updated to: ${newVersion}`);
// 自动执行 git add 和 git commit
try {
execSync("git add package.json");
execSync(`git commit -m "chore(package): update to version ${newVersion}"`);
console.log(`Committed: chore(package): update to version ${newVersion}`);
} catch (error) {
console.error("Failed to commit version update:", error.message);
}

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

@@ -0,0 +1,58 @@
#!/bin/bash
set -e
# 远程仓库配置
REMOTE="${1:-origin}"
BRANCH="${2:-master}"
echo "🚀 开始版本更新流程..."
echo "远程: ${REMOTE}/${BRANCH}"
echo "::>------------------------"
# 获取当前提交数
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})"
echo "📝 更新版本号..."
# 使用 sed 更新 package.json 中的版本号
# 匹配 "version": "..." 模式
sed -i "s/\"version\": \".*\"/\"version\": \"${NEW_VERSION}\"/" package.json
echo "版本将更新到: ${NEW_VERSION}"
echo "::>------------------------"
# Git 操作
echo "📦 提交更改..."
git add package.json
git commit -m "chore(package): update to version ${NEW_VERSION}"
echo "已提交: chore(package): update to version ${NEW_VERSION}"
echo "::>------------------------"
echo "🏷️ 创建标签..."
git tag "${NEW_VERSION}"
echo "已创建标签: ${NEW_VERSION}"
echo "::>------------------------"
echo "🌐 推送更改..."
git push "${REMOTE}" "${BRANCH}"
echo "已推送提交到 ${REMOTE}/${BRANCH}"
echo "::>------------------------"
echo "🌐 推送标签..."
git push "${REMOTE}" "${NEW_VERSION}"
echo "已推送标签: ${NEW_VERSION}"
echo "::>------------------------"
echo "==========================="
echo "✅ 版本更新完成!"
echo "新版本: ${NEW_VERSION}"
echo "==========================="