Initial commit

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

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

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