Initial commit

This commit is contained in:
2026-02-02 22:47:52 +03:00
committed by GitHub
commit f53016aeda
239 changed files with 84360 additions and 0 deletions

45
src/utils/albums.ts Normal file
View File

@@ -0,0 +1,45 @@
// Album data configuration file
// Used to manage data for the album display page
const albumModules = import.meta.glob('../content/albums/*.json', { eager: true });
export interface Photo {
src: string;
alt?: string;
title?: string;
description?: string;
tags?: string[];
date?: string;
width?: number;
height?: number;
}
export interface AlbumGroup {
id: string;
title: string;
description?: string;
cover: string;
date: string;
location?: string;
tags?: string[];
layout?: "grid" | "masonry" | "list";
columns?: number;
photos: Photo[];
visible?: boolean;
}
export const albums: AlbumGroup[] = Object.entries(albumModules).map(([path, mod]: [string, any]) => {
const id = path.split('/').pop()?.replace('.json', '') || '';
const data = mod.default as any;
const album: AlbumGroup = {
id,
...data,
photos: data.photos || [],
visible: data.visible !== false, // 默认为 true
};
return album;
});
// Sort albums by date in descending order
export const sortedAlbums = [...albums].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);

121
src/utils/content.ts Normal file
View File

@@ -0,0 +1,121 @@
import { type CollectionEntry, getCollection } from "astro:content";
import { getCategoryUrl } from "@utils/url";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
// // Retrieve posts and sort them by publication date
async function getRawSortedPosts() {
const allBlogPosts = await getCollection("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const sorted = allBlogPosts.sort((a, b) => {
// 首先按置顶状态排序,置顶文章在前
if (a.data.pinned && !b.data.pinned) return -1;
if (!a.data.pinned && b.data.pinned) return 1;
// 如果置顶状态相同,则按发布日期排序
const dateA = new Date(a.data.published);
const dateB = new Date(b.data.published);
return dateA > dateB ? -1 : 1;
});
return sorted;
}
export async function getSortedPosts() {
const sorted = await getRawSortedPosts();
for (let i = 1; i < sorted.length; i++) {
sorted[i].data.nextSlug = sorted[i - 1].id;
sorted[i].data.nextTitle = sorted[i - 1].data.title;
}
for (let i = 0; i < sorted.length - 1; i++) {
sorted[i].data.prevSlug = sorted[i + 1].id;
sorted[i].data.prevTitle = sorted[i + 1].data.title;
}
return sorted;
}
export type PostForList = {
id: string;
data: CollectionEntry<"posts">["data"];
};
export async function getSortedPostsList(): Promise<PostForList[]> {
const sortedFullPosts = await getRawSortedPosts();
// delete post.body
const sortedPostsList = sortedFullPosts.map((post) => ({
id: post.id,
data: post.data,
}));
return sortedPostsList;
}
export type Tag = {
name: string;
count: number;
};
export async function getTagList(): Promise<Tag[]> {
const allBlogPosts = await getCollection<"posts">("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const countMap: { [key: string]: number } = {};
allBlogPosts.forEach((post: { data: { tags: string[] } }) => {
post.data.tags.forEach((tag: string) => {
if (!countMap[tag]) countMap[tag] = 0;
countMap[tag]++;
});
});
// sort tags
const keys: string[] = Object.keys(countMap).sort((a, b) => {
return a.toLowerCase().localeCompare(b.toLowerCase());
});
return keys.map((key) => ({ name: key, count: countMap[key] }));
}
export type Category = {
name: string;
count: number;
url: string;
};
export async function getCategoryList(): Promise<Category[]> {
const allBlogPosts = await getCollection<"posts">("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const count: { [key: string]: number } = {};
allBlogPosts.forEach((post: { data: { category: string | null } }) => {
if (!post.data.category) {
const ucKey = i18n(I18nKey.uncategorized);
count[ucKey] = count[ucKey] ? count[ucKey] + 1 : 1;
return;
}
const categoryName =
typeof post.data.category === "string"
? post.data.category.trim()
: String(post.data.category).trim();
count[categoryName] = count[categoryName] ? count[categoryName] + 1 : 1;
});
const lst = Object.keys(count).sort((a, b) => {
return a.toLowerCase().localeCompare(b.toLowerCase());
});
const ret: Category[] = [];
for (const c of lst) {
ret.push({
name: c,
count: count[c],
url: getCategoryUrl(c),
});
}
return ret;
}

24
src/utils/date.ts Normal file
View File

@@ -0,0 +1,24 @@
import { langToLocaleMap } from "@i18n/language";
import { getDefaultLanguage } from "./language";
export function formatDateToYYYYMMDD(date: Date): string {
return date.toISOString().substring(0, 10);
}
// 国际化日期格式化函数
export function formatDateI18n(dateString: string): string {
const date = new Date(dateString);
const lang = getDefaultLanguage();
// 根据语言设置不同的日期格式
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
};
// 使用统一的语言配置获取 locale
const locale = langToLocaleMap[lang] || "en-US";
return date.toLocaleDateString(locale, options);
}

25
src/utils/diary.ts Normal file
View File

@@ -0,0 +1,25 @@
// Diary data configuration file
// Used to manage data for the diary display page
const diaryModules = import.meta.glob('../content/diary/*.json', { eager: true });
export interface Moment {
id: string;
content: string;
date: string;
images?: string[];
}
export const moments: Moment[] = Object.entries(diaryModules).map(([path, mod]: [string, any]) => {
const id = path.split('/').pop()?.replace('.json', '') || '';
const data = mod.default as any;
const moment: Moment = {
id,
...data,
};
return moment;
});
// Sort moments by date in descending order
export const sortedMoments = [...moments].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);

101
src/utils/fancybox.ts Normal file
View File

@@ -0,0 +1,101 @@
let fancyboxSelectors: string[] = [];
let Fancybox: any;
// 图片灯箱按需加载
export async function initFancybox() {
if (typeof document === "undefined") return;
// 相册图片选择器 (只绑定不在 a 标签内的图片,避免与链接绑定冲突)
const albumImagesSelector = ".custom-md img:not(a *), #post-cover img:not(a *), .moment-images img:not(a *), .photo-gallery img:not(a *)";
// 相册链接选择器
const albumLinksSelector = ".moment-images a[data-fancybox], .photo-gallery a[data-fancybox]";
// 单张图片选择器
const singleFancyboxSelector = "[data-fancybox]:not(.moment-images a):not(.photo-gallery a)";
// 检查是否有图片需要绑定
const hasImages =
document.querySelector(albumImagesSelector) ||
document.querySelector(albumLinksSelector) ||
document.querySelector(singleFancyboxSelector);
if (!hasImages) return;
// 检查是否已初始化 Fancybox
if (!Fancybox) {
const mod = await import("@fancyapps/ui");
Fancybox = mod.Fancybox;
await import("@fancyapps/ui/dist/fancybox/fancybox.css");
}
if (fancyboxSelectors.length > 0) {
return; // 已经初始化,直接返回
}
// 公共配置
const commonConfig = {
Thumbs: {
autoStart: true,
showOnStart: "yes"
},
Toolbar: {
display: {
left: ["infobar"],
middle: [
"zoomIn",
"zoomOut",
"toggle1to1",
"rotateCCW",
"rotateCW",
"flipX",
"flipY",
],
right: ["slideshow", "thumbs", "close"],
},
},
animated: true,
dragToClose: true,
keyboard: {
Escape: "close",
Delete: "close",
Backspace: "close",
PageUp: "next",
PageDown: "prev",
ArrowUp: "next",
ArrowDown: "prev",
ArrowRight: "next",
ArrowLeft: "prev",
},
fitToView: true,
preload: 3,
infinite: true,
Panzoom: {
maxScale: 3,
minScale: 1
},
caption: false,
};
// 绑定相册/文章图片
Fancybox.bind(albumImagesSelector, {
...commonConfig,
groupAll: true,
Carousel: {
transition: "slide",
preload: 2,
},
});
fancyboxSelectors.push(albumImagesSelector);
// 绑定相册链接
Fancybox.bind(albumLinksSelector, {
...commonConfig,
source: (el: any) => {
return el.getAttribute("data-src") || el.getAttribute("href");
},
});
fancyboxSelectors.push(albumLinksSelector);
// 绑定单独的 fancybox 图片
Fancybox.bind(singleFancyboxSelector, commonConfig);
fancyboxSelectors.push(singleFancyboxSelector);
}
// 清理 Fancybox 实例
export function cleanupFancybox() {
if (!Fancybox) return; // 如果从未加载过,无需清理
fancyboxSelectors.forEach((selector) => {
Fancybox.unbind(selector);
});
fancyboxSelectors = [];
}

18
src/utils/friends.ts Normal file
View File

