Compare commits
53 Commits
26.1.14(25
...
26.2.28(30
| Author | SHA1 | Date | |
|---|---|---|---|
| 0493426eaa | |||
| 042342f923 | |||
| 13548b222e | |||
| adb804f249 | |||
| d91f8b90aa | |||
| 69d117152a | |||
| b6e7649a4f | |||
| 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 |
@@ -1,16 +1,19 @@
|
||||
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";
|
||||
import { wrapHeadingsAsSections } from "./theme/utils/sectionWrapper";
|
||||
// https://mdit-plugins.github.io/img-mark.html
|
||||
import { imgMark } from "@mdit/plugin-img-mark";
|
||||
import { sectionWrapper } from "./theme/utils/mdSectionWrapper";
|
||||
import { table } from "./theme/utils/mdTable";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/",
|
||||
@@ -40,8 +43,10 @@ export default defineConfig({
|
||||
config(md) {
|
||||
md.use(align);
|
||||
md.use(footnote);
|
||||
md.use(wrapHeadingsAsSections);
|
||||
md.use(sectionWrapper);
|
||||
md.use(tasklist, { label: true });
|
||||
md.use(imgMark);
|
||||
md.use(table);
|
||||
},
|
||||
image: {
|
||||
lazyLoading: true,
|
||||
@@ -94,6 +99,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",
|
||||
|
||||
@@ -8,35 +8,34 @@ import { useScreenWidthStore } from "../stores/screenWidth";
|
||||
import { handleTabNavigation } from "../utils/tabNavigation";
|
||||
import { useRoute } from "vitepress";
|
||||
|
||||
// 初始化 store 实例
|
||||
/** 初始化 store 实例 */
|
||||
const searchStateStore = useSearchStateStore();
|
||||
const screenWidthStore = useScreenWidthStore();
|
||||
const postsStore = usePostStore();
|
||||
|
||||
// 从 posts store 中解构出 posts 响应式数据
|
||||
/** 解构出 posts 响应式数据 */
|
||||
const { posts } = storeToRefs(postsStore);
|
||||
|
||||
// 初始化路由实例
|
||||
/** 初始化路由实例 */
|
||||
const route = useRoute();
|
||||
|
||||
// DOM 元素引用
|
||||
/** 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 是否隐藏的状态
|
||||
|
||||
// 使用 useGlobalScroll
|
||||
const { scrollResult, isScrolled: globalIsScrolled } = useGlobalScroll({ threshold: 100 });
|
||||
const { y, directions } = scrollResult;
|
||||
|
||||
// 计算属性
|
||||
const isScrolled = computed(() => globalIsScrolled.value); // 使用 useGlobalScroll 的 isScrolled
|
||||
/** 计算属性 */
|
||||
const isScrolled = computed(() => globalIsScrolled.value);
|
||||
const isTabFocusable = computed(() => !screenWidthStore.isAboveBreakpoint); // 判断当前屏幕宽度下,元素是否应该可被 Tab 键聚焦
|
||||
|
||||
// 工具函数:获取滚动容器
|
||||
/** 获取滚动容器 */
|
||||
const getScrollContainer = () => document.querySelector<HTMLElement>(".content-flow");
|
||||
|
||||
/**
|
||||
@@ -148,7 +147,7 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// 检查是否在搜索激活状态
|
||||
/** 检查是否在搜索激活状态 */
|
||||
const isSearchActive = computed(() => searchStateStore.isSearchActive);
|
||||
|
||||
/**
|
||||
@@ -204,7 +203,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听路由变化,处理页面切换后的状态重置和滚动绑定
|
||||
/** 处理页面切换后的状态重置和滚动绑定 */
|
||||
watch(
|
||||
() => route.path,
|
||||
async () => {
|
||||
@@ -225,25 +224,25 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// 事件处理函数引用(用于清理)
|
||||
/** 事件处理函数引用 */
|
||||
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();
|
||||
|
||||
@@ -257,7 +256,7 @@ const initializeAppBar = () => {
|
||||
addEventListeners();
|
||||
};
|
||||
|
||||
// 清理函数
|
||||
/** 清理函数 */
|
||||
const cleanupAppBar = () => {
|
||||
// 移除事件监听器
|
||||
removeEventListeners();
|
||||
@@ -324,7 +323,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <p class="description" v-if="post.description">{{ post.description }}</p> -->
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useBreakpoints } from "@vueuse/core";
|
||||
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";
|
||||
|
||||
const postsStore = usePostStore();
|
||||
const { theme } = useGlobalData();
|
||||
|
||||
/** 设置面板是否打开 */
|
||||
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 | "">("");
|
||||
|
||||
/**
|
||||
* 监听排序字段变化,自动调整排序方向
|
||||
* 标题:默认正序 (A-Z)
|
||||
* 时间:默认倒序 (最新在前)
|
||||
*/
|
||||
watch(sortField, (val) => {
|
||||
if (val === "title") {
|
||||
sortOrder.value = "asc";
|
||||
} else {
|
||||
sortOrder.value = "desc";
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 监听筛选或排序条件变化
|
||||
* 重置页码
|
||||
*/
|
||||
watch(
|
||||
[pageSize, selectedCategory, selectedTags, sortField, sortOrder],
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
/** 响应式断点配置 */
|
||||
const breakpoints = useBreakpoints({
|
||||
mobile: 600,
|
||||
@@ -28,12 +80,69 @@ const columnCount = computed(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取按日期降序排列的文章列表
|
||||
* @returns {PostData[]} 排序后的文章数组
|
||||
* 获取仅经过筛选(未排序)的文章列表
|
||||
* 逻辑:筛选分类和标签
|
||||
*/
|
||||
const articlesList = computed(() => {
|
||||
const posts = [...(postsStore.posts || [])];
|
||||
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
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);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -44,7 +153,7 @@ const masonryGroups = computed(() => {
|
||||
const count = columnCount.value;
|
||||
const groups: PostData[][] = Array.from({ length: count }, () => []);
|
||||
|
||||
articlesList.value.forEach((item, index) => {
|
||||
displayArticles.value.forEach((item, index) => {
|
||||
groups[index % count].push(item);
|
||||
});
|
||||
|
||||
@@ -76,28 +185,221 @@ const getArticleImage = (item: PostData): string[] => {
|
||||
const hasDownloadableContent = (item: PostData): boolean => {
|
||||
return Array.isArray(item.external_links) && item.external_links.some((link) => link.type === "download");
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换页码并滚动到顶部
|
||||
* @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" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 激活页码编辑模式 */
|
||||
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">
|
||||
<ClientOnly>
|
||||
<div v-for="(column, colIndex) in masonryGroups" :key="colIndex" class="masonry-column">
|
||||
<MaterialCard
|
||||
v-for="(item, rowIndex) in column"
|
||||
:key="item.id"
|
||||
class="entrance"
|
||||
variant="feed"
|
||||
size="m"
|
||||
color="outlined"
|
||||
:href="item.url"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:date="item.date"
|
||||
:impression="getArticleImage(item)"
|
||||
:downloadable="hasDownloadableContent(item)"
|
||||
:tabindex="getLogicIndex(colIndex, rowIndex) + 1"
|
||||
:style="{ '--delay': getLogicIndex(colIndex, rowIndex) }"
|
||||
/>
|
||||
<div 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -27,12 +27,6 @@ const siteVersion = theme.value.siteVersion;
|
||||
>
|
||||
</div>
|
||||
<div class="beian-info">
|
||||
<div class="beian-gongan">
|
||||
<img src="/assets/images/beian.png" loading="eager" />
|
||||
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=23020002230215" target="_blank"
|
||||
>黑公网安备23020002230215</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank">黑ICP备2024016516号-1</a>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ const config = reactive({
|
||||
animFast: 300,
|
||||
});
|
||||
|
||||
const { frontmatter, theme } = useGlobalData();
|
||||
const { frontmatter, theme, page } = useGlobalData();
|
||||
const headerRef = ref<HTMLElement | null>(null);
|
||||
const isHovering = useElementHover(headerRef);
|
||||
|
||||
@@ -32,9 +32,7 @@ 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];
|
||||
@@ -45,23 +43,29 @@ const totalCount = computed(() => rawImgList.value.length);
|
||||
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 变量名
|
||||
@@ -78,11 +82,16 @@ const parseTimeToken = (cssVar: string, defaultVal: number): number => {
|
||||
|
||||
/**
|
||||
* 并行加载图片并存入 Blob 缓存以消除闪烁
|
||||
* 同时缓存原图和梯度背景图
|
||||
* @param urls 图片地址列表
|
||||
*/
|
||||
const cacheImages = async (urls: string[]) => {
|
||||
if (!isClient()) return;
|
||||
const uncached = urls.filter((url) => !blobCache.has(url));
|
||||
|
||||
// 生成所有需要缓存的 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 {
|
||||
@@ -90,9 +99,9 @@ const cacheImages = async (urls: string[]) => {
|
||||
const blob = await res.blob();
|
||||
blobCache.set(url, URL.createObjectURL(blob));
|
||||
} catch {
|
||||
blobCache.set(url, url);
|
||||
blobCache.set(url, url); // 失败时回退到原始 URL
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -124,8 +133,17 @@ const slotStates = computed(() => {
|
||||
];
|
||||
const { cls, order, offset } = stateMap[relativePos];
|
||||
const imgIndex = (((currentRealIndex.value + offset) % totalCount.value) + totalCount.value) % totalCount.value;
|
||||
|
||||
const rawUrl = rawImgList.value[imgIndex];
|
||||
return { id: slotId, className: cls, imgUrl: blobCache.get(rawUrl) || rawUrl, order };
|
||||
const rawGradientUrl = getGradientUrl(rawUrl);
|
||||
|
||||
return {
|
||||
id: slotId,
|
||||
className: cls,
|
||||
imgUrl: blobCache.get(rawUrl) || rawUrl,
|
||||
gradientUrl: blobCache.get(rawGradientUrl) || rawGradientUrl,
|
||||
order,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,7 +156,7 @@ const { pause, resume } = useRafFn(
|
||||
step(1).then(() => (remainingTime.value = config.duration));
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -179,7 +197,7 @@ watch(
|
||||
await cacheImages(newList);
|
||||
if (hasMultiple.value && isClient()) resume();
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -199,56 +217,87 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="{ 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 * 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>
|
||||
<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>
|
||||
|
||||
@@ -3,9 +3,6 @@ import { ref, computed, onMounted, nextTick, watch } from "vue";
|
||||
import { useWindowSize, useEventListener, useVModel } from "@vueuse/core";
|
||||
import { handleTabNavigation } from "../utils/tabNavigation";
|
||||
|
||||
/**
|
||||
* 组件属性定义
|
||||
*/
|
||||
interface Props {
|
||||
images: string[];
|
||||
currentIndex: number;
|
||||
@@ -54,9 +51,7 @@ 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) {
|
||||
@@ -72,17 +67,13 @@ const calculateInitialTransform = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置缩放与平移位置
|
||||
*/
|
||||
/** 重置缩放与平移位置 */
|
||||
const resetZoom = () => {
|
||||
imageScale.value = 1;
|
||||
imagePosition.value = { x: 0, y: 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示查看器,并记录当前焦点
|
||||
*/
|
||||
/** 显示查看器,并记录当前焦点 */
|
||||
const show = () => {
|
||||
// 立即记录当前活跃元素
|
||||
lastActiveElement.value = document.activeElement as HTMLElement;
|
||||
@@ -95,14 +86,12 @@ const show = () => {
|
||||
// 延迟一帧触发动画,确保 CSS 过渡生效
|
||||
isAnimating.value = true;
|
||||
// 将焦点转移到查看器内部的关闭按钮
|
||||
const closeBtn = document.querySelector<HTMLElement>(".btn-close");
|
||||
const closeBtn = document.querySelector<HTMLElement>(".ImageViewer .close");
|
||||
closeBtn?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 隐藏查看器,并还原焦点
|
||||
*/
|
||||
/** 隐藏查看器,并还原焦点 */
|
||||
const hide = () => {
|
||||
calculateInitialTransform();
|
||||
isAnimating.value = false;
|
||||
@@ -124,8 +113,32 @@ const hide = () => {
|
||||
const prevImage = () => hasPrevious.value && activeIndex.value--;
|
||||
const nextImage = () => hasNext.value && activeIndex.value++;
|
||||
|
||||
/** 键盘交互处理 */
|
||||
useEventListener("keydown", (e: KeyboardEvent) => {
|
||||
/** 导航 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;
|
||||
}
|
||||
};
|
||||
|
||||
/** 处理键盘快捷键 */
|
||||
const handleKeyboardShortcuts = (e: KeyboardEvent) => {
|
||||
if (!isVisible.value) return;
|
||||
|
||||
// 陷阱焦点逻辑
|
||||
@@ -157,7 +170,10 @@ useEventListener("keydown", (e: KeyboardEvent) => {
|
||||
imageScale.value = Math.max(ZOOM_CONFIG.MIN, imageScale.value - ZOOM_CONFIG.STEP);
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 键盘交互处理 */
|
||||
useEventListener("keydown", handleKeyboardShortcuts);
|
||||
|
||||
/** 滚轮缩放与翻页 */
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
@@ -213,7 +229,7 @@ const handleTouchMove = (e: TouchEvent) => {
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
imageScale.value = Math.min(
|
||||
Math.max((dist / initialTouchDist) * initialTouchScale, ZOOM_CONFIG.MIN),
|
||||
ZOOM_CONFIG.MAX_TOUCH
|
||||
ZOOM_CONFIG.MAX_TOUCH,
|
||||
);
|
||||
} else if (e.touches.length === 1) {
|
||||
const touch = e.touches[0];
|
||||
@@ -245,23 +261,15 @@ defineExpose({ show, hide });
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<MaterialButton @click="hide" class="btn-close" icon="close" color="text" size="m" aria-label="关闭" />
|
||||
<MaterialButton
|
||||
v-if="hasPrevious"
|
||||
@click="prevImage"
|
||||
class="btn-nav prev"
|
||||
icon="chevron_left"
|
||||
size="l"
|
||||
aria-label="上一张"
|
||||
/>
|
||||
<MaterialButton
|
||||
v-if="hasNext"
|
||||
@click="nextImage"
|
||||
class="btn-nav next"
|
||||
icon="chevron_right"
|
||||
size="l"
|
||||
aria-label="下一张"
|
||||
<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.self="hide"
|
||||
@@ -291,18 +299,6 @@ defineExpose({ show, hide });
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<p class="index-text">{{ activeIndex + 1 }} / {{ images.length }}</p>
|
||||
<div class="thumbnails" v-if="images.length > 1">
|
||||
<button
|
||||
v-for="(img, idx) in images"
|
||||
:key="idx"
|
||||
class="thumbnail"
|
||||
:class="{ active: idx === activeIndex }"
|
||||
@click="activeIndex = idx"
|
||||
>
|
||||
<img :src="img" alt="thumbnail" />
|
||||
</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>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const { start: lockTimer } = useTimeoutFn(
|
||||
isLocked.value = false;
|
||||
},
|
||||
1200,
|
||||
{ immediate: false }
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
/** 计算文章 ID 与短链 */
|
||||
@@ -38,17 +38,13 @@ const articleId = computed(() => {
|
||||
|
||||
const shortLink = computed(() => (articleId.value ? `/p/${articleId.value}` : ""));
|
||||
|
||||
/**
|
||||
* 复制短链到剪贴板
|
||||
*/
|
||||
/** 复制短链到剪贴板 */
|
||||
const copyShortLink = async () => {
|
||||
if (!shortLink.value) return;
|
||||
await copyToClipboard(`${window.location.origin}${shortLink.value}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 收集页面中的 h1 和 h2 标题
|
||||
*/
|
||||
/** 收集页面中的 h1 和 h2 标题 */
|
||||
const collectHeadings = () => {
|
||||
if (!isClient()) return;
|
||||
const nodes = Array.from(document.querySelectorAll("h1[id], h2[id]")) as HTMLElement[];
|
||||
@@ -59,9 +55,7 @@ const collectHeadings = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新指示器(高亮块)的位置和尺寸
|
||||
*/
|
||||
/** 更新指示器(高亮边框)的位置和尺寸 */
|
||||
const updateIndicator = () => {
|
||||
const container = pageIndicator.value;
|
||||
const id = headingsActiveId.value;
|
||||
@@ -132,11 +126,11 @@ if (isClient()) {
|
||||
headingsActiveId.value = bestId;
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.1, 0.5, 1] }
|
||||
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.1, 0.5, 1] },
|
||||
);
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** 监听容器及其子元素尺寸变化 */
|
||||
@@ -159,7 +153,7 @@ watch(
|
||||
} else {
|
||||
indicator.value.opacity = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(headingsActiveId, () => {
|
||||
@@ -180,7 +174,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<div ref="pageIndicator" class="PageIndicator">
|
||||
<div class="label">
|
||||
<p class="text">在此页上</p>
|
||||
<p class="text">文章短链</p>
|
||||
<p class="icon">link</p>
|
||||
<p class="article-id" :title="isCopied ? '已复制' : '复制短链'" v-if="articleId" @click="copyShortLink">
|
||||
{{ isCopied ? "已复制" : articleId }}
|
||||
|
||||
@@ -107,6 +107,5 @@ const next = computed(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "sass:meta";
|
||||
/* 引用现有的导航组件样式 */
|
||||
@include meta.load-css("../styles/components/PrevNext");
|
||||
</style>
|
||||
|
||||
@@ -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,8 +1,12 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
/**
|
||||
* 适用本项目的再包装 useScroll 工具函数
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useScroll } from "@vueuse/core";
|
||||
import { isClient } from "../utils/env";
|
||||
|
||||
// 全局状态
|
||||
/** 全局状态 */
|
||||
const globalThreshold = ref(80);
|
||||
const globalPrecision = ref(1);
|
||||
const globalTargetScrollable = ref(".content-flow");
|
||||
@@ -11,14 +15,21 @@ 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;
|
||||
}
|
||||
|
||||
// 检测容器
|
||||
/**
|
||||
* 检测容器
|
||||
* @param targetScrollable 目标容器
|
||||
* @returns 判断通过的滚动容器
|
||||
*/
|
||||
function detectContainer(targetScrollable: string) {
|
||||
if (!isClient()) return window;
|
||||
|
||||
@@ -27,7 +38,13 @@ function detectContainer(targetScrollable: string) {
|
||||
return window;
|
||||
}
|
||||
|
||||
// 计算滚动百分比
|
||||
/**
|
||||
* 计算滚动百分比
|
||||
* @param scrollTop 顶部距离
|
||||
* @param scrollContainer 滚动容器
|
||||
* @param precision 浮点精度
|
||||
* @returns 百分比
|
||||
*/
|
||||
function calculatePercentage(scrollTop: number, scrollContainer: HTMLElement | Window, precision: number): number {
|
||||
try {
|
||||
let scrollHeight: number, clientHeight: number;
|
||||
@@ -55,13 +72,14 @@ function calculatePercentage(scrollTop: number, scrollContainer: HTMLElement | W
|
||||
}
|
||||
}
|
||||
|
||||
// 更新全局状态
|
||||
/** 更新全局状态 */
|
||||
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 ?? globalThreshold.value;
|
||||
const localPrecision = options?.precision ?? globalPrecision.value;
|
||||
@@ -155,7 +173,7 @@ export function useGlobalScroll(options?: { threshold?: number; container?: stri
|
||||
scrollPosition: computed(() => localScrollPosition.value),
|
||||
scrollPercentage: computed(() => localScrollPercentage.value),
|
||||
|
||||
// 原始 useScroll 结果(用于高级用途)
|
||||
// 原始 useScroll 结果
|
||||
scrollResult,
|
||||
|
||||
// 容器引用
|
||||
@@ -167,7 +185,7 @@ export function useGlobalScroll(options?: { threshold?: number; container?: stri
|
||||
};
|
||||
}
|
||||
|
||||
// 全局滚动状态
|
||||
/** 全局滚动状态 */
|
||||
export const globalScrollState = {
|
||||
isScrolled: computed(() => globalIsScrolled.value),
|
||||
threshold: computed(() => globalThreshold.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);
|
||||
|
||||
@@ -12,15 +12,13 @@ const { copy: copyToClipboard } = useClipboard();
|
||||
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 : "",
|
||||
);
|
||||
|
||||
/**
|
||||
* 相对时间显示配置
|
||||
*/
|
||||
/** 相对时间显示配置 */
|
||||
const timeAgo = useTimeAgo(
|
||||
computed(() => lastUpdatedTime.value || 0),
|
||||
{
|
||||
@@ -37,7 +35,7 @@ const timeAgo = useTimeAgo(
|
||||
minute: (n: number) => `${n}分钟`,
|
||||
second: (n: number) => `${n}秒`,
|
||||
} as any,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 计算最终显示的编辑时间文本 */
|
||||
@@ -87,9 +85,7 @@ const openImageViewer = (index: number, event: MouseEvent) => {
|
||||
showImageViewer.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 复制锚点链接到剪贴板
|
||||
*/
|
||||
/** 复制锚点链接到剪贴板 */
|
||||
const handleAnchorClick = (event: MouseEvent) => {
|
||||
const anchor = (event.target as HTMLElement).closest("a.title-anchor") as HTMLAnchorElement;
|
||||
if (!anchor) return;
|
||||
@@ -108,9 +104,7 @@ const handleAnchorClick = (event: MouseEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理列表 Bullet 旋转和有序列表对齐
|
||||
*/
|
||||
/** 处理列表 Bullet 旋转和有序列表对齐 */
|
||||
const enhanceDomStyles = () => {
|
||||
if (!articleContentRef.value) return;
|
||||
|
||||
@@ -132,9 +126,7 @@ const enhanceDomStyles = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 绑定文章图片点击监听
|
||||
*/
|
||||
/** 绑定文章图片点击监听 */
|
||||
const bindImageEvents = () => {
|
||||
articleContentRef.value?.querySelectorAll("img").forEach((img, index) => {
|
||||
(img as HTMLElement).onclick = (e) => openImageViewer(index, e);
|
||||
@@ -150,7 +142,7 @@ if (isClient()) {
|
||||
enhanceDomStyles();
|
||||
bindImageEvents();
|
||||
},
|
||||
{ childList: true, subtree: true, characterData: true }
|
||||
{ childList: true, subtree: true, characterData: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -165,21 +157,15 @@ if (isClient()) {
|
||||
<template>
|
||||
<Header />
|
||||
<main id="article-content" ref="articleContentRef" @click="handleAnchorClick">
|
||||
<hgroup>
|
||||
<h1>{{ frontmatter.title || page.title }}</h1>
|
||||
<div v-if="frontmatter.description">
|
||||
<hr />
|
||||
<h6>{{ frontmatter.description }}</h6>
|
||||
</div>
|
||||
</hgroup>
|
||||
<Content />
|
||||
<PrevNext />
|
||||
</main>
|
||||
<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>
|
||||
|
||||
@@ -8,21 +8,15 @@ 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());
|
||||
|
||||
/** 布局映射表 */
|
||||
@@ -114,13 +108,11 @@ if (isClient()) {
|
||||
{
|
||||
attributes: true,
|
||||
attributeFilter: ["impression-color"],
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由变化监听:处理短链重定向
|
||||
*/
|
||||
/** 处理短链重定向 */
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
@@ -128,24 +120,20 @@ watch(
|
||||
isRedirecting.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听首页状态变化,进入首页时更新随机问候语
|
||||
*/
|
||||
/** 进入首页时更新随机问候语 */
|
||||
watch(
|
||||
() => frontmatter.value.home,
|
||||
(isHome) => {
|
||||
if (isHome) {
|
||||
randomGreeting.value = getFormattedRandomPhrase();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 过渡动画钩子:进入后更新色板
|
||||
*/
|
||||
/** 进入后更新色板 */
|
||||
function onAfterEnter() {
|
||||
if (!isRedirecting.value) updatePalette();
|
||||
}
|
||||
@@ -170,7 +158,7 @@ onMounted(() => {
|
||||
<h3>
|
||||
{{ randomGreeting }}
|
||||
</h3>
|
||||
<img src="/assets/images/avatar_transparent.png" alt="" />
|
||||
<img src="/assets/images/avatar_transparent.webp" alt="" />
|
||||
<span></span>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
width: 42px;
|
||||
|
||||
object-fit: cover;
|
||||
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
|
||||
mask: var(--via-svg-mask) no-repeat 0 / 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
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 {
|
||||
@@ -54,6 +169,13 @@
|
||||
overflow: hidden;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ table {
|
||||
:is(td, th) {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
padding-block: 0px !important;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -883,10 +836,6 @@
|
||||
@media screen and (max-width: 600px) {
|
||||
#article-content {
|
||||
grid-column: 1 / 5;
|
||||
|
||||
p:has(img) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
#article-aside {
|
||||
|
||||
@@ -56,16 +56,44 @@
|
||||
width: max-content;
|
||||
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
padding-inline: 12px 24px;
|
||||
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
text-align: justify;
|
||||
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-small) var(--md-sys-shape-corner-large) var(--md-sys-shape-corner-large);
|
||||
|
||||
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 {
|
||||
@@ -76,9 +104,10 @@
|
||||
height: 300px;
|
||||
width: 300px;
|
||||
|
||||
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
|
||||
mask: var(--via-svg-mask) no-repeat 0 / 100%;
|
||||
translate: 0 -50%;
|
||||
translate: 0px -50%;
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -90,13 +119,42 @@
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
|
||||
background: linear-gradient(90deg, var(--md-sys-color-purple), var(--md-sys-color-purple-container));
|
||||
|
||||
animation: spin 60s linear infinite;
|
||||
mask: var(--via-svg-mask) no-repeat 0 / 100%;
|
||||
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +172,7 @@
|
||||
h1 {
|
||||
@include mixin.typescale-style("display-large");
|
||||
|
||||
font-variation-settings: "wght" 600;
|
||||
text-align: center;
|
||||
word-break: keep-all;
|
||||
}
|
||||
@@ -162,6 +221,10 @@
|
||||
|
||||
.avatar-box {
|
||||
zoom: 0.7;
|
||||
|
||||
h3 {
|
||||
max-width: calc(840px / 2 - 12px);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -183,6 +246,10 @@
|
||||
|
||||
.avatar-box {
|
||||
zoom: 0.6;
|
||||
|
||||
h3 {
|
||||
max-width: 80vw;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -191,3 +258,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,7 @@ import type MarkdownIt from "markdown-it";
|
||||
* 将连续的标题块包裹为独立的 section(headline-block),便于样式与交互处理
|
||||
* @param mdit - MarkdownIt 实例
|
||||
*/
|
||||
export function wrapHeadingsAsSections(mdit: MarkdownIt): void {
|
||||
if (!mdit || !mdit.core || !mdit.core.ruler) {
|
||||
console.warn("Invalid MarkdownIt instance provided");
|
||||
return;
|
||||
}
|
||||
|
||||
export function sectionWrapper(mdit: MarkdownIt): void {
|
||||
mdit.core.ruler.before("inline", "group_sections", (state) => {
|
||||
const tokens = state.tokens;
|
||||
const newTokens: any[] = [];
|
||||
131
.vitepress/theme/utils/mdTable.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
type StateBlock = any;
|
||||
|
||||
/**
|
||||
* 一个简易的 markdown-it 表格插件
|
||||
* @param mdit MarkdownIt 实例
|
||||
*/
|
||||
export const table = (mdit: MarkdownIt) => {
|
||||
// 禁用原有的表格规则
|
||||
mdit.disable("table");
|
||||
|
||||
/** 表格块解析规则 */
|
||||
const tableBlock = (state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean => {
|
||||
let nextLine = startLine;
|
||||
let lineText = state.getLines(nextLine, nextLine + 1, 0, false).trim();
|
||||
|
||||
// 检查是否包含表格分隔符
|
||||
if (!lineText.includes("|")) return false;
|
||||
|
||||
const rows: string[] = [];
|
||||
while (nextLine < endLine) {
|
||||
lineText = state.getLines(nextLine, nextLine + 1, 0, false).trim();
|
||||
if (!lineText.includes("|") && lineText !== "") break;
|
||||
if (lineText !== "") rows.push(lineText);
|
||||
nextLine++;
|
||||
}
|
||||
|
||||
if (rows.length < 1) return false;
|
||||
|
||||
if (silent) return true;
|
||||
|
||||
const token = state.push("simple_table", "table", 0);
|
||||
token.map = [startLine, nextLine];
|
||||
token.content = rows.join("\n");
|
||||
|
||||
state.line = nextLine;
|
||||
return true;
|
||||
};
|
||||
|
||||
mdit.block.ruler.before("paragraph", "simple_table", tableBlock);
|
||||
|
||||
// 渲染逻辑
|
||||
mdit.renderer.rules.simple_table = (tokens, idx) => {
|
||||
const content = tokens[idx].content;
|
||||
const lines = content.split("\n").filter((l) => l.trim() !== "");
|
||||
if (lines.length === 0) return "";
|
||||
|
||||
let html = "<table>\n";
|
||||
|
||||
// 检查第二行是否是分隔行
|
||||
const hasSeparator = lines.length > 1 && /^[|:\-\s]+$/.test(lines[1]) && lines[1].includes("-");
|
||||
|
||||
let headerLines: string[] = [];
|
||||
let bodyLines: string[] = [];
|
||||
|
||||
if (hasSeparator) {
|
||||
headerLines = [lines[0]];
|
||||
bodyLines = lines.slice(2);
|
||||
} else {
|
||||
// 如果没有分隔行,用第一行作为表头
|
||||
headerLines = [lines[0]];
|
||||
bodyLines = lines.slice(1);
|
||||
}
|
||||
|
||||
// 分割单元格并清理首尾空的装饰管线
|
||||
const getCells = (line: string) => {
|
||||
const cells = line.split("|");
|
||||
if (cells.length > 0 && cells[0].trim() === "") cells.shift();
|
||||
if (cells.length > 0 && cells[cells.length - 1].trim() === "") cells.pop();
|
||||
return cells.map((c) => c.trim());
|
||||
};
|
||||
|
||||
// 解析单元格中的属性
|
||||
const parseAttributes = (cellContent: string) => {
|
||||
const attrRegex = /\{([^{}]+)\}\s*$/;
|
||||
const match = cellContent.match(attrRegex);
|
||||
const attrs: Record<string, string> = {};
|
||||
let cleanedContent = cellContent;
|
||||
|
||||
if (match) {
|
||||
cleanedContent = cellContent.replace(attrRegex, "").trim();
|
||||
const attrString = match[1];
|
||||
const parts = attrString.split(/\s+/);
|
||||
parts.forEach((part) => {
|
||||
const [key, value] = part.split("=");
|
||||
if (key && (key === "colspan" || key === "rowspan")) {
|
||||
attrs[key] = value || "1";
|
||||
}
|
||||
});
|
||||
}
|
||||
return { cleanedContent, attrs };
|
||||
};
|
||||
|
||||
// 渲染表头
|
||||
if (headerLines.length > 0) {
|
||||
html += "<thead>\n";
|
||||
headerLines.forEach((line) => {
|
||||
html += "<tr>\n";
|
||||
getCells(line).forEach((cell) => {
|
||||
const { cleanedContent, attrs } = parseAttributes(cell);
|
||||
const attrStr = Object.entries(attrs)
|
||||
.map(([k, v]) => ` ${k}="${v}"`)
|
||||
.join("");
|
||||
html += `<th${attrStr}>${mdit.renderInline(cleanedContent)}</th>\n`;
|
||||
});
|
||||
html += "</tr>\n";
|
||||
});
|
||||
html += "</thead>\n";
|
||||
}
|
||||
|
||||
// 渲染表体
|
||||
if (bodyLines.length > 0) {
|
||||
html += "<tbody>\n";
|
||||
bodyLines.forEach((line) => {
|
||||
html += "<tr>\n";
|
||||
getCells(line).forEach((cell) => {
|
||||
const { cleanedContent, attrs } = parseAttributes(cell);
|
||||
const attrStr = Object.entries(attrs)
|
||||
.map(([k, v]) => ` ${k}="${v}"`)
|
||||
.join("");
|
||||
html += `<td${attrStr}>${mdit.renderInline(cleanedContent)}</td>\n`;
|
||||
});
|
||||
html += "</tr>\n";
|
||||
});
|
||||
html += "</tbody>\n";
|
||||
}
|
||||
|
||||
html += "</table>";
|
||||
return html;
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 短语库 - 用于在主页显示随机问候语
|
||||
* 用于在主页显示随机问候语的短语库
|
||||
*/
|
||||
|
||||
export interface Phrase {
|
||||
@@ -23,9 +23,7 @@ export function getCurrentTimeOfDay(): "morning" | "afternoon" | "evening" {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 短语库 - 按时间段分类
|
||||
*/
|
||||
/** 按时间段分类 */
|
||||
export const phrasesByTime: Record<"morning" | "afternoon" | "evening" | "any", Phrase[]> = {
|
||||
morning: [
|
||||
{ text: "早上好!新的一天开始啦", timeOfDay: "morning" },
|
||||
|
||||
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.
|
||||
18
package.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "26.1.14(252)",
|
||||
"version": "26.2.28(305)",
|
||||
"scripts": {
|
||||
"update-version": "bash ./scripts/update-version.sh",
|
||||
"docs:dev": "vitepress dev",
|
||||
@@ -9,16 +9,18 @@
|
||||
},
|
||||
"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",
|
||||
"@waline/client": "^3.13.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 { colspan=2 } | | description | type | default |
|
||||
| -------------------------- | ------------- | ---------------- | --------------------- | ----------------------------------------- |
|
||||
| 文章相关 { rowspan=9 } | title | 文章标题 | text { rowspan=5 } | 使用 `_config.yml` 中的配置 { rowspan=5 } |
|
||||
| | description | 文章简介 | | |
|
||||
| | author | 文章作者 | | |
|
||||
| | color | 文章主题颜色 | | |
|
||||
| | impression | 文章头图 | | |
|
||||
| | categories | 目录分类 | list { rowspan=2 } | 未定义 { rowspan=2 } |
|
||||
| | tags | 文章标签 | | |
|
||||
| | published | 是否发布文章 | boolean { rowspan=2 } | true { rowspan=2 } |
|
||||
| | toc | 是否生成文章目录 | | |
|
||||
| 页面导航相关 { rowspan=3 } | segment_icon | 导航栏中的图标 | text { rowspan=2 } | - { rowspan=3 } |
|
||||
| | segment_title | 导航栏中的标题 | | |
|
||||
| | navigation | 是否在导航中显示 | boolean | |
|
||||
@@ -1,10 +1,13 @@
|
||||
---
|
||||
title: "Markdown 扩展示例"
|
||||
description: "本页面展示了 VitePress 提供的一些内置 markdown 扩展功能。"
|
||||
color: ""
|
||||
impression: ""
|
||||
color: "#f53283"
|
||||
impression: "/assets/images/138124971_p0.webp"
|
||||
categories:
|
||||
tags:
|
||||
- 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 |
|
Before Width: | Height: | Size: 3.2 MiB |
BIN
public/assets/images/avatar_transparent.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,5 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# 远程仓库配置
|
||||
REMOTE="${1:-origin}"
|
||||
BRANCH="${2:-master}"
|
||||
|
||||
echo "🚀 开始版本更新流程..."
|
||||
echo "远程: ${REMOTE}/${BRANCH}"
|
||||
echo "::>------------------------"
|
||||
|
||||
# 获取当前提交数
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
|
||||
@@ -13,16 +23,36 @@ 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 "Version updated to: ${NEW_VERSION}"
|
||||
echo "版本将更新到: ${NEW_VERSION}"
|
||||
echo "::>------------------------"
|
||||
|
||||
# Git 操作
|
||||
echo "📦 提交更改..."
|
||||
git add package.json
|
||||
git commit -m "chore(package): update to version ${NEW_VERSION}"
|
||||
echo "Committed: chore(package): update to version ${NEW_VERSION}"
|
||||
echo "已提交: chore(package): update to version ${NEW_VERSION}"
|
||||
echo "::>------------------------"
|
||||
|
||||
echo "🏷️ 创建标签..."
|
||||
git tag "${NEW_VERSION}"
|
||||
echo "Tagged: ${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 "==========================="
|
||||
|
||||