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

53 Commits

Author SHA1 Message Date
0493426eaa chore(package): update to version 26.2.28(305) 2026-02-28 20:59:32 +08:00
042342f923 article(Jekylmt): update table formatting 2026-02-28 20:59:22 +08:00
13548b222e feat(mdTable): add a simple markdown-it table plugin 2026-02-28 20:58:45 +08:00
adb804f249 fix(mdSectionWrapper): remove invalid instance check for MarkdownIt 2026-02-28 20:30:47 +08:00
d91f8b90aa rename: sectionWrapper to mdSectionWrapper 2026-02-28 20:28:41 +08:00
69d117152a remove: Dockerfile 2026-02-27 21:28:48 +08:00
b6e7649a4f remove: 公安备案 2026-02-27 21:27:24 +08:00
1bb864cf24 chore(package): update to version 26.2.25(298) 2026-02-25 23:24:57 +08:00
68448d29fe feat(ImageViewer): enhance image viewer with button group navigation 2026-02-25 23:24:46 +08:00
cad4130789 feat(ButtonGroup): enhance button group component with new props and event handling 2026-02-25 23:24:07 +08:00
b4df562522 style(tokens): update color scheme for light mode 2026-02-25 23:22:26 +08:00
0a4f340e88 chore(NavBar): remove theme state initialization on mount 2026-02-25 23:17:22 +08:00
6a0cc5f5cb style(Article): optimize image display 2026-02-25 23:17:02 +08:00
008160b3c9 article(Jekylmt): add new article 2026-02-25 23:15:53 +08:00
416426d769 refactor(theme): simplify theme management using useColorMode 2026-02-25 23:12:08 +08:00
e07acc8e35 chore(config): update markdown-it plugins and add markdown-it imgMark 2026-02-25 23:11:41 +08:00
b1f8d6f15b chore(package): update to version 26.2.22(289) 2026-02-22 21:09:12 +08:00
c7af2eeb0f feat(Dockerfile): add initial Dockerfile 2026-02-22 21:08:50 +08:00
1fbac018f9 article: update color scheme and replace impression image 2026-02-21 21:31:39 +08:00
cbd3979476 fix(images): replace PNG images with WEBP format 2026-02-09 23:44:31 +08:00
5bcec415b3 fix(dependencies): downgrade @material/material-color-utilities to version 0.3.0 and update vue to version 3.5.28 2026-02-09 23:35:49 +08:00
1ada9579ce style(layout): improve transition effects 2026-02-09 23:30:43 +08:00
1215d1ca22 style(Header): enhance header transition effects 2026-02-09 23:29:33 +08:00
56264d3504 chore(package): update dependencies to latest versions 2026-02-09 16:20:19 +08:00
251b22f1b5 chore(package): update to version 26.2.8(281) 2026-02-08 23:43:53 +08:00
2c5cbfad01 style(update-version): improve output formatting for version update process 2026-02-08 23:43:43 +08:00
42fedf3c3d article(Markdown 扩展示例): add color and impression 2026-02-08 23:40:16 +08:00
ecd4fb2886 style(Header): enhance title with gradient background and overlay effect 2026-02-08 23:39:39 +08:00
fff5f3ba2b fix(Header): hide SVG element 2026-02-08 21:25:26 +08:00
5faa7e4220 article(设置开机自启动的 Jekyll 服务): add color and impression 2026-02-08 21:18:32 +08:00
568c7a7427 style: update text style for improved visibility 2026-02-08 21:16:34 +08:00
bef3d5ce77 style: update link decoration 2026-02-08 21:15:42 +08:00
00b034f02b fix(PageIndicator): correct syntax and update text for short link display 2026-02-08 21:14:09 +08:00
f27da216e0 chore(package): update to version 26.2.6(272) 2026-02-06 16:17:35 +08:00
051aadc565 feat(update-version): enhance version update script with remote configuration and improved logging 2026-02-06 16:16:59 +08:00
26ad7fed76 feat(Header): add gradient image handling and improve layout structure 2026-02-06 16:11:41 +08:00
73a1b4044d feat(images): add new gradient images and remove obsolete image 2026-02-06 15:13:35 +08:00
1a6a60d5cc chore(package): update to version 26.1.30(268) 2026-01-30 22:05:21 +08:00
8cdbd059bb fix(navState): update cookie name 2026-01-30 22:04:07 +08:00
9ec1a05160 feat(NavBar): add theme toggle button 2026-01-30 22:03:03 +08:00
07ad900625 chore(package): update @mdit/plugin-align to version 0.23.0 2026-01-21 21:19:51 +08:00
3a986b01eb style(ArticleMasonry): improve panel layout and responsiveness, enhance transition effects 2026-01-21 14:54:06 +08:00
f9203e5815 project: add MIT License 2026-01-19 14:38:50 +08:00
07db1778c3 chore(package): update to version 26.1.18(262) 2026-01-18 23:18:34 +08:00
a5621168ad feat(ArticleMasonry): enhance article filtering and pagination features 2026-01-18 23:17:18 +08:00
d9fe9ae1c4 fix(Button): update transition properties and improve child selector specificity 2026-01-18 22:51:35 +08:00
b547d81000 article: add tags 2026-01-18 22:51:15 +08:00
49f1911cfa fix(DefaultLayout): correct syntax errors 2026-01-18 22:50:37 +08:00
794eb5dea5 feat(Chip): add new MaterialChip component with styles and functionality 2026-01-18 22:49:33 +08:00
aaa506b394 feat(mixin): add G3 box 2026-01-16 17:17:45 +08:00
d5d7b34814 refactor: improve code comments for better clarity and maintainability 2026-01-14 22:40:18 +08:00
01d2ecba5b refactor(DefaultLayout): enhance avatar box styles and animations 2026-01-14 22:08:17 +08:00
76ba78bce1 refactor(styles): remove redundant -webkit-mask properties from various components 2026-01-14 21:11:40 +08:00
74 changed files with 2045 additions and 676 deletions

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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 }}

