mirror of
https://github.com/StepanovPlaton/AboutMe.git
synced 2026-04-06 13:50:50 +04:00
Initial commit
This commit is contained in:
45
src/utils/albums.ts
Normal file
45
src/utils/albums.ts
Normal 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
121
src/utils/content.ts
Normal 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
24
src/utils/date.ts
Normal 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
25
src/utils/diary.ts
Normal 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
101
src/utils/fancybox.ts
Normal 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
18
src/utils/friends.ts
Normal 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
41
src/utils/hue.ts
Normal 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
210
src/utils/language.ts
Normal 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
117
src/utils/markdown.ts
Normal 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
130
src/utils/navigation.ts
Normal 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
393
src/utils/particle.ts
Normal 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
71
src/utils/projects.ts
Normal 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
76
src/utils/skills.ts
Normal 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
108
src/utils/theme.ts
Normal 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
96
src/utils/timeline.ts
Normal 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
76
src/utils/url.ts
Normal 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
362
src/utils/wallpaper.ts
Normal 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
432
src/utils/widget.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user