@@ -0,0 +1,18 @@
// Friends links data configuration file
// Used to manage data for the friends page
const friendModules = import.meta.glob('../content/friends/*.json', { eager: true });
export interface FriendLink {
id: string;
title: string;
imgurl: string;
desc: string;
siteurl: string;
tags?: string[];
}
export const friendsData: FriendLink[] = Object.entries(friendModules).map(([path, mod]: [string, any]) => {
const id = path.split('/').pop()?.replace('.json', '') || '';
const data = mod.default;
return { id, ...data } as FriendLink;
});

41
src/utils/hue.ts Normal file
View File

@@ -0,0 +1,41 @@
import { siteConfig } from "@/config";
// Function to set hue
export function setHue(hue: number): void {
if (typeof localStorage !== "undefined") {
localStorage.setItem("hue", String(hue));
}
if (typeof document !== "undefined") {
const r = document.querySelector(":root") as HTMLElement;
if (!r) {
return;
}
r.style.setProperty("--hue", String(hue));
}
}
// Function to get default hue from config-carrier dataset
export function getDefaultHue(): number {
const fallback = siteConfig.themeColor.hue.toString();
if (typeof document !== "undefined") {
const configCarrier = document.getElementById("config-carrier");
return Number.parseInt(configCarrier?.dataset.hue || fallback);
}
return Number.parseInt(fallback);
}
// Function to get hue from local storage or default
export function getHue(): number {
if (typeof localStorage !== "undefined") {
const stored = localStorage.getItem("hue");
return stored ? Number.parseInt(stored) : getDefaultHue();
}
return getDefaultHue();
}
// Function to initialize hue from local storage or default
export function initHue(): void {
const hue = getHue();
setHue(hue);
}

210
src/utils/language.ts Normal file
View File

@@ -0,0 +1,210 @@
import {
type SupportedLanguage,
SUPPORTED_LANGUAGES,
langToTranslateMap,
translateToLangMap,
LANGUAGE_CONFIG,
} from "@i18n/language";
import {
siteConfig,
} from "@/config";
// 重新导出以保持向后兼容
export { SUPPORTED_LANGUAGES, type SupportedLanguage, langToTranslateMap, translateToLangMap };
// 语言存储键
const LANG_STORAGE_KEY = "selected-language";
// 存储语言设置
export function setStoredLanguage(lang: string): void {
if (typeof localStorage !== "undefined") {
localStorage.setItem(LANG_STORAGE_KEY, lang);
}
}
// 获取存储的语言设置
export function getStoredLanguage(): string | null {
if (typeof localStorage !== "undefined") {
return localStorage.getItem(LANG_STORAGE_KEY);
}
return null;
}
// 获取默认语言配置
export function getDefaultLanguage(): string {
const fallback = siteConfig.lang;
if (typeof document !== "undefined") {
const configCarrier = document.getElementById("config-carrier");
return configCarrier?.dataset.lang || fallback;
}
return fallback;
}
// 将配置文件的语言代码转换为翻译服务的语言代码
export function getTranslateLanguageFromConfig(configLang: string): string {
return langToTranslateMap[configLang] || "chinese_simplified";
}
// 获取解析后的站点语言代码
export function getResolvedSiteLang(): SupportedLanguage {
const configLang = getDefaultLanguage() as any;
if (SUPPORTED_LANGUAGES.includes(configLang)) {
return configLang as SupportedLanguage;
}
// 如果 siteConfig.lang 不合规,则使用浏览器检测到的语言
return detectBrowserLanguage();
}
// 将翻译服务的语言代码转换为配置文件的语言代码
export function getConfigLanguageFromTranslate(translateLang: string): string {
return translateToLangMap[translateLang] || "zh";
}
// 获取语言的显示名称
export function getLanguageDisplayName(langCode: string): string {
// 先尝试作为配置语言代码查找
if (langCode in LANGUAGE_CONFIG) {
return LANGUAGE_CONFIG[langCode as SupportedLanguage].displayName;
}
// 尝试作为翻译服务代码查找
const configLang = translateToLangMap[langCode];
if (configLang && configLang in LANGUAGE_CONFIG) {
return LANGUAGE_CONFIG[configLang as SupportedLanguage].displayName;
}
// 如果都找不到,返回原始代码
return langCode;
}
// 检测浏览器语言并返回支持的语言代码
export function detectBrowserLanguage(fallbackLang: SupportedLanguage = "en"): SupportedLanguage {
// 服务端渲染时返回备用语言
if (typeof window === "undefined" || typeof navigator === "undefined") {
return fallbackLang;
}
// 获取浏览器语言列表
const browserLangs = navigator.languages || [navigator.language];
// 遍历浏览器语言列表,找到第一个支持的语言
for (const browserLang of browserLangs) {
// 提取主语言代码(例如:'zh-CN' -> 'zh', 'en-US' -> 'en'
const langCode = browserLang.toLowerCase().split("-")[0];
// 检查是否在支持的语言列表中
if (SUPPORTED_LANGUAGES.includes(langCode as SupportedLanguage)) {
return langCode as SupportedLanguage;
}
}
// 如果没有找到支持的语言,返回备用语言
return fallbackLang;
}
// 获取当前站点语言(优先使用缓存,其次是配置语言,最后是浏览器检测)
export function getSiteLanguage(configLang?: string): string {
// 优先从缓存读取
const storedLang = getStoredLanguage();
if (storedLang) return storedLang;
// 其次使用传入的配置语言或从 carrier 获取的默认语言
const defaultLang = configLang || getDefaultLanguage();
if (SUPPORTED_LANGUAGES.includes(defaultLang as SupportedLanguage)) {
return langToTranslateMap[defaultLang];
}
// 最后自动检测浏览器语言并转换为翻译服务代码
const browserLang = detectBrowserLanguage();
return langToTranslateMap[browserLang];
}
// 初始化翻译功能
export function initTranslateService(): void {
if (typeof window === "undefined" || !siteConfig.translate?.enable) return;
// 检查 translate.js 是否已加载
const translate = (window as any).translate;
if (!translate || (window as any).translateInitialized) return;
// 配置 translate.js
if (siteConfig.translate.service) {
translate.service.use(siteConfig.translate.service);
}
// 设置源语言(始终是网站渲染的语言)
const resolvedLang = getResolvedSiteLang();
const sourceLang = getTranslateLanguageFromConfig(resolvedLang);
translate.language.setLocal(sourceLang);
// 获取目标语言(缓存 -> 配置 -> 浏览器)
const targetLang = getSiteLanguage(resolvedLang);
// 如果目标语言不同于源语言,则设置目标语言
if (targetLang && targetLang !== sourceLang) {
translate.to = targetLang;
}
// 自动识别语言
if (siteConfig.translate.autoDiscriminate) {
translate.setAutoDiscriminateLocalLanguage();
}
// 设置忽略项
if (siteConfig.translate.ignoreClasses) {
siteConfig.translate.ignoreClasses.forEach((className: string) => {
translate.ignore.class.push(className);
});
}
if (siteConfig.translate.ignoreTags) {
siteConfig.translate.ignoreTags.forEach((tagName: string) => {
translate.ignore.tag.push(tagName);
});
}
// UI 配置
if (siteConfig.translate.showSelectTag === false) {
translate.selectLanguageTag.show = false;
}
// 接管存储逻辑:使用自定义缓存并同步到 translate.js
translate.storage.set = function (key: string, value: string) {
if (key === "to") { // translate.js 使用 "to" 存储目标语言
setStoredLanguage(value);
} else {
localStorage.setItem(key, value);
}
};
translate.storage.get = function (key: string) {
if (key === "to") {
return getStoredLanguage();
}
return localStorage.getItem(key);
};
// 启动翻译监听
translate.listener.start();
(window as any).translateInitialized = true;
// 如果目标语言存在且不是源语言,执行翻译
// 强制执行一次 execute 以确保初始化时应用翻译
if (translate.to && translate.to !== translate.language.getLocal()) {
// 延迟一小段时间执行,确保 DOM 完全就绪
setTimeout(() => {
translate.execute();
}, 10);
} else if (translate.to === translate.language.getLocal()) {
// 如果目标语言就是源语言,确保处于未翻译状态
// 有时插件可能会残留之前的翻译状态
translate.reset();
}
}
// 加载并初始化翻译功能
export async function loadAndInitTranslate(): Promise<void> {
if (typeof window === "undefined" || !siteConfig.translate?.enable) return;
try {
// 检查是否已经加载
if (!(window as any).translate) {
// 使用动态导入Vite 会自动处理代码分割
await import("@/plugins/translate");
(window as any).translateScriptLoaded = true;
}
// 初始化服务
initTranslateService();
} catch (error) {
console.error('Failed to load or init translate.js:', error);
}
}
// 切换语言
export function toggleLanguage(langCode: string): void {
const translate = (window as any).translate;
if (!translate) return;
// 切换语言
translate.changeLanguage(langCode);
setStoredLanguage(langCode);
}

