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

37 Commits

Author SHA1 Message Date
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
60 changed files with 1639 additions and 524 deletions

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

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

View File

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

View File

@@ -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;
@@ -100,9 +91,7 @@ const show = () => {
});
};
/**
* 隐藏查看器,并还原焦点
*/
/** 隐藏查看器,并还原焦点 */
const hide = () => {
calculateInitialTransform();
isAnimating.value = false;

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,13 +152,14 @@ watch(
nextTick(() => {
window.dispatchEvent(new Event("resize"));
});
}
},
);
if (isClient()) {
onMounted(() => {
screenWidthStore.init();
navStateStore.init();
themeStateStore.init();
nextTick(() => {
window.dispatchEvent(new Event("resize"));
@@ -185,6 +201,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,90 @@
/**
* 颜色主题偏好管理
*/
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
import { usePreferredDark } from "@vueuse/core";
import { setCookie, getCookie } from "../utils/cookie";
import { isClient } from "../utils/env";
export type ThemePreference = "auto" | "light" | "dark";
export const useThemeStateStore = defineStore("themeState", () => {
/** 系统是否偏好深色模式 */
const systemDark = usePreferredDark();
/** 用户当前的偏好设置 */
const preference = ref<ThemePreference>("auto");
/** 计算当前实际生效的颜色模式 */
const isDarkActive = computed(() => {
if (preference.value === "auto") {
return systemDark.value;
}
return preference.value === "dark";
});
/** 初始化颜色主题,从 Cookie 读取配置,默认为 auto */
const init = () => {
const stored = getCookie("theme_preference");
if (stored === "light" || stored === "dark" || stored === "auto") {
preference.value = stored;
}
};
/** 监听实际生效的深色状态变化,操作 DOM */
watch(
isDarkActive,
(isDark) => {
if (isClient()) {
document.documentElement.classList.toggle("dark", isDark);
}
},
{ immediate: true },
);
/** 监听用户偏好变化,持久化到 Cookie */
watch(preference, (val) => {
setCookie("theme_preference", val);
});
/** 切换主题模式 */
const cycleTheme = () => {
const modes: ThemePreference[] = ["auto", "light", "dark"];
const nextIndex = (modes.indexOf(preference.value) + 1) % modes.length;
preference.value = modes[nextIndex];
};
/** 当前状态对应的图标 */
const currentIcon = computed(() => {
switch (preference.value) {
case "light":
return "light_mode";
case "dark":
return "dark_mode";
default:
return "brightness_auto";
}
});
/** 当前状态对应的文本标签 */
const currentLabel = computed(() => {
switch (preference.value) {
case "light":
return "亮色模式";
case "dark":
return "深色模式";
default:
return "跟随系统";
}
});
return {
preference,
currentIcon,
currentLabel,
init,
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

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

@@ -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 {
@@ -784,7 +738,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 +775,12 @@
&::before {
@include mixin.material-symbols($size: 14);
margin-block: 1px auto;
}
&.description::before {
content: "message";
}
&.date-publish::before {

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

@@ -122,7 +122,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 +146,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

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

11
Dockerfile Normal file
View File

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

21
LICENSE Normal file
View File

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

View File

@@ -1,5 +1,5 @@
{
"version": "26.1.14(252)",
"version": "26.2.22(289)",
"scripts": {
"update-version": "bash ./scripts/update-version.sh",
"docs:dev": "vitepress dev",
@@ -9,16 +9,16 @@
},
"dependencies": {
"@material/material-color-utilities": "^0.3.0",
"@vueuse/core": "^14.1.0",
"@vueuse/core": "^14.2.0",
"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.23.1",
"@mdit/plugin-footnote": "^0.22.4",
"@mdit/plugin-tasklist": "^0.22.3",
"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.28"
}
}

View File

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

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,8 +1,8 @@
---
title: "关于这个主题的一些事"
description: ""
color: "#59f3b3"
impression: "/assets/images/133925125_p0.webp"
color: "#0084ff"
impression: "/assets/images/131559307_p0.webp"
categories:
- 随笔
tags:

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.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 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 "==========================="