import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Bot, Compass, Crosshair, Download, Megaphone, Minus, Plus, RotateCcw, ScrollText, Target, TrendingUp, UserCheck, X, Zap, } from 'lucide-react'; import { useTheme } from '../../contexts/ThemeContext'; import type { ExpertCard, SixStepMessage } from './types'; import { CURRENT_PAIR_BY_STAGE, EDGE_COLOR, EDGE_LABEL, STATUS_TITLE, VARIANT_LABEL, buildEdgePath, buildLayout, type LayoutEdge, type LayoutNode, } from './flowLayout'; interface SixStepFlowFullscreenProps { message?: SixStepMessage; onClose: () => void; } const SCALE_MIN = 0.2; const SCALE_MAX = 3; const buildExpertRootDetail = (expert: ExpertCard): string => { const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim())); return segments.join(' · ') || '已确认专家'; }; const VARIANT_TOKEN: Record; iconCls: { dark: string; light: string }; }> = { agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300', icon: Bot, iconCls: { dark: 'bg-slate-500/30 text-slate-200', light: 'bg-slate-100 text-slate-600' } }, purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300', icon: Target, iconCls: { dark: 'bg-violet-500/30 text-violet-200', light: 'bg-orange-100 text-orange-600' } }, actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300', icon: UserCheck, iconCls: { dark: 'bg-cyan-500/30 text-cyan-200', light: 'bg-red-100 text-red-600' } }, behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300', icon: TrendingUp,iconCls: { dark: 'bg-emerald-500/30 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' } }, driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300', icon: Zap, iconCls: { dark: 'bg-teal-500/30 text-teal-200', light: 'bg-teal-100 text-teal-700' } }, narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300', icon: ScrollText,iconCls: { dark: 'bg-amber-500/30 text-amber-200', light: 'bg-amber-100 text-amber-600' } }, }; const MINIMAP_VARIANT_FILL: Record = { agent: { dark: '#475569', light: '#cbd5e1' }, purpose: { dark: '#7c3aed', light: '#fb923c' }, actor: { dark: '#06b6d4', light: '#ef4444' }, behavior: { dark: '#10b981', light: '#10b981' }, driver: { dark: '#0d9488', light: '#0d9488' }, narrative: { dark: '#f59e0b', light: '#f59e0b' }, }; const DetailNode: React.FC<{ node: LayoutNode; isDark: boolean; collapsible: boolean; collapsed: boolean; onToggleCollapse?: (nodeId: string) => void; entering: boolean; exiting: boolean; }> = ({ node, isDark, collapsible, collapsed, onToggleCollapse, entering, exiting }) => { const token = VARIANT_TOKEN[node.variant]; const Icon = token.icon; const grad = isDark ? token.dark : token.light; const canToggle = collapsible && Boolean(onToggleCollapse); const handleClick = (e: React.MouseEvent) => { if (!canToggle) return; e.stopPropagation(); onToggleCollapse?.(node.id); }; const handleMouseDown = (e: React.MouseEvent) => { if (canToggle) e.stopPropagation(); }; return (
{collapsible && (
{collapsed ? '+' : '-'}
)}
{VARIANT_LABEL[node.variant]}
{node.title}
{node.detail || '—'}
); }; const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => ( ); interface MinimapProps { layout: { nodes: LayoutNode[]; width: number; height: number }; transform: { tx: number; ty: number; scale: number }; viewportSize: { width: number; height: number }; onJump: (worldX: number, worldY: number) => void; isDark: boolean; } const MINIMAP_W = 220; const MINIMAP_H = 150; const COLLAPSE_TRANSITION_MS = 240; const Minimap: React.FC = ({ layout, transform, viewportSize, onJump, isDark }) => { const ratio = layout.width > 0 && layout.height > 0 ? Math.min((MINIMAP_W - 10) / layout.width, (MINIMAP_H - 10) / layout.height) : 0.1; const renderW = layout.width * ratio; const renderH = layout.height * ratio; const offsetX = (MINIMAP_W - renderW) / 2; const offsetY = (MINIMAP_H - renderH) / 2; // Visible area (in world coords): from world (-tx/scale, -ty/scale) with size viewportW/scale × viewportH/scale const viewW = viewportSize.width / transform.scale; const viewH = viewportSize.height / transform.scale; const viewX = -transform.tx / transform.scale; const viewY = -transform.ty / transform.scale; const handleClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const mx = e.clientX - rect.left - offsetX; const my = e.clientY - rect.top - offsetY; const wx = mx / ratio; const wy = my / ratio; onJump(wx, wy); }; return (
缩略图
{layout.nodes.map((n) => ( ))}
); }; const SixStepFlowFullscreen: React.FC = ({ message, onClose }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; const purposes = message?.stages?.purpose?.cards ?? []; const actors = message?.stages?.actor?.cards ?? []; const behaviors = message?.stages?.behavior?.cards ?? []; const narratives = message?.stages?.narrative?.cards ?? []; const selectedExpert = message?.selectedExpert; const layout = useMemo( () => buildLayout(purposes, actors, behaviors, narratives, { rootTitle: selectedExpert?.name, rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined, }), [purposes, actors, behaviors, narratives, selectedExpert], ); const currentSub = message?.currentSubStep; const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null; const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化'; const generating = Boolean(currentSub); const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 }); const [isPanning, setIsPanning] = useState(false); const [viewportSize, setViewportSize] = useState({ width: 800, height: 600 }); const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()); const [displayedNodeIds, setDisplayedNodeIds] = useState>(new Set()); const [enteringNodeIds, setEnteringNodeIds] = useState>(new Set()); const [exitingNodeIds, setExitingNodeIds] = useState>(new Set()); const [displayedEdgeIds, setDisplayedEdgeIds] = useState>(new Set()); const [exitingEdgeIds, setExitingEdgeIds] = useState>(new Set()); const containerRef = useRef(null); const svgRef = useRef(null); const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null); const transitionTimerRef = useRef(null); const nodeMap = useMemo(() => new Map(layout.nodes.map((node) => [node.id, node])), [layout.nodes]); const targetVisibleNodeSet = useMemo(() => { const visible = new Set(); layout.nodes.forEach((node) => { let parentId = node.parent; let hidden = false; while (parentId) { if (collapsedNodeIds.has(parentId)) { hidden = true; break; } parentId = nodeMap.get(parentId)?.parent; } if (!hidden) visible.add(node.id); }); return visible; }, [collapsedNodeIds, layout.nodes, nodeMap]); const targetVisibleEdgeSet = useMemo(() => { const visible = new Set(); layout.edges.forEach((edge) => { if (targetVisibleNodeSet.has(edge.fromId) && targetVisibleNodeSet.has(edge.toId)) { visible.add(edge.id); } }); return visible; }, [layout.edges, targetVisibleNodeSet]); const renderedNodeSet = displayedNodeIds.size > 0 ? displayedNodeIds : targetVisibleNodeSet; const renderedEdgeSet = displayedEdgeIds.size > 0 ? displayedEdgeIds : targetVisibleEdgeSet; const renderedNodes = useMemo( () => layout.nodes.filter((node) => renderedNodeSet.has(node.id)), [layout.nodes, renderedNodeSet], ); const renderedEdges = useMemo( () => layout.edges.filter((edge) => renderedEdgeSet.has(edge.id)), [layout.edges, renderedEdgeSet], ); const toggleNodeCollapse = (nodeId: string) => { const node = nodeMap.get(nodeId); if (!node || node.children.length === 0) return; setCollapsedNodeIds((prev) => { const next = new Set(prev); if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId); return next; }); }; useEffect(() => { const update = () => { const el = containerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); setViewportSize({ width: rect.width, height: rect.height }); }; update(); window.addEventListener('resize', update); return () => window.removeEventListener('resize', update); }, []); // Native wheel listener with passive:false so preventDefault actually blocks browser zoom useEffect(() => { const container = containerRef.current; if (!container) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const rect = container.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const delta = -e.deltaY * 0.0015; setTransform((prev) => { const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta))); const ratio = nextScale / prev.scale; return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale }; }); }; container.addEventListener('wheel', onWheel, { passive: false }); return () => container.removeEventListener('wheel', onWheel); }, []); const handlePanStart = (e: React.MouseEvent) => { if (e.button !== 0) return; panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty }; setIsPanning(true); }; useEffect(() => { if (!isPanning) return; const move = (e: MouseEvent) => { const start = panStart.current; if (!start) return; setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) })); }; const up = () => { panStart.current = null; setIsPanning(false); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; }, [isPanning]); const fitToView = () => { const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const scale = Math.min((rect.width - 80) / Math.max(layout.width, 1), (rect.height - 80) / Math.max(layout.height, 1), 1); const tx = (rect.width - layout.width * scale) / 2; const ty = (rect.height - layout.height * scale) / 2; setTransform({ tx, ty, scale }); }; const centerView = () => { const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); setTransform((prev) => ({ ...prev, tx: (rect.width - layout.width * prev.scale) / 2, ty: (rect.height - layout.height * prev.scale) / 2, })); }; const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) })); const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) })); const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 }); const fitAppliedRef = useRef(false); useEffect(() => { if (!fitAppliedRef.current && viewportSize.width > 0 && layout.nodes.length > 1) { fitToView(); fitAppliedRef.current = true; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewportSize.width, layout.nodes.length]); const handleMinimapJump = (wx: number, wy: number) => { const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); setTransform((prev) => ({ ...prev, tx: rect.width / 2 - wx * prev.scale, ty: rect.height / 2 - wy * prev.scale, })); }; const handleDownload = () => { const svg = svgRef.current; if (!svg) return; const cloned = svg.cloneNode(true) as SVGSVGElement; cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); cloned.setAttribute('width', String(layout.width)); cloned.setAttribute('height', String(layout.height)); cloned.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`); const inner = cloned.querySelector('g'); if (inner) inner.setAttribute('transform', ''); const serializer = new XMLSerializer(); const svgString = serializer.serializeToString(cloned); const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `6步法推演-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.svg`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); useEffect(() => { const validIds = new Set(layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id)); setCollapsedNodeIds((prev) => { let changed = false; const next = new Set(); prev.forEach((id) => { if (validIds.has(id)) next.add(id); else changed = true; }); return changed ? next : prev; }); }, [layout.nodes]); useEffect(() => { if (transitionTimerRef.current) { window.clearTimeout(transitionTimerRef.current); transitionTimerRef.current = null; } const prevNodes = displayedNodeIds; const nextNodes = targetVisibleNodeSet; const prevEdges = displayedEdgeIds; const nextEdges = targetVisibleEdgeSet; const enteringNodes = new Set([...nextNodes].filter((id) => !prevNodes.has(id))); const leavingNodes = new Set([...prevNodes].filter((id) => !nextNodes.has(id))); const leavingEdges = new Set([...prevEdges].filter((id) => !nextEdges.has(id))); const mergedNodes = new Set([...prevNodes, ...nextNodes]); const mergedEdges = new Set([...prevEdges, ...nextEdges]); setEnteringNodeIds(enteringNodes); setExitingNodeIds(leavingNodes); setExitingEdgeIds(leavingEdges); setDisplayedNodeIds(mergedNodes); setDisplayedEdgeIds(mergedEdges); transitionTimerRef.current = window.setTimeout(() => { setDisplayedNodeIds(new Set(nextNodes)); setDisplayedEdgeIds(new Set(nextEdges)); setEnteringNodeIds(new Set()); setExitingNodeIds(new Set()); setExitingEdgeIds(new Set()); transitionTimerRef.current = null; }, COLLAPSE_TRANSITION_MS); }, [targetVisibleNodeSet, targetVisibleEdgeSet]); useEffect(() => { return () => { if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current); }; }, []); return (
{statusTitle}
全屏视图 · 完整内容展示
{Math.round(transform.scale * 100)}%
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => { const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light']; const active = activePair === pair; return ( {EDGE_LABEL[pair]} ); })}
{renderedNodes.length <= 1 ? (
等待推演开始
推演产出将以流程图形式呈现
) : ( {(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => { const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light']; return ( ); })} {renderedEdges.map((edge) => { const from = nodeMap.get(edge.fromId); const to = nodeMap.get(edge.toId); if (!from || !to) return null; const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light']; const isActive = activePair === edge.pair; const exiting = exitingEdgeIds.has(edge.id); return ( ); })} {renderedNodes.map((node) => ( 0} collapsed={collapsedNodeIds.has(node.id)} onToggleCollapse={toggleNodeCollapse} entering={enteringNodeIds.has(node.id)} exiting={exitingNodeIds.has(node.id)} /> ))} )} {targetVisibleNodeSet.size > 1 && (
targetVisibleNodeSet.has(node.id)), width: layout.width, height: layout.height, }} transform={transform} viewportSize={viewportSize} onJump={handleMinimapJump} isDark={isDark} />
)}
点击父节点可收起/展开子节点
); }; export default SixStepFlowFullscreen;