117
src/utils/markdown.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Markdown 相关交互逻辑
* 包括代码块复制和折叠功能
* 使用事件委托,确保在 Swup 无刷新跳转后依然有效
*/
export function initMarkdownActions() {
if (typeof document === "undefined") return;
// 移除旧的监听器(如果有),防止重复绑定
// 注意:由于使用的是匿名函数且通常在页面加载时只运行一次,在 Swup 环境下只要这个脚本在主布局中加载,它就只会运行一次。
document.addEventListener("click", function (e: MouseEvent) {
const target = e.target as Element | null;
if (!target) return;
// 1. 处理复制按钮点击
if (target.classList.contains("copy-btn") || target.closest(".copy-btn")) {
const btn = target.classList.contains("copy-btn") ? target : target.closest(".copy-btn");
if (!btn) return;
const codeEle = btn.parentElement?.querySelector("code");
// 精确的代码提取逻辑
let code = '';
if (codeEle) {
// 获取所有代码行元素
const lineElements = codeEle.querySelectorAll('span.line');
// 对于有行结构的代码块,精确处理每一行
if (lineElements.length > 0) {
const lines: string[] = [];
for (let i = 0; i < lineElements.length; i++) {
const lineElement = lineElements[i];
const lineText = lineElement.textContent || '';
lines.push(lineText);
}
code = lines.join('\n');
} else {
const codeElements = codeEle.querySelectorAll('.code:not(summary *)');
if (codeElements.length > 0) {
const lines: string[] = [];
for (let i = 0; i < codeElements.length; i++) {
const el = codeElements[i];
const lineText = el.textContent || '';
lines.push(lineText);
}
code = lines.join('\n');
} else {
code = codeEle.textContent || '';
}
}
}
// 处理连续空行
code = code.replace(/\n\n\n+/g, function(match) {
const newlineCount = match.length;
const emptyLineCount = newlineCount - 1;
let resultEmptyLines: number;
if (emptyLineCount % 2 === 0) {
resultEmptyLines = emptyLineCount / 2;
} else {
resultEmptyLines = Math.floor((emptyLineCount + 1) / 2);
}
if (resultEmptyLines < 1) resultEmptyLines = 1;
return '\n'.repeat(resultEmptyLines + 1);
});
// 尝试多种复制方法
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (clipboardErr) {
console.warn('Clipboard API 失败,尝试备用方案:', clipboardErr);
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
} catch (execErr) {
console.error('execCommand 也失败了:', execErr);
throw new Error('所有复制方法都失败了');
} finally {
document.body.removeChild(textArea);
}
}
};
// 调用复制函数
copyToClipboard(code).then(() => {
const timeoutId = btn.getAttribute("data-timeout-id");
if (timeoutId) {
clearTimeout(parseInt(timeoutId));
}
btn.classList.add("success");
const newTimeoutId = setTimeout(() => {
btn.classList.remove("success");
}, 1000);
btn.setAttribute("data-timeout-id", newTimeoutId.toString());
}).catch(err => {
console.error('复制失败:', err);
});
}
// 2. 处理折叠按钮点击
if (target.classList.contains("collapse-btn") || target.closest(".collapse-btn")) {
const btn = target.classList.contains("collapse-btn") ? target : target.closest(".collapse-btn");
const codeBlock = btn?.closest(".expressive-code");
if (codeBlock) {
codeBlock.classList.toggle("collapsed");
codeBlock.classList.toggle("expanded");
}
}
});
}

130
src/utils/navigation.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* 导航工具函数
* 提供统一的页面导航功能,支持 Swup 无刷新跳转
*/
import { navbarConfig } from "@/config";
import { LinkPresets } from "@constants/link-presets";
import { type NavbarLink } from "@/types/config";
import { pathsEqual } from "./url";
/**
* 根据当前路径查找其所属的父级页面(如 [...menu].astro 生成的中转页)
* @param currentPath 当前页面的路径
* @returns 如果找到父级页面,则返回该页面的 NavbarLink 对象,否则返回 undefined
*/
export function getParentLink(currentPath: string): NavbarLink | undefined {
// 遍历导航栏中的所有链接
for (const link of navbarConfig.links) {
// 检查是否有子链接且不是 LinkPreset 枚举
if (typeof link !== "number" && link.children && link.children.length > 0) {
// 检查子链接中是否包含当前路径
for (const child of link.children) {
let childLink: NavbarLink;
if (typeof child === "number") {
childLink = LinkPresets[child];
} else {
childLink = child;
}
// 比较路径是否匹配
if (pathsEqual(childLink.url, currentPath)) {
return link;
}
}
}
}
return undefined;
}
/**
* 降级导航函数
* 当 Swup 不可用时使用普通的页面跳转
*/
function fallbackNavigation(
url: string,
options?: {
replace?: boolean;
force?: boolean;
},
): void {
if (typeof window === "undefined") return;
if (options?.replace) {
window.location.replace(url);
} else {
window.location.href = url;
}
}
/**
* 导航到指定页面
* @param url 目标页面URL
* @param options 导航选项
*/
export function navigateToPage(
url: string,
options?: {
replace?: boolean;
force?: boolean;
},
): void {
// 检查 URL 是否有效
if (!url || typeof url !== "string") {
console.warn("navigateToPage: Invalid URL provided");
return;
}
// 如果是外部链接,直接跳转
if (
url.startsWith("http://") ||
url.startsWith("https://") ||
url.startsWith("//")
) {
window.open(url, "_blank");
return;
}
// 如果是锚点链接,滚动到对应位置
if (url.startsWith("#")) {
if (typeof document !== "undefined") {
const element = document.getElementById(url.slice(1));
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}
return;
}
// 检查 Swup 是否可用
if (typeof window !== "undefined" && (window as any).swup) {
try {
// 使用 Swup 进行无刷新跳转
if (options?.replace) {
(window as any).swup.navigate(url, { history: false });
} else {
(window as any).swup.navigate(url);
}
} catch (error) {
console.error("Swup navigation failed:", error);
// 降级到普通跳转
fallbackNavigation(url, options);
}
} else {
// Swup 不可用时的降级处理
fallbackNavigation(url, options);
}
}
/**
* 获取当前页面路径
*/
export function getCurrentPath(): string {
return typeof window !== "undefined" ? window.location.pathname : "";
}
/**
* 检查是否为首页
*/
export function isHomePage(): boolean {
const path = getCurrentPath();
return path === "/" || path === "";
}

393
src/utils/particle.ts Normal file
View File

