Compare commits
66 Commits
26.1.9(232
...
26.2.25(29
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bb864cf24 | |||
| 68448d29fe | |||
| cad4130789 | |||
| b4df562522 | |||
| 0a4f340e88 | |||
| 6a0cc5f5cb | |||
| 008160b3c9 | |||
| 416426d769 | |||
| e07acc8e35 | |||
| b1f8d6f15b | |||
| c7af2eeb0f | |||
| 1fbac018f9 | |||
| cbd3979476 | |||
| 5bcec415b3 | |||
| 1ada9579ce | |||
| 1215d1ca22 | |||
| 56264d3504 | |||
| 251b22f1b5 | |||
| 2c5cbfad01 | |||
| 42fedf3c3d | |||
| ecd4fb2886 | |||
| fff5f3ba2b | |||
| 5faa7e4220 | |||
| 568c7a7427 | |||
| bef3d5ce77 | |||
| 00b034f02b | |||
| f27da216e0 | |||
| 051aadc565 | |||
| 26ad7fed76 | |||
| 73a1b4044d | |||
| 1a6a60d5cc | |||
| 8cdbd059bb | |||
| 9ec1a05160 | |||
| 07ad900625 | |||
| 3a986b01eb | |||
| f9203e5815 | |||
| 07db1778c3 | |||
| a5621168ad | |||
| d9fe9ae1c4 | |||
| b547d81000 | |||
| 49f1911cfa | |||
| 794eb5dea5 | |||
| aaa506b394 | |||
| d5d7b34814 | |||
| 01d2ecba5b | |||
| 76ba78bce1 | |||
| 93dbbee3e1 | |||
| 3b61388272 | |||
| 336fbb34fc | |||
| 3a6810c436 | |||
| 7844d3ba47 | |||
| 9797bdf4dd | |||
| 8df0464354 | |||
| 53b7479bea | |||
| c9806f812a | |||
| 77d368dfdf | |||
| efea73968a | |||
| 5b13683d2b | |||
| b17fbaf443 | |||
| ba03ac1764 | |||
| e1943e94bd | |||
| 304eb88b85 | |||
| 1b74ac5964 | |||
| 651713067c | |||
| 788bfc543e | |||
| 03c217fb0e |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
35
.vitepress/theme/components/Chip.vue
Normal 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>
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 再包装的全局数据
|
||||
*/
|
||||
|
||||
import { useData } from "vitepress";
|
||||
|
||||
export function useGlobalData() {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 文章数据获取
|
||||
* 生成文章 ID,格式化头部数据
|
||||
*/
|
||||
|
||||
import { createContentLoader, type ContentData } from "vitepress";
|
||||
|
||||
export interface PostData {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 文章数据存储处理
|
||||
*/
|
||||
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { data as postsData, type PostData } from "./posts.data";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
63
.vitepress/theme/stores/themeState.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
77
.vitepress/theme/styles/components/Chip.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,6 @@
|
||||
h2,
|
||||
a {
|
||||
@include mixin.typescale-style("display-small");
|
||||
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
.vitepress/theme/utils/phrases.ts
Normal 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
@@ -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
@@ -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.
|
||||
19
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ categories:
|
||||
tags:
|
||||
- readme
|
||||
- osu!
|
||||
- 示例文章
|
||||
- 作品介绍
|
||||
date: 2022-07-04T04:00:00Z
|
||||
external_links:
|
||||
- type: download
|
||||
|
||||
75
posts/Jekylmt.md
Normal 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) 的网站上找到。
|
||||
|
||||
# 主题截图
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# 主要功能
|
||||
|
||||
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 | ^^ |
|
||||
@@ -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
|
||||
---
|
||||
|
||||
# 语法高亮
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
]"
|
||||
/>
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: "设置开机自启动的 Jekyll 服务"
|
||||
description: "通过 systemd 实现一个开机自启的 Jekyll 服务,通常来说,这对使用 Jekyll 作为服务后端的网站很有用。"
|
||||
color: ""
|
||||
impression: ""
|
||||
color: "#aa0c2b"
|
||||
impression: "/assets/images/120678678_p0.webp"
|
||||
categories:
|
||||
- 随笔
|
||||
tags:
|
||||
|
||||
BIN
public/assets/images/120678678_p0.webp
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
public/assets/images/120678678_p0_mesh-gradient.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 383 KiB |
BIN
public/assets/images/121337686_p0_mesh-gradient.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/assets/images/131559307_p0.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/assets/images/131559307_p0_mesh-gradient.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 624 KiB After Width: | Height: | Size: 538 KiB |
BIN
public/assets/images/132307491_p0_mesh-gradient.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/assets/images/133925125_p0_mesh-gradient.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 653 KiB |
BIN
public/assets/images/138124971_p0.webp
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
public/assets/images/138124971_p0_mesh-gradient.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/assets/images/26/jekylmt_1.webp
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
public/assets/images/26/jekylmt_2.webp
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
public/assets/images/26/jekylmt_3.webp
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
public/assets/images/26/jekylmt_4.webp
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
public/assets/images/26/jekylmt_5.webp
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/assets/images/26/jekylmt_6.webp
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
public/assets/images/26/jekylmt_7.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
public/assets/images/26/jekylmt_8.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/assets/images/avatar_transparent.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/images/beian.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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
@@ -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 "==========================="
|
||||