View File

@@ -107,6 +107,5 @@ const next = computed(() => {
<style lang="scss" scoped>
@use "sass:meta";
/* 引用现有的导航组件样式 */
@include meta.load-css("../styles/components/PrevNext");
</style>

View File

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

View File

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

View File

@@ -1,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),

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,3 @@
.layout-enter-active {
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial)
var(--md-sys-motion-duration-short1);
}
.layout-leave-active {
transition: opacity var(--md-sys-motion-spring-default-effect-duration) var(--md-sys-motion-spring-default-effect),
transform var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
.layout-enter-from,
.layout-leave-to {
opacity: 0;
transform: translateY(10px);
}
@keyframes fade-out {
0% {
opacity: 0;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,12 @@
z-index: 0;
.single {
display: grid;
align-items: center;
grid-template-columns: 50% 50%;
position: relative;
height: 100%;
width: 100%;
@@ -24,7 +30,116 @@
background-size: cover;
overflow: hidden;
z-index: 1;
h1 {
@include mixin.typescale-style(
"display-large",
$font-size: 72rem,
$line-height: 78rem,
$font-variation-settings: "wght" 900
);
grid-column: 1 / 2;
grid-row: 1;
position: relative;
margin-inline-start: 48px;
color: transparent;
background-clip: text;
background-position: 0% 0%;
background-repeat: no-repeat;
background-size: 100%;
animation: title-gradient 5s var(--md-sys-motion-spring-slow-effect) infinite alternate-reverse;
z-index: 2;
&.overlay {
color: var(--md-ref-palette-primary20);
background: none;
animation: none;
mix-blend-mode: luminosity;
z-index: 3;
}
@keyframes title-gradient {
0% {
background-position: 0% 0%;
background-size: 100%;
}
50% {
background-position: 100% 0%;
}
100% {
background-position: 100% 100%;
background-size: 200%;
}
}
@media screen and (max-width: 1200px) {
@include mixin.typescale-style(
"display-large",
$font-size: 72rem,
$line-height: 78rem,
$font-variation-settings: "wght" 800
);
margin-inline: 36px;
}
@media screen and (max-width: 840px) {
@include mixin.typescale-style(
"display-large",
$font-size: 48rem,
$line-height: 54rem,
$font-variation-settings: "wght" 900
);
grid-column: 1 / 3;
margin-inline: 24px;
}
}
img {
grid-column: 1 / 3;
grid-row: 1;
height: 100%;
width: 100%;
object-fit: cover;
overflow: hidden;
pointer-events: none;
user-select: none;
-moz-user-select: none;
&:nth-of-type(1) {
backdrop-filter: blur(10px);
filter: url(#noise-filter);
mask-image: linear-gradient(to right, black 0%, transparent 60%);
mix-blend-mode: screen;
z-index: 1;
}
&:nth-of-type(2) {
z-index: 0;
}
@media screen and (max-width: 840px) {
&:nth-of-type(1) {
mask-image: linear-gradient(to right, black 0%, transparent 100%);
}
}
}
}
.stage {
@@ -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);
}
}
}

View File

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

View File

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

View File

@@ -66,6 +66,10 @@ table {
:is(td, th) {
border: 1px solid var(--md-sys-color-outline);
&:empty {
display: none;
}
code {
padding-block: 0px !important;
}

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,18 @@ body {
background-color: var(--md-sys-color-on-surface-variant);
}
&.light {
img[data-mode="darkmode-only"] {
display: none !important;
}
}
&.dark {
img[data-mode="lightmode-only"] {
display: none !important;
}
}
}
#app {
@@ -122,7 +134,8 @@ a {
color: var(--md-sys-color-primary);
letter-spacing: 0px;
text-underline-offset: 5px;
text-decoration: underline solid;
text-underline-offset: 6px;
code {
color: var(--md-sys-color-inverse-primary) !important;
@@ -145,7 +158,6 @@ hr {
background: var(--md-sys-color-outline-variant);
-webkit-mask: var(--via-svg-wave) repeat;
mask: var(--via-svg-wave) repeat;
opacity: 0.3;
}

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -4,12 +4,7 @@ import type MarkdownIt from "markdown-it";
* sectionheadline-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[] = [];

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

View File

@@ -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
View File

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

View File

@@ -1,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"
}
}

View File

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

75
posts/Jekylmt.md Normal file
View File

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

View File

@@ -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
---

View File

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

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -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 "==========================="