@@ -0,0 +1,393 @@
import type { ParticleConfig } from "@/types/config";
import { particleConfig } from "@/config";
const BOUNDARY_OFFSET = 100;
// 粒子对象类
class Particle {
x: number;
y: number;
s: number;
r: number;
a: number;
fn: {
x: (x: number, y: number) => number;
y: (x: number, y: number) => number;
r: (r: number) => number;
a: (a: number) => number;
};
idx: number;
img: HTMLImageElement;
limitArray: number[];
config: ParticleConfig;
// 构造函数
constructor(
x: number,
y: number,
s: number,
r: number,
a: number,
fn: {
x: (x: number, y: number) => number;
y: (x: number, y: number) => number;
r: (r: number) => number;
a: (a: number) => number;
},
idx: number,
img: HTMLImageElement,
limitArray: number[],
config: ParticleConfig,
) {
this.x = x;
this.y = y;
this.s = s;
this.r = r;
this.a = a;
this.fn = fn;
this.idx = idx;
this.img = img;
this.limitArray = limitArray;
this.config = config;
}
// 绘制粒子
draw(cxt: CanvasRenderingContext2D) {
cxt.save();
cxt.translate(this.x, this.y);
cxt.rotate(this.r);
cxt.globalAlpha = this.a;
cxt.drawImage(this.img, 0, 0, 40 * this.s, 40 * this.s);
cxt.restore();
}
// 更新粒子位置和状态
update() {
this.x = this.fn.x(this.x, this.y);
this.y = this.fn.y(this.y, this.y);
this.r = this.fn.r(this.r);
this.a = this.fn.a(this.a);
// 如果粒子越界或完全透明,重新调整位置
if (
this.x > window.innerWidth ||
this.x < 0 ||
this.y > window.innerHeight + BOUNDARY_OFFSET ||
this.y < -BOUNDARY_OFFSET || // 从顶部消失
this.a <= 0
) {
// 如果粒子不做限制
if (this.limitArray[this.idx] === -1) {
this.resetPosition();
}
// 否则粒子有限制
else {
if (this.limitArray[this.idx] > 0) {
this.resetPosition();
this.limitArray[this.idx]--;
}
}
}
}
// 重置粒子位置
private resetPosition() {
this.r = getRandom("fnr", this.config);
if (Math.random() > 0.4) {
this.x = getRandom("x", this.config);
this.y = window.innerHeight + Math.random() * BOUNDARY_OFFSET; // 从屏幕底部开始
this.s = getRandom("s", this.config);
this.r = getRandom("r", this.config);
this.a = getRandom('a', this.config);
} else {
this.x = window.innerWidth;
this.y = getRandom("y", this.config);
this.s = getRandom("s", this.config);
this.r = getRandom("r", this.config);
this.a = getRandom('a', this.config);
}
}
}
// 粒子列表类
class ParticleList {
list: Particle[];
// 构造函数
constructor() {
this.list = [];
}
// 添加粒子
push(particle: Particle) {
this.list.push(particle);
}
// 更新所有粒子
update() {
for (let i = 0, len = this.list.length; i < len; i++) {
this.list[i].update();
}
}
// 绘制所有粒子
draw(cxt: CanvasRenderingContext2D) {
for (let i = 0, len = this.list.length; i < len; i++) {
this.list[i].draw(cxt);
}
}
// 获取指定索引的粒子
get(i: number) {
return this.list[i];
}
// 获取粒子数量
size() {
return this.list.length;
}
}
// 获取随机值的函数
function getRandom(option: string, config: ParticleConfig): any {
let ret: any;
let random: number;
// 根据选项获取随机值
switch (option) {
case "x":
ret = Math.random() * window.innerWidth;
break;
case "y":
ret = window.innerHeight + Math.random() * BOUNDARY_OFFSET; // 初始位置在屏幕底部
break;
case "s":
ret =
config.size.min + Math.random() * (config.size.max - config.size.min);
break;
case "r":
ret = Math.random() * 6;
break;
case "a":
ret = config.opacity.min + Math.random() * (config.opacity.max - config.opacity.min);
break;
case "fnx":
random = config.speed.horizontal.min + Math.random() * (config.speed.horizontal.max - config.speed.horizontal.min); // x方向保持较小的随机运动
ret = function (x: number, y: number) {
return x + random;
};
break;
case "fny":
random = -(config.speed.vertical.min + Math.random() * (config.speed.vertical.max - config.speed.vertical.min)); // y方向随机向上运动
ret = function (x: number, y: number) {
return y + random;
};
break;
case "fnr":
ret = function (r: number) {
return r + config.speed.rotation * 0.1;
};
break;
case "fna":
ret = function (alpha: number) {
return alpha - config.speed.fadeSpeed * 0.01;
};
break;
}
return ret;
}
// 粒子管理器类
export class ParticleManager {
private config: ParticleConfig;
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private particleList: ParticleList | null = null;
private animationId: number | null = null;
private img: HTMLImageElement | null = null;
private isRunning = false;
// 构造函数
constructor(config: ParticleConfig) {
this.config = config;
}
// 初始化粒子特效
async init(): Promise<void> {
if (typeof document === "undefined" || !this.config.enable || this.isRunning) {
return;
}
// 创建图片对象
this.img = new Image();
this.img.src = "/assets/images/particle.png"; // 使用粒子图片
// 等待图片加载完成
await new Promise<void>((resolve, reject) => {
if (this.img) {
this.img.onload = () => resolve();
this.img.onerror = () =>
reject(new Error("Failed to load particle image"));
}
});
// 创建画布
this.createCanvas();
// 创建粒子列表
this.createParticleList();
// 启动动画循环
this.startAnimation();
// 标记为运行中
this.isRunning = true;
}
// 创建画布
private createCanvas(): void {
if (typeof document === "undefined") return;
this.canvas = document.createElement("canvas");
this.canvas.height = window.innerHeight;
this.canvas.width = window.innerWidth;
this.canvas.setAttribute(
"style",
`position: fixed; left: 0; top: 0; pointer-events: none; z-index: ${this.config.zIndex};`,
);
this.canvas.setAttribute("id", "canvas_particle");
document.body.appendChild(this.canvas);
this.ctx = this.canvas.getContext("2d");
// 监听窗口大小变化
if (typeof window !== "undefined") {
window.addEventListener("resize", this.handleResize.bind(this));
}
}
// 创建粒子列表
private createParticleList(): void {
if (!this.img || !this.ctx) return;
this.particleList = new ParticleList();
const limitArray = new Array(this.config.particleNum).fill(
this.config.limitTimes,
);
for (let i = 0; i < this.config.particleNum; i++) {
const randomX = getRandom("x", this.config);
const randomY = getRandom("y", this.config);
const randomS = getRandom("s", this.config);
const randomR = getRandom("r", this.config);
const randomA = getRandom("a", this.config);
const randomFnx = getRandom("fnx", this.config);
const randomFny = getRandom("fny", this.config);
const randomFnR = getRandom("fnr", this.config);
const randomFnA = getRandom("fna", this.config);
const particle = new Particle(
randomX,
randomY,
randomS,
randomR,
randomA,
{
x: randomFnx,
y: randomFny,
r: randomFnR,
a: randomFnA,
},
i,
this.img,
limitArray,
this.config,
);
particle.draw(this.ctx);
this.particleList.push(particle);
}
}
// 开始动画
private startAnimation(): void {
if (!this.ctx || !this.canvas || !this.particleList) return;
const animate = () => {
if (!this.ctx || !this.canvas || !this.particleList) return;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.particleList.update();
this.particleList.draw(this.ctx);
this.animationId = requestAnimationFrame(animate);
};
this.animationId = requestAnimationFrame(animate);
}
// 处理窗口大小变化
private handleResize(): void {
if (this.canvas) {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
}
// 停止粒子特效
stop(): void {
if (this.animationId && typeof window !== "undefined") {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.canvas && typeof document !== "undefined") {
document.body.removeChild(this.canvas);
this.canvas = null;
}
if (typeof window !== "undefined") {
window.removeEventListener("resize", this.handleResize.bind(this));
}
this.isRunning = false;
}
// 切换粒子特效
toggle(): void {
if (this.isRunning) {
this.stop();
} else {
this.init();
}
}
// 更新配置
updateConfig(newConfig: ParticleConfig): void {
const wasRunning = this.isRunning;
if (wasRunning) {
this.stop();
}
this.config = newConfig;
if (wasRunning && newConfig.enable) {
this.init();
}
}
// 获取运行状态
getIsRunning(): boolean {
return this.isRunning;
}
}
// 创建全局粒子管理器实例
let globalParticleManager: ParticleManager | null = null;
// 初始化粒子特效
export function initParticle(config: ParticleConfig): void {
if (globalParticleManager) {
globalParticleManager.updateConfig(config);
} else {
globalParticleManager = new ParticleManager(config);
if (config.enable) {
globalParticleManager.init();
}
}
}
// 切换粒子特效
export function toggleParticle(): void {
if (globalParticleManager) {
globalParticleManager.toggle();
}
}
// 停止粒子特效
export function stopParticle(): void {
if (globalParticleManager) {
globalParticleManager.stop();
globalParticleManager = null;
}
}
// 获取粒子特效运行状态
export function getParticleStatus(): boolean {
return globalParticleManager ? globalParticleManager.getIsRunning() : false;
}
// 包含配置检查、重复初始化检查以及页面加载状态处理
export function setupParticleEffects(): void {
if (typeof window === "undefined") return;
// 初始化函数
const init = () => {
if (!particleConfig || !particleConfig.enable) return;
if ((window as any).particleInitialized) return;
initParticle(particleConfig);
(window as any).particleInitialized = true;
};
// 处理页面加载状态
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}

71
src/utils/projects.ts Normal file
View File

@@ -0,0 +1,71 @@
// Project data configuration file
// Used to manage data for the project display page
const projectModules = import.meta.glob('../content/projects/*.json', { eager: true });
export interface Project {
id: string;
title: string;
description: string;
image: string;
category: "library" | "ai" | "software" | "website" | "game";
techStack: string[];
status: "completed" | "in-progress" | "planned";
demoUrl?: string;
sourceUrl?: string;
startDate: string;
endDate?: string;
featured?: boolean;
tags?: string[];
}
export const projectsData: Project[] = Object.entries(projectModules).map(([path, mod]: [string, any]) => {
const id = path.split('/').pop()?.replace('.json', '') || '';
const data = mod.default as any;
const project: Project = {
id,
...data,
demoUrl: data.demoUrl ?? data.liveDemo,
sourceUrl: data.sourceUrl ?? data.sourceCode,
};
return project;
});
// Get project statistics
export const getProjectStats = () => {
const total = projectsData.length;
const completed = projectsData.filter((p) => p.status === "completed").length;
const inProgress = projectsData.filter(
(p) => p.status === "in-progress",
).length;
const planned = projectsData.filter((p) => p.status === "planned").length;
return {
total,
byStatus: {
completed,
inProgress,
planned,
},
};
};
// Get projects by category
export const getProjectsByCategory = (category?: string) => {
if (!category || category === "all") {
return projectsData;
}
return projectsData.filter((p) => p.category === category);
};
// Get featured projects
export const getFeaturedProjects = () => {
return projectsData.filter((p) => p.featured);
};
// Get all tech stacks
export const getAllTechStack = () => {
const techSet = new Set<string>();
projectsData.forEach((project) => {
project.techStack.forEach((tech) => techSet.add(tech));
});
return Array.from(techSet).sort();
};

76
src/utils/skills.ts Normal file
View File

@@ -0,0 +1,76 @@
// Skill data configuration file
// Used to manage data for the skill display page
const skillModules = import.meta.glob('../content/skills/*.json', { eager: true });
export interface Skill {
id: string;
name: string;
description: string;
icon: string; // Iconify icon name
category: "ai" | "backend" | "client" | "frontend" | "database" | "engines" | "tools" | "others";
level: "beginner" | "intermediate" | "advanced" | "expert";
experience: {
years: number;
months: number;
};
projects?: string[]; // Related project IDs
certifications?: string[];
color?: string; // Skill card theme color
}
export const skillsData: Skill[] = Object.entries(skillModules).map(([path, mod]: [string, any]) => {
const id = path.split('/').pop()?.replace('.json', '') || '';
const data = mod.default;
return { id, ...data } as Skill;
});
// Get skill statistics
export const getSkillStats = () => {
const total = skillsData.length;
const byLevel = {
beginner: skillsData.filter((s) => s.level === "beginner").length,
intermediate: skillsData.filter((s) => s.level === "intermediate").length,
advanced: skillsData.filter((s) => s.level === "advanced").length,
expert: skillsData.filter((s) => s.level === "expert").length,
};
const byCategory = {
ai: skillsData.filter((s) => s.category === "ai").length,
backend: skillsData.filter((s) => s.category === "backend").length,
client: skillsData.filter((s) => s.category === "client").length,
frontend: skillsData.filter((s) => s.category === "frontend").length,
database: skillsData.filter((s) => s.category === "database").length,
tools: skillsData.filter((s) => s.category === "tools").length,
engines: skillsData.filter((s) => s.category === "engines").length,
others: skillsData.filter((s) => s.category === "others").length,
};
return { total, byLevel, byCategory };
};
// Get skills by category
export const getSkillsByCategory = (category?: string) => {
if (!category || category === "all") {
return skillsData;
}
return skillsData.filter((s) => s.category === category);
};
// Get advanced skills
export const getAdvancedSkills = () => {
return skillsData.filter(
(s) => s.level === "advanced" || s.level === "expert",
);
};
// Calculate total years of experience
export const getTotalExperience = () => {
const totalMonths = skillsData.reduce((total, skill) => {
return total + skill.experience.years * 12 + skill.experience.months;
}, 0);
return {
years: Math.floor(totalMonths / 12),
months: totalMonths % 12,
};
};

108
src/utils/theme.ts Normal file
View File

@@ -0,0 +1,108 @@
import {
SYSTEM_MODE,
DARK_MODE,
LIGHT_MODE,
} from "@constants/constants";
import type {
LIGHT_DARK_MODE,
} from "@/types/config";
import {
siteConfig,
} from "@/config";
// Function to apply theme to document
export function applyThemeToDocument(theme: LIGHT_DARK_MODE, force = false) {
if (typeof document === "undefined") return;
// 获取当前主题状态的完整信息
const currentIsDark = document.documentElement.classList.contains("dark");
const currentTheme = document.documentElement.getAttribute("data-theme");
// 计算目标主题状态
let targetIsDark: boolean;
switch (theme) {
case LIGHT_MODE:
targetIsDark = false;
break;
case DARK_MODE:
targetIsDark = true;
break;
case SYSTEM_MODE:
targetIsDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
break;
default:
targetIsDark = currentIsDark; // fallback to current mode if theme is unknown
break;
}
// 检测是否真的需要主题切换
const needsThemeChange = currentIsDark !== targetIsDark;
const targetTheme = targetIsDark ? "github-dark" : "github-light";
const needsCodeThemeUpdate = currentTheme !== targetTheme;
// 如果既不需要主题切换也不需要代码主题更新且不是强制更新,直接返回
if (!force && !needsThemeChange && !needsCodeThemeUpdate) {
return;
}
// 只在需要主题切换时添加过渡保护
if (needsThemeChange) {
document.documentElement.classList.add("is-theme-transitioning");
}
// 使用 requestAnimationFrame 确保在下一帧执行,避免闪屏
requestAnimationFrame(() => {
// 应用主题变化
if (needsThemeChange) {
if (targetIsDark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
// Set the theme for Expressive Code based on current mode
document.documentElement.setAttribute("data-theme", targetTheme);
// 在下一帧快速移除保护类使用微任务确保DOM更新完成
if (needsThemeChange) {
// 使用 requestAnimationFrame 确保在下一帧移除过渡保护类
requestAnimationFrame(() => {
document.documentElement.classList.remove("is-theme-transitioning");
});
}
});
}
// Function to set theme
export function setTheme(theme: LIGHT_DARK_MODE): void {
if (typeof localStorage !== "undefined") {
localStorage.setItem("theme", theme);
}
applyThemeToDocument(theme);
}
// Function to get default theme from config-carrier
export function getDefaultTheme(): LIGHT_DARK_MODE {
const fallback = siteConfig.defaultTheme;
if (typeof document !== "undefined") {
const configCarrier = document.getElementById("config-carrier");
return (configCarrier?.dataset.theme as LIGHT_DARK_MODE) || fallback;
}
return fallback;
}
// Function to get stored theme from local storage or default
export function getStoredTheme(): LIGHT_DARK_MODE {
if (typeof localStorage !== "undefined") {
return (localStorage.getItem("theme") as LIGHT_DARK_MODE) || getDefaultTheme();
}
return getDefaultTheme();
}
// Function to initialize theme from local storage or default
export function initTheme(): void {
if (typeof window === "undefined") return;
const storedTheme = getStoredTheme();
applyThemeToDocument(storedTheme, true);
// 监听系统主题变化
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
const currentStored = getStoredTheme();
if (currentStored === SYSTEM_MODE) {
applyThemeToDocument(SYSTEM_MODE);
}
});
}

96
src/utils/timeline.ts Normal file
View File

@@ -0,0 +1,96 @@
// Timeline data configuration file
// Used to manage data for the timeline page
const timelineModules = import.meta.glob('../content/timeline/*.json', { eager: true });
export interface TimelineItem {
id: string;
title: string;
description: string;
type: "education" | "work" | "project" | "achievement";
startDate: string;
endDate?: string; // If empty, it means current
location?: string;
organization?: string;
position?: string;
skills?: string[];
achievements?: string[];
links?: {
name: string;
url: string;
type: "certificate" | "project" | "other";
}[];
icon?: string; // Iconify icon name
color?: string;
featured?: boolean;
}
export const timelineData: TimelineItem[] = Object.entries(timelineModules).map(([path, mod]: [string, any]) => {
const id = path.split('/').pop()?.replace('.json', '') || '';
const data = mod.default;
return { id, ...data } as TimelineItem;
});
// Get timeline statistics
export const getTimelineStats = () => {
const total = timelineData.length;
const byType = {
education: timelineData.filter((item) => item.type === "education").length,
work: timelineData.filter((item) => item.type === "work").length,
project: timelineData.filter((item) => item.type === "project").length,
achievement: timelineData.filter((item) => item.type === "achievement")
.length,
};
return { total, byType };
};
// Get timeline items by type
export const getTimelineByType = (type?: string) => {
if (!type || type === "all") {
return timelineData.sort(
(a, b) =>
new Date(b.startDate).getTime() - new Date(a.startDate).getTime(),
);
}
return timelineData
.filter((item) => item.type === type)
.sort(
(a, b) =>
new Date(b.startDate).getTime() - new Date(a.startDate).getTime(),
);
};
// Get featured timeline items
export const getFeaturedTimeline = () => {
return timelineData
.filter((item) => item.featured)
.sort(
(a, b) =>
new Date(b.startDate).getTime() - new Date(a.startDate).getTime(),
);
};
// Get current ongoing items
export const getCurrentItems = () => {
return timelineData.filter((item) => !item.endDate);
};
// Calculate total work experience
export const getTotalWorkExperience = () => {
const workItems = timelineData.filter((item) => item.type === "work");
let totalMonths = 0;
workItems.forEach((item) => {
const startDate = new Date(item.startDate);
const endDate = item.endDate ? new Date(item.endDate) : new Date();
const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
const diffMonths = Math.ceil(diffTime / (1000 * 60 * 60 * 24 * 30));
totalMonths += diffMonths;
});
return {
years: Math.floor(totalMonths / 12),
months: totalMonths % 12,
};
};

76
src/utils/url.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { CollectionEntry } from "astro:content";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
export function pathsEqual(path1: string, path2: string) {
const normalizedPath1 = path1.replace(/^\/|\/$/g, "").toLowerCase();
const normalizedPath2 = path2.replace(/^\/|\/$/g, "").toLowerCase();
return normalizedPath1 === normalizedPath2;
}
function joinUrl(...parts: string[]): string {
const joined = parts.join("/");
return joined.replace(/\/+/g, "/");
}
export function removeFileExtension(id: string): string {
return id.replace(/\.(md|mdx|markdown)$/i, "");
}
export function getPostUrlBySlug(slug: string): string {
// 移除文件扩展名(如 .md, .mdx 等)
const slugWithoutExt = removeFileExtension(slug);
return url(`/posts/${slugWithoutExt}/`);
}
export function getPostUrlByRouteName(routeName: string): string {
// 移除开头的斜杠并确保固定链接在 /posts/ 路径下
const cleanRouteName = routeName.replace(/^\/+/, "");
return url(`/posts/${cleanRouteName}/`);
}
export function getPostUrl(post: CollectionEntry<"posts">): string;
export function getPostUrl(post: { id: string; data: { routeName?: string } }): string;
export function getPostUrl(post: any): string {
// 如果文章有自定义固定链接,优先使用固定链接
if (post.data.routeName) {
return getPostUrlByRouteName(post.data.routeName);
}
// 否则使用默认的 slug 路径
return getPostUrlBySlug(post.id);
}
export function getTagUrl(tag: string): string {
if (!tag) return url("/archive/");
return url(`/archive/?tag=${encodeURIComponent(tag.trim())}`);
}
export function getCategoryUrl(category: string | null): string {
if (
!category ||
category.trim() === "" ||
category.trim().toLowerCase() === i18n(I18nKey.uncategorized).toLowerCase()
)
return url("/archive/?uncategorized=true");
return url(`/archive/?category=${encodeURIComponent(category.trim())}`);
}
export function getDir(path: string): string {
// 移除文件扩展名
const pathWithoutExt = removeFileExtension(path);
const lastSlashIndex = pathWithoutExt.lastIndexOf("/");
if (lastSlashIndex < 0) {
return "/";
}
return pathWithoutExt.substring(0, lastSlashIndex + 1);
}
export function getFileDirFromPath(filePath: string): string {
return filePath.replace(/^src\//, "").replace(/\/[^/]+$/, "");
}
export function url(path: string) {
return joinUrl("", import.meta.env.BASE_URL, path);
}

362
src/utils/wallpaper.ts Normal file
View File

@@ -0,0 +1,362 @@
import {
WALLPAPER_FULLSCREEN,
WALLPAPER_BANNER,
WALLPAPER_NONE,
BANNER_HEIGHT,
MAIN_PANEL_OVERLAPS_BANNER_HEIGHT,
} from "@constants/constants";
import type {
WALLPAPER_MODE,
} from "@/types/config";
import {
siteConfig,
} from "@/config";
// Declare global function types for carousel initializers
declare global {
interface Window {
initBannerCarousel?: () => void;
initFullscreenWallpaperCarousel?: () => void;
initSemifullScrollDetection?: () => void;
bannerCarouselState?: {
currentIndex: number;
lastSwitchTime: number;
};
fullscreenWallpaperState?: {
currentIndex: number;
lastSwitchTime: number;
};
bannerCarouselTimer?: any;
fullscreenWallpaperTimer?: any;
currentBannerCarousel?: HTMLElement | null;
currentFullscreenWallpaperCarousel?: HTMLElement | null;
}
}
// Function to get navbar transparent mode for wallpaper mode
export function getNavbarTransparentModeForWallpaperMode(mode: WALLPAPER_MODE): string {
if (mode === WALLPAPER_FULLSCREEN) {
return siteConfig.wallpaper.fullscreen?.navbar?.transparentMode || "semi";
}
if (mode === WALLPAPER_BANNER) {
return siteConfig.wallpaper.banner?.navbar?.transparentMode || "semifull";
}
return "semi"; // 其他情况使用默认的 semi 模式
}
// Cache for elements
const getElements = () => {
if (typeof document === 'undefined') return {
navbar: null,
bannerWrapper: null,
banner: null,
fullscreenContainer: null,
mainContent: null,
};
return {
navbar: document.getElementById('navbar'),
bannerWrapper: document.getElementById('banner-wrapper'),
banner: document.getElementById('banner'),
fullscreenContainer: document.querySelector('[data-fullscreen-wallpaper]') as HTMLElement,
mainContent: document.querySelector('.absolute.w-full.z-30') as HTMLElement,
};
};
// Helper to safely execute after a delay if mode hasn't changed
function runIfMode(mode: WALLPAPER_MODE, callback: () => void, delay = 600) {
setTimeout(() => {
if (typeof document !== 'undefined' && document.documentElement.getAttribute('data-wallpaper-mode') === mode) {
callback();
}
}, delay);
}
// Function to adjust main content position based on wallpaper mode
function adjustMainContentPosition(mode: WALLPAPER_MODE | 'banner' | 'none' | 'fullscreen') {
const { mainContent } = getElements();
if (!mainContent) return;
// Remove existing position classes
mainContent.classList.remove('no-banner-layout');
// Add new position classes based on mode
switch (mode) {
case WALLPAPER_BANNER:
case 'banner':
// 主内容在banner下方
mainContent.style.top = `calc(${BANNER_HEIGHT}vh - ${MAIN_PANEL_OVERLAPS_BANNER_HEIGHT}rem)`;
break;
case WALLPAPER_FULLSCREEN:
case 'fullscreen':
case WALLPAPER_NONE:
case 'none':
// 主内容从导航栏下方开始
mainContent.classList.add('no-banner-layout');
mainContent.style.top = '5.5rem';
break;
default:
mainContent.style.top = '5.5rem';
break;
}
}
// Function to update navbar transparency based on wallpaper mode
function updateNavbarTransparency(mode: WALLPAPER_MODE) {
const { navbar } = getElements();
if (!navbar) return;
// 根据当前壁纸模式获取透明模式配置
const transparentMode = getNavbarTransparentModeForWallpaperMode(mode);
// 更新导航栏的透明模式属性
navbar.setAttribute('data-transparent-mode', transparentMode);
// 重新初始化半透明模式滚动检测(如果需要)
if (transparentMode === 'semifull' && typeof window.initSemifullScrollDetection === 'function') {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => window.initSemifullScrollDetection!());
} else {
setTimeout(() => window.initSemifullScrollDetection!(), 0);
}
}
}
// Helper to initialize banner elements
function initBannerElements(banner: HTMLElement | null) {
if (!banner) return;
banner.classList.remove('opacity-0');
banner.classList.add('opacity-100');
// Handle mobile banner
const mobileBanner = document.querySelector('.block.md\\:hidden[alt="Mobile banner"]');
if (mobileBanner) {
mobileBanner.classList.remove('opacity-0');
mobileBanner.classList.add('opacity-100');
}
}
// Function to show banner mode wallpaper
function showBannerMode() {
const { bannerWrapper, fullscreenContainer, banner } = getElements();
// 隐藏全屏壁纸通过CSS类控制
if (fullscreenContainer) {
fullscreenContainer.style.opacity = '0';
runIfMode(WALLPAPER_BANNER, () => {
fullscreenContainer.classList.add('hidden');
});
}
// 显示banner
if (!bannerWrapper) {
requestAnimationFrame(showBannerMode);
return;
}
const isAlreadyVisible = typeof document !== 'undefined' && !bannerWrapper.classList.contains('hidden') && !document.documentElement.classList.contains('banner-hiding');
if (!isAlreadyVisible && typeof document !== 'undefined') {
// 如果正在隐藏中,先移除隐藏类
document.documentElement.classList.remove('banner-hiding');
// 添加过渡类到 html
document.documentElement.classList.add('banner-transitioning');
// 移除 hidden
bannerWrapper.classList.remove('hidden');
// 触发重绘
void bannerWrapper.offsetHeight;
// 移除过渡类
document.documentElement.classList.remove('banner-transitioning');
// 添加显示动画类
document.documentElement.classList.add('show-banner-animation');
setTimeout(() => {
document.documentElement.classList.remove('show-banner-animation');
}, 1200);
}
// 确保banner可见
bannerWrapper.classList.remove('opacity-0');
bannerWrapper.classList.add('opacity-100');
// Initialize carousel or static banner
if (typeof window.initBannerCarousel === 'function') {
window.initBannerCarousel();
} else {
setTimeout(() => {
initBannerElements(banner);
}, 100);
}
}
// Function to show fullscreen mode wallpaper
function showFullscreenMode() {
const { bannerWrapper, fullscreenContainer } = getElements();
// 显示全屏
if (!fullscreenContainer) {
requestAnimationFrame(showFullscreenMode);
return;
}
fullscreenContainer.classList.remove('hidden');
void fullscreenContainer.offsetHeight;
fullscreenContainer.style.opacity = siteConfig.wallpaper.fullscreen?.opacity?.toString() || '0.8';
// 隐藏banner
if (bannerWrapper) {
if (typeof document !== 'undefined' && document.documentElement.classList.contains('banner-hiding')) {
runIfMode(WALLPAPER_FULLSCREEN, () => {
bannerWrapper.classList.add('hidden');
});
} else {
bannerWrapper.classList.add('hidden');
}
}
}
// Function to show none mode wallpaper
function showNoneMode() {
const { bannerWrapper, fullscreenContainer } = getElements();
// 隐藏banner
if (bannerWrapper) {
bannerWrapper.classList.add('hidden');
}
// 隐藏全屏
if (fullscreenContainer) {
fullscreenContainer.style.opacity = '0';
runIfMode(WALLPAPER_NONE, () => {
fullscreenContainer.classList.add('hidden');
});
}
}
// Function to reinitialize components based on wallpaper mode
function reinitializeComponents(mode: WALLPAPER_MODE) {
if (mode === WALLPAPER_BANNER) {
setTimeout(() => {
initBannerElements(getElements().banner);
}, 100);
}
}
// Function to apply wallpaper mode to document
export function applyWallpaperModeToDocument(mode: WALLPAPER_MODE, force = false) {
if (typeof document === 'undefined') return;
// 获取当前的壁纸模式
const currentMode = document.documentElement.getAttribute('data-wallpaper-mode') as WALLPAPER_MODE;
// 如果模式没有变化且不是强制更新,直接返回
if (!force && currentMode === mode) {
return;
}
// 更新数据属性
document.documentElement.setAttribute('data-wallpaper-mode', mode);
// Handle Banner exit transition
if (currentMode === WALLPAPER_BANNER && mode !== WALLPAPER_BANNER) {
document.documentElement.classList.add('banner-hiding');
// 主内容区域开始向上滑动
adjustMainContentPosition(mode);
// 导航栏也立即更新透明度
updateNavbarTransparency(mode);
// 等待过渡动画完成后再执行实际的模式切换
setTimeout(() => {
document.documentElement.classList.remove('banner-hiding');
executeApply();
}, 600);
return;
}
// 如果是初始加载或强制更新,我们可能需要立即执行一些逻辑,或者等待 DOM 就绪
const apply = () => {
executeApply();
};
function executeApply() {
const body = document.body;
if (!body) {
// 如果 body 还没准备好,稍后再试
requestAnimationFrame(executeApply);
return;
}
// 添加过渡保护类
document.documentElement.classList.add('is-wallpaper-transitioning');
// 只有当新模式不需要透明效果时,才移除 wallpaper-transparent
const nextRequiresTransparency = mode === WALLPAPER_BANNER || mode === WALLPAPER_FULLSCREEN;
if (!nextRequiresTransparency) {
// 延迟移除以配合背景过渡动画
setTimeout(() => {
const isStillTransitioning = document.documentElement.classList.contains('is-wallpaper-transitioning');
const currentDataMode = document.documentElement.getAttribute('data-wallpaper-mode');
const isNowTransparentMode = currentDataMode === WALLPAPER_BANNER || currentDataMode === WALLPAPER_FULLSCREEN;
if (!isStillTransitioning || !isNowTransparentMode) {
body.classList.remove('wallpaper-transparent');
}
}, 300);
} else {
body.classList.add('wallpaper-transparent');
}
// 移除 enable-banner由 showBannerMode 重新添加(如果是切换到 Banner 模式)
// 如果是从 Banner 切换走,则在 executeApply 中移除
if (mode !== WALLPAPER_BANNER) {
body.classList.remove('enable-banner');
} else {
body.classList.add('enable-banner');
}
// 根据模式添加相应的CSS类
switch (mode) {
case WALLPAPER_BANNER:
showBannerMode();
break;
case WALLPAPER_FULLSCREEN:
showFullscreenMode();
adjustMainContentTransparency(true);
break;
case WALLPAPER_NONE:
showNoneMode();
adjustMainContentTransparency(false);
break;
}
// 调整主内容位置
adjustMainContentPosition(mode);
// 更新导航栏透明模式
updateNavbarTransparency(mode);
// 重新初始化相关组件
reinitializeComponents(mode);
// 等待过渡动画完成后移除过渡保护类
setTimeout(() => {
document.documentElement.classList.remove('is-wallpaper-transitioning');
}, 600);
}
// 使用 requestAnimationFrame 确保在下一帧执行,避免闪屏
requestAnimationFrame(apply);
}
// Function to adjust main content transparency based on wallpaper mode
function adjustMainContentTransparency(enable: boolean) {
const { mainContent } = getElements();
if (!mainContent) return;
// Add or remove transparent class based on enable flag
if (enable) {
mainContent.classList.add('wallpaper-transparent');
} else {
mainContent.classList.remove('wallpaper-transparent');
}
}
// Function to set wallpaper mode and apply it to document
export function setWallpaperMode(mode: WALLPAPER_MODE): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('wallpaperMode', mode);
}
applyWallpaperModeToDocument(mode);
}
// Function to get default wallpaper mode from config-carrier
export function getDefaultWallpaperMode(): WALLPAPER_MODE {
const fallback = siteConfig.wallpaper.mode;
if (typeof document !== 'undefined') {
const configCarrier = document.getElementById('config-carrier');
return (configCarrier?.dataset.wallpaperMode as WALLPAPER_MODE) || fallback;
}
return fallback;
}
// Function to get stored wallpaper mode from local storage
export function getStoredWallpaperMode(): WALLPAPER_MODE {
if (typeof localStorage !== 'undefined') {
return (localStorage.getItem('wallpaperMode') as WALLPAPER_MODE) || getDefaultWallpaperMode();
}
return getDefaultWallpaperMode();
}
// Function to initialize wallpaper mode on page load
export function initWallpaperMode(): void {
const storedMode = getStoredWallpaperMode();
applyWallpaperModeToDocument(storedMode, true);
}

