mirror of
https://github.com/StepanovPlaton/AboutMe.git
synced 2026-04-04 12:50:49 +04:00
Initial commit
This commit is contained in:
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