/** *本项目Variant Switcher 组件 (重构版) * * 核心特性: * - 零依赖:不依赖任何外部 CSS 框架或图标库 * - 轻量化 UI:使用图标入口替代重型控制条 * - 丰富信息:支持标题和描述 * - 全局面板:支持页面级统一管理和跳转 * - 隐形控制:支持快捷键显隐入口 * - 自动全局入口:当有比选组件时自动显示全局入口按钮 */ import React, { useState, useEffect, useCallback, CSSProperties, useRef } from 'react'; import { createPortal } from 'react-dom'; // --- 类型定义 --- export interface VariantItem { /** 唯一标识,若不提供则使用索引 */ key?: string; /** 渲染内容 */ content: React.ReactNode; /** 方案标题 */ title: string; /** 方案一句话描述 */ description: string; /** 方案详细说明文档(Markdown 格式) */ markdown?: string; } export interface VariantAPI { id: string; /** 比选方案的中文名称,用于在全局面板中显示 */ name: string; currentIndex: number; totalVariants: number; isDecided: boolean; variants: VariantItem[]; // 暴露方案详情供全局面板使用 select: (index: number) => void; confirm: () => void; reset: () => void; focus: () => void; // 聚焦到该组件(滚动) } declare global { interface Window { AXHUB_VARIANT_MANAGER?: VariantManager; } } type Listener = () => void; export interface VariantManager { register: (id: string, api: VariantAPI) => void; unregister: (id: string) => void; instances: Record; subscribe: (listener: Listener) => () => void; notify: () => void; setVisibility: (visible: boolean) => void; isVisible: boolean; } export interface VariantSwitcherProps { id?: string; /** 比选方案的中文名称,显示在全局面板中(如"头部设计"、"登录页布局") */ name?: string; /** 方案列表 */ variants: VariantItem[]; defaultIndex?: number; onConfirm?: (index: number, item: VariantItem) => void; onReset?: () => void; style?: CSSProperties; className?: string; } // --- 图标定义 --- const Icons = { Switcher: () => ( ), Check: () => ( ), Close: () => ( ), // 比选图标:两个重叠的卡片,表示多个方案比选 VariantCompare: () => ( {/* 底层卡片 */} {/* 顶层卡片(偏移) */} ), Target: () => ( ), Exit: () => ( ), Doc: () => ( ), Back: () => ( ) }; // --- 主题配置 --- const THEME_COLOR = '#008F5D'; const THEME_COLOR_BG = 'rgba(0, 143, 93, 0.1)'; // --- 内置 Markdown 渲染器(零依赖) --- const MarkdownViewer: React.FC<{ content: string }> = ({ content }) => { const parseInlineStyles = (text: string): React.ReactNode => { // 处理行内代码 `code` const parts = text.split(/(`[^`]+`)/g); return parts.map((part, i) => { if (part.startsWith('`') && part.endsWith('`')) { return ( {part.slice(1, -1)} ); } // 处理加粗 **text** const boldParts = part.split(/(\*\*[^*]+\*\*)/g); return boldParts.map((bp, j) => { if (bp.startsWith('**') && bp.endsWith('**')) { return {bp.slice(2, -2)}; } return bp; }); }); }; const parseLine = (line: string, index: number): React.ReactNode => { // 标题 if (line.startsWith('### ')) { return

{parseInlineStyles(line.slice(4))}

