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

16 Commits

Author SHA1 Message Date
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
36 changed files with 1216 additions and 325 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

@@ -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,18 +43,14 @@ 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;

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

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

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

@@ -18,9 +18,7 @@ const lastUpdatedRawTime = computed(() =>
lastUpdatedTime.value ? useDateFormat(lastUpdatedTime.value, "YYYY-MM-DD HH:mm:ss").value : ""
);
/**
* 相对时间显示配置
*/
/** 相对时间显示配置 */
const timeAgo = useTimeAgo(
computed(() => lastUpdatedTime.value || 0),
{
@@ -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);

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

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

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

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

@@ -784,7 +784,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));

View File

@@ -56,16 +56,43 @@
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 +103,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 +118,40 @@
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);
}
}
}
}
@@ -162,6 +217,10 @@
.avatar-box {
zoom: 0.7;
h3 {
max-width: calc(840px / 2 - 12px);
}
}
.title {
@@ -183,6 +242,10 @@
.avatar-box {
zoom: 0.6;
h3 {
max-width: 80vw;
}
}
.title {

View File

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

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.1.30(268)",
"scripts": {
"update-version": "bash ./scripts/update-version.sh",
"docs:dev": "vitepress dev",
@@ -13,7 +13,7 @@
"pinia": "^3.0.4"
},
"devDependencies": {
"@mdit/plugin-align": "^0.22.2",
"@mdit/plugin-align": "^0.23.0",
"@mdit/plugin-footnote": "^0.22.3",
"@mdit/plugin-tasklist": "^0.22.2",
"markdown-it-anchor": "^9.2.0",

View File

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

View File

@@ -5,6 +5,9 @@ color: ""
impression: ""
categories:
tags:
- markdown 语法
- 示例文章
- 组件示例
date: 2007-08-31T00:00:00Z
---