432
src/utils/widget.ts Normal file
View File

@@ -0,0 +1,432 @@
import type {
WidgetComponentConfig,
WidgetComponentType,
SidebarConfig,
} from "@/types/config";
import { sidebarConfig } from "@/config";
/**
* 组件映射表 - 将组件类型映射到实际的组件路径
*/
export const WIDGET_COMPONENT_MAP = {
profile: "@components/sidebar/profile.astro",
announcement: "@components/sidebar/announcement.astro",
categories: "@components/sidebar/categories.astro",
tags: "@components/sidebar/tags.astro",
toc: "@components/sidebar/toc.astro",
statistics: "@components/sidebar/statistics.astro",
custom: null, // 自定义组件需要在配置中指定路径
} as const;
/**
* 组件管理器类
* 负责管理侧边栏组件的动态加载、排序和渲染
*/
export class WidgetManager {
private config: SidebarConfig;
constructor(config: SidebarConfig = sidebarConfig) {
this.config = config;
}
/**
* 获取配置
*/
getConfig(): SidebarConfig {
return this.config;
}
/**
* 获取指定侧边栏上的组件列表
* @param side 侧边栏位置:'left' | 'right'
*/
getComponentsBySide(side: "left" | "right"): WidgetComponentConfig[] {
return this.config.components[side] || [];
}
/**
* 根据位置获取组件列表
* @param position 组件位置:'top' | 'sticky'
*/
getComponentsByPosition(position: "top" | "sticky"): WidgetComponentConfig[] {
const left = this.getComponentsBySideAndPosition("left", position);
const right = this.getComponentsBySideAndPosition("right", position);
// Note: This might return duplicates if left/right logic overlaps, but used for enabled types check
return [...left, ...right];
}
/**
* 根据侧边栏和位置获取组件列表
* @param side 侧边栏位置:'left' | 'right' | 'middle'
* @param position 组件位置:'top' | 'sticky'
*/
getComponentsBySideAndPosition(
side: "left" | "right" | "middle",
position: "top" | "sticky",
): WidgetComponentConfig[] {
const leftComponents = (this.config.components.left || []).filter(c => c.position === position);
const rightComponents = (this.config.components.right || []).filter(c => c.position === position);
if (side === "left") {
// Left sidebar includes Right components on Tablet (merged)
return [...leftComponents, ...rightComponents];
}
if (side === "right") {
// Right sidebar only shows Right components (Desktop only)
return rightComponents;
}
if (side === "middle") {
// Middle sidebar includes all components
return [...leftComponents, ...rightComponents];
}
return [];
}
/**
* 获取组件的CSS类名
* @param component 组件配置
* @param index 组件在列表中的索引
* @param side 当前渲染的侧边栏位置
*/
getComponentClass(component: WidgetComponentConfig, index: number, side: "left" | "right" | "middle"): string {
const classes: string[] = [];
// 基础响应式隐藏配置 (用户配置的)
if (component.responsive?.hidden) {
component.responsive.hidden.forEach((device) => {
switch (device) {
case "mobile":
classes.push("hidden md:block");
break;
case "tablet":
classes.push("md:hidden lg:block");
break;
case "desktop":
classes.push("lg:hidden");
break;
}
});
}
// 自动布局逻辑
const isFromLeft = (this.config.components.left || []).includes(component);
const isFromRight = (this.config.components.right || []).includes(component);
if (side === "left") {
if (isFromRight && !isFromLeft) {
// 如果是右侧组件在左侧栏渲染(平板模式),则仅在平板显示
classes.push("hidden md:block lg:hidden");
}
// 左侧组件默认显示
}
return classes.join(" ");
}
/**
* 获取组件的内联样式
* @param component 组件配置
* @param index 组件在列表中的索引
*/
getComponentStyle(component: WidgetComponentConfig, index: number): string {
const styles: string[] = [];
// 添加自定义样式
if (component.style) {
styles.push(component.style);
}
return styles.join("; ");
}
/**
* 检查组件是否应该折叠
* @param component 组件配置
* @param itemCount 组件内容项数量
*/
isCollapsed(component: WidgetComponentConfig, itemCount: number): boolean {
if (!component.responsive?.collapseThreshold) {
return false;
}
return itemCount >= component.responsive.collapseThreshold;
}
/**
* 获取组件的路径
* @param componentType 组件类型
*/
getComponentPath(componentType: WidgetComponentType): string | null {
return WIDGET_COMPONENT_MAP[componentType];
}
/**
* 检查指定侧边栏是否具有实际可显示的内容
* @param side 侧边栏位置:'left' | 'right'
* @param headings 页面标题列表,用于判断特殊组件是否显示
*/
hasContentOnSide(side: "left" | "right", headings: any[] = []): boolean {
const components = this.getComponentsBySide(side);
if (components.length === 0) return false;
// 只要有一个组件能显示内容,侧边栏就不是空的
return components.some((component) => {
// TOC 组件只有在有标题时才显示
if (component.type === "toc") {
return headings && headings.length > 0;
}
// 其他组件暂认为始终有内容
return true;
});
}
/**
* 更新组件配置
* @param newConfig 新的配置
*/
updateConfig(newConfig: Partial<SidebarConfig>): void {
this.config = { ...this.config, ...newConfig };
}
/**
* 添加新组件
* @param component 组件配置
* @param side 侧边栏位置
*/
addComponent(component: WidgetComponentConfig, side: "left" | "right"): void {
if (!this.config.components[side]) {
this.config.components[side] = [];
}
this.config.components[side].push(component);
}
/**
* 移除组件
* @param componentType 组件类型
*/
removeComponent(componentType: WidgetComponentType): void {
if (this.config.components.left) {
this.config.components.left = this.config.components.left.filter(
(component) => component.type !== componentType,
);
}
if (this.config.components.right) {
this.config.components.right = this.config.components.right.filter(
(component) => component.type !== componentType,
);
}
}
/**
* 重新排序组件
* @param side 侧边栏
* @param oldIndex 旧索引
* @param newIndex 新索引
*/
reorderComponent(side: "left" | "right", oldIndex: number, newIndex: number): void {
const list = this.config.components[side];
if (!list) return;
if (oldIndex >= 0 && oldIndex < list.length && newIndex >= 0 && newIndex < list.length) {
const [moved] = list.splice(oldIndex, 1);
list.splice(newIndex, 0, moved);
}
}
/**
* 检查组件是否应该在侧边栏中渲染
* @param componentType 组件类型
*/
isSidebarComponent(componentType: WidgetComponentType): boolean {
return true;
}
/**
* 获取页面中的标题列表
* @returns 格式化后的标题数组
*/
getPageHeadings() {
if (typeof document === "undefined") return [];
return Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6"))
.filter((h) => h.id)
.map((h) => ({
depth: parseInt(h.tagName.substring(1)),
slug: h.id,
text: (h.textContent || "").replace(/#+\s*$/, ""),
}));
}
/**
* 获取网格布局相关的类名
* @param headings 页面标题列表
*/
getGridLayout(headings: any[] = []) {
const hasLeftComponents = this.hasContentOnSide("left", headings);
const hasRightComponents = this.hasContentOnSide("right", headings);
const hasAnyComponents = hasLeftComponents || hasRightComponents;
// Desktop: Left if hasLeft, Right if hasRight
const hasLeftSidebar = hasLeftComponents;
const hasRightSidebar = hasRightComponents;
// 动态网格布局类名
const gridCols = `
grid-cols-1
${hasAnyComponents ? "md:grid-cols-[17.5rem_1fr]" : "md:grid-cols-1"}
${
hasLeftSidebar && hasRightSidebar
? "lg:grid-cols-[17.5rem_1fr_17.5rem]"
: hasLeftSidebar
? "lg:grid-cols-[17.5rem_1fr]"
: hasRightSidebar
? "lg:grid-cols-[1fr_17.5rem]"
: "lg:grid-cols-1"
}
`.trim().replace(/\s+/g, " ");
// 左侧侧边栏容器类名
// Mobile: Hidden
// Tablet: Visible if hasAnyComponents (merged)
// Desktop: Visible if hasLeftSidebar
const leftSidebarClass = `
mb-0 col-span-1 hidden
${hasAnyComponents ? "md:block md:max-w-70" : ""}
${hasLeftSidebar ? "lg:block lg:max-w-70 lg:col-start-1 lg:col-end-2 lg:row-start-1 lg:row-end-2" : "lg:hidden"}
`.trim().replace(/\s+/g, " ");
// 右侧侧边栏容器类名
// Mobile: Hidden
// Tablet: Hidden
// Desktop: Visible if hasRightSidebar
const rightSidebarClass = `
mb-0 col-span-1 hidden
md:hidden
${
hasRightSidebar
? hasLeftSidebar
? "lg:block lg:max-w-70 lg:col-start-3 lg:col-end-4 lg:row-start-1 lg:row-end-2"
: "lg:block lg:max-w-70 lg:col-start-2 lg:col-end-3 lg:row-start-1 lg:row-end-2"
: "lg:hidden"
}
`.trim().replace(/\s+/g, " ");
// 移动端 Footer 类名
// Always 1 col on mobile
// 2 cols on tablet if sidebar is present
const mobileFooterClass = `
footer col-span-1 onload-animation-up block lg:hidden transition-swup-fade
${hasAnyComponents ? "md:col-span-2" : "md:col-span-1"}
`.trim().replace(/\s+/g, " ");
// 移动端侧边栏类名
const middleSidebarClass = `
col-span-1 block md:hidden
${!hasAnyComponents ? "hidden" : ""}
`.trim().replace(/\s+/g, " ");
// 主内容区域类名
const mainContentClass = `
overflow-hidden w-full
col-span-1 row-start-1 row-end-2
${hasAnyComponents ? "md:col-start-2 md:col-end-3 md:row-start-1 md:row-end-2" : "md:col-span-1"}
${
hasLeftSidebar && hasRightSidebar
? "lg:col-start-2 lg:col-end-3 lg:row-start-1 lg:row-end-2"
: hasLeftSidebar
? "lg:col-start-2 lg:col-end-3 lg:row-start-1 lg:row-end-2"
: hasRightSidebar
? "lg:col-start-1 lg:col-end-2 lg:row-start-1 lg:row-end-2"
: "lg:col-span-1"
}
`.trim().replace(/\s+/g, " ");
return {
hasLeftSidebar,
hasRightSidebar,
hasAnyComponents,
gridCols,
leftSidebarClass,
rightSidebarClass,
mainContentClass,
mobileFooterClass,
middleSidebarClass,
};
}
}
/**
* 默认组件管理器实例
*/
export const widgetManager = new WidgetManager();
/**
* 工具函数:根据组件类型获取组件配置
* @param componentType 组件类型
*/
export function getComponentConfig(
componentType: WidgetComponentType,
): WidgetComponentConfig | undefined {
const left = widgetManager.getConfig().components.left || [];
const right = widgetManager.getConfig().components.right || [];
return left.find((c) => c.type === componentType) ||
right.find((c) => c.type === componentType);
}
/**
* 工具函数:检查组件是否启用
* @param componentType 组件类型
*/
export function isComponentEnabled(
componentType: WidgetComponentType,
): boolean {
// 默认所有配置中存在的组件都视为启用
return !!getComponentConfig(componentType);
}
/**
* 工具函数:获取所有启用的组件类型
*/
export function getEnabledComponentTypes(): WidgetComponentType[] {
const enabledComponents = widgetManager.getComponentsByPosition("top").concat(
widgetManager.getComponentsByPosition("sticky")
);
return enabledComponents.map((c) => c.type);
}
/**
* 通用的点击外部关闭处理函数
* @param event 鼠标事件
* @param panelId 面板ID
* @param ignoreIds 忽略的元素ID按钮等支持单个ID或ID数组
* @param action 关闭回调
*/
export function onClickOutside(
event: MouseEvent,
panelId: string,
ignoreIds: string | string[],
action: () => void
) {
if (typeof document === "undefined") {
return;
}
const panel = document.getElementById(panelId);
const target = event.target as HTMLElement;
const ids = Array.isArray(ignoreIds) ? ignoreIds : [ignoreIds];
// 如果点击的是忽略元素或其内部元素,则不执行关闭操作
for (const id of ids) {
if (target.closest(`#${id}`)) {
return;
}
}
// 如果面板存在且点击发生在面板外部,则执行关闭操作
if (panel && !panel.contains(target)) {
action();
}
}