; } if (line.startsWith('## ')) { return

{parseInlineStyles(line.slice(3))}

; } if (line.startsWith('# ')) { return

{parseInlineStyles(line.slice(2))}

; } // 列表 if (line.startsWith('- ')) { return (
  • {parseInlineStyles(line.slice(2))}
  • ); } if (/^\d+\.\s/.test(line)) { const match = line.match(/^(\d+)\.\s(.*)$/); if (match) { return (
  • {parseInlineStyles(match[2])}
  • ); } } // 引用 if (line.startsWith('> ')) { return (
    {parseInlineStyles(line.slice(2))}
    ); } // 空行 if (line.trim() === '') { return
    ; } // 普通段落 return

    {parseInlineStyles(line)}

    ; }; const lines = content.split('\n'); return (
    {lines.map((line, i) => parseLine(line, i))}
    ); }; // --- 全局管理器实现 --- const listeners: Listener[] = []; let globalVisible = true; function initGlobalManager(): VariantManager { if (!window.AXHUB_VARIANT_MANAGER) { window.AXHUB_VARIANT_MANAGER = { instances: {}, isVisible: true, register(id, api) { this.instances[id] = api; this.notify(); }, unregister(id) { delete this.instances[id]; this.notify(); }, subscribe(listener) { listeners.push(listener); return () => { const idx = listeners.indexOf(listener); if (idx > -1) listeners.splice(idx, 1); }; }, notify() { this.isVisible = globalVisible; listeners.forEach(fn => fn()); }, setVisibility(visible) { globalVisible = visible; this.notify(); } }; } return window.AXHUB_VARIANT_MANAGER; } // --- Hooks --- /** 获取所有注册的实例及全局可见性 */ function useVariantManager() { const [state, setState] = useState<{ instances: Record; isVisible: boolean; }>({ instances: {}, isVisible: true }); useEffect(() => { const manager = initGlobalManager(); const update = () => setState({ instances: { ...manager.instances }, isVisible: manager.isVisible }); update(); return manager.subscribe(update); }, []); return state; } // --- 样式定义 --- const STYLES = { container: { position: 'relative' as const, width: '100%', height: '100%', }, triggerBtn: { position: 'absolute' as const, top: '4px', right: '4px', zIndex: 9001, width: '24px', height: '24px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(255, 255, 255, 0.95)', border: '1px solid rgba(0, 0, 0, 0.08)', borderRadius: '2px', color: '#666', cursor: 'pointer', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', transition: 'all 0.2s', }, popover: { position: 'absolute' as const, top: '32px', right: '0px', width: '260px', backgroundColor: '#fff', borderRadius: '2px', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', border: '1px solid rgba(0, 0, 0, 0.08)', padding: '4px', zIndex: 9999, display: 'flex', flexDirection: 'column' as const, gap: '2px', opacity: 0, transform: 'translateY(-4px)', pointerEvents: 'none' as const, transition: 'all 0.15s ease-out', }, popoverVisible: { opacity: 1, transform: 'translateY(0)', pointerEvents: 'auto' as const, }, variantCard: { display: 'flex', flexDirection: 'column' as const, padding: '8px 10px', borderRadius: '0', cursor: 'pointer', border: 'none', transition: 'background 0.2s', textAlign: 'left' as const, background: 'transparent', }, variantCardActive: { background: THEME_COLOR_BG, border: 'none', }, variantTitle: { fontSize: '13px', fontWeight: 500, color: '#333', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', }, variantDesc: { fontSize: '12px', color: '#888', lineHeight: '1.4', }, globalTrigger: { position: 'fixed' as const, bottom: '24px', right: '24px', zIndex: 99999, width: '32px', height: '32px', borderRadius: '16px', backgroundColor: '#fff', color: '#555', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.12)', cursor: 'pointer', border: '1px solid rgba(0,0,0,0.05)', transition: 'transform 0.2s, opacity 0.2s', }, globalPanel: { position: 'fixed' as const, top: 0, right: 0, bottom: 0, width: '300px', backgroundColor: '#fff', boxShadow: '-4px 0 24px rgba(0,0,0,0.08)', zIndex: 100000, padding: '0', display: 'flex', flexDirection: 'column' as const, transform: 'translateX(100%)', transition: 'transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)', }, globalPanelVisible: { transform: 'translateX(0)', }, globalPanelHeader: { padding: '16px', borderBottom: '1px solid #f5f5f5', display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '15px', fontWeight: 600, color: '#333', }, globalPanelContent: { flex: 1, overflowY: 'auto' as const, padding: '16px', }, globalPanelFooter: { padding: '12px 16px', borderTop: '1px solid #f5f5f5', display: 'flex', justifyContent: 'center', }, nodeGroup: { marginBottom: '16px', border: '1px solid #eee', borderRadius: '0', overflow: 'hidden', }, nodeHeader: { padding: '6px 10px', backgroundColor: '#fafafa', borderBottom: '1px solid #eee', fontSize: '12px', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: '#666', }, exitBtn: { display: 'flex', alignItems: 'center', gap: '6px', background: 'none', border: 'none', color: '#999', fontSize: '12px', cursor: 'pointer', padding: '8px', borderRadius: '0', transition: 'all 0.2s', } }; // --- 全局入口组件(单例) --- let globalControlMountRef = { current: false }; /** 全局入口控制组件 */ const GlobalVariantControl: React.FC = () => { const [isPanelOpen, setIsPanelOpen] = useState(false); const [docView, setDocView] = useState<{ title: string; content: string } | null>(null); const { instances: allInstances, isVisible } = useVariantManager(); // 键盘快捷键监听 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === '.') { e.preventDefault(); initGlobalManager().setVisibility(!isVisible); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isVisible]); const instancesList = Object.values(allInstances); // 按 id 字母顺序排序,保持稳定的显示顺序 const sortedInstances = [...instancesList].sort((a, b) => a.id.localeCompare(b.id)); // 如果没有实例或不可见,则不渲染 if (!isVisible || sortedInstances.length === 0) { return null; } return ( <> {/* 全局悬浮球 */} {/* 全局侧边栏面板 */}
    {/* Header */}
    {docView ? ( <> ) : ( <> 方案比选 )}
    {/* Content */}
    {docView ? ( /* 文档视图 - 简洁无风格 */
    {docView.title}
    ) : ( /* 方案列表视图 */ sortedInstances.map(inst => (
    {/* Node Header */}
    {inst.name}
    {/* Variants List */}
    {inst.variants.map((v, idx) => { const isActive = inst.currentIndex === idx; return (
    inst.select(idx)} style={{ cursor: 'pointer' }} >
    {v.title} {isActive && 当前}
    {v.description}
    {/* 文档按钮 */} {v.markdown && ( )}
    ); })}
    )) )}
    {/* Footer: Exit Button - 仅在列表视图显示 */} {!docView && (
    )}
    {/* 遮罩层 */} {isPanelOpen && (
    setIsPanelOpen(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.1)', zIndex: 99999 }} /> )} ); }; // --- 主组件 --- export const VariantSwitcher: React.FC = ({ id: propId, name: propName, variants = [], defaultIndex = 0, onConfirm, onReset, style, className, }) => { const [instanceId] = useState(() => propId || `axhub_vs_${Math.random().toString(36).substr(2, 9)}` ); // 如果没有提供 name,使用 id 作为显示名称 const displayName = propName || instanceId; const containerRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(defaultIndex); const [isDecided, setIsDecided] = useState(false); const [isHovered, setIsHovered] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const { isVisible: globalVisible } = useVariantManager(); // 标记当前组件负责渲染全局入口(单例,只渲染一次) const [isGlobalControlOwner, setIsGlobalControlOwner] = useState(false); useEffect(() => { // 如果还没有组件负责渲染全局入口,则当前组件负责 if (!globalControlMountRef.current) { globalControlMountRef.current = true; setIsGlobalControlOwner(true); } // 组件卸载时,如果当前组件是全局入口的拥有者,则释放 return () => { if (isGlobalControlOwner) { globalControlMountRef.current = false; } }; }, [isGlobalControlOwner]); // --- API Methods --- const select = useCallback((index: number) => { if (index >= 0 && index < variants.length) { setCurrentIndex(index); } }, [variants.length]); const confirm = useCallback(() => { setIsDecided(true); setIsPopoverOpen(false); if (variants[currentIndex]) { onConfirm?.(currentIndex, variants[currentIndex]); } }, [currentIndex, variants, onConfirm]); const reset = useCallback(() => { setIsDecided(false); onReset?.(); }, [onReset]); const focus = useCallback(() => { if (containerRef.current) { containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); setIsHovered(true); setTimeout(() => setIsHovered(false), 2000); } }, []); // --- 注册到全局 --- useEffect(() => { if (variants.length > 0) { const manager = initGlobalManager(); const api: VariantAPI = { id: instanceId, name: displayName, currentIndex, totalVariants: variants.length, isDecided, variants, select, confirm, reset, focus, }; manager.register(instanceId, api); return () => manager.unregister(instanceId); } }, [instanceId, displayName, currentIndex, variants, isDecided, select, confirm, reset, focus]); // --- 点击外部关闭弹窗 --- useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsPopoverOpen(false); } }; if (isPopoverOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => document.removeEventListener('mousedown', handleClickOutside); }, [isPopoverOpen]); if (variants.length === 0) return null; return ( <> {/* 全局入口(单例,通过 Portal 渲染到 body,只由第一个组件渲染) */} {isGlobalControlOwner && typeof document !== 'undefined' && createPortal( , document.body )}
    setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} data-axhub-variant-id={instanceId} > {/* 渲染当前方案内容 */} {variants[currentIndex]?.content} {/* 触发器图标 (轻量化,悬停显示) */} {globalVisible && ( )} {/* 下拉选择面板 */} {globalVisible && (
    选择方案
    {variants.map((variant, index) => { const isActive = index === currentIndex; return (
    { e.stopPropagation(); select(index); }} style={{ ...STYLES.variantCard, ...(isActive ? STYLES.variantCardActive : {}), }} >
    {variant.title} {isActive && }
    {variant.description}
    ); })}
    {isDecided ? ( ) : ( )}
    )}
    ); }; export default VariantSwitcher;