654 lines
26 KiB
TypeScript
654 lines
26 KiB
TypeScript
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<LayoutNode['variant'], {
|
||
dark: string; light: string;
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
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<LayoutNode['variant'], { dark: string; light: string }> = {
|
||
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<SVGGElement>) => {
|
||
if (!canToggle) return;
|
||
e.stopPropagation();
|
||
onToggleCollapse?.(node.id);
|
||
};
|
||
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
|
||
if (canToggle) e.stopPropagation();
|
||
};
|
||
return (
|
||
<g
|
||
onMouseDown={handleMouseDown}
|
||
onClick={handleClick}
|
||
style={{
|
||
cursor: canToggle ? 'pointer' : undefined,
|
||
opacity: exiting ? 0 : 1,
|
||
transform: entering || exiting ? 'translateY(8px)' : 'translateY(0)',
|
||
transformOrigin: `${node.x + node.width / 2}px ${node.y + node.height / 2}px`,
|
||
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease, transform ${COLLAPSE_TRANSITION_MS}ms ease`,
|
||
}}
|
||
>
|
||
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
|
||
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm p-2.5 shadow-sm overflow-hidden`}>
|
||
{collapsible && (
|
||
<div
|
||
className={`absolute right-2 top-2 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
|
||
}`}
|
||
title={collapsed ? '展开子节点' : '收起子节点'}
|
||
>
|
||
{collapsed ? '+' : '-'}
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-1.5 mb-1">
|
||
<div className={`w-5 h-5 rounded-md flex items-center justify-center ${token.iconCls[isDark ? 'dark' : 'light']}`}>
|
||
<Icon className="w-3 h-3" />
|
||
</div>
|
||
<span className={`text-[10px] uppercase tracking-wider font-semibold ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
|
||
{VARIANT_LABEL[node.variant]}
|
||
</span>
|
||
</div>
|
||
<div className={`text-xs font-semibold leading-tight line-clamp-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||
{node.title}
|
||
</div>
|
||
<div className={`text-[11px] leading-snug line-clamp-2 mt-0.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>
|
||
{node.detail || '—'}
|
||
</div>
|
||
</div>
|
||
</foreignObject>
|
||
</g>
|
||
);
|
||
};
|
||
|
||
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
|
||
<button
|
||
type="button"
|
||
title={title}
|
||
onClick={onClick}
|
||
className={`sixstep-toolbar-btn ${
|
||
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
|
||
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<MinimapProps> = ({ 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<SVGSVGElement>) => {
|
||
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 (
|
||
<div className={`rounded-xl overflow-hidden border shadow-lg backdrop-blur-md ${
|
||
isDark ? 'bg-slate-900/80 border-slate-700/70' : 'bg-white/90 border-gray-200'
|
||
}`}>
|
||
<div className={`px-3 py-1.5 text-[10px] uppercase tracking-wider border-b font-medium ${
|
||
isDark ? 'text-slate-400 border-slate-700/50' : 'text-gray-500 border-gray-100'
|
||
}`}>
|
||
缩略图
|
||
</div>
|
||
<svg
|
||
width={MINIMAP_W}
|
||
height={MINIMAP_H}
|
||
onClick={handleClick}
|
||
style={{ cursor: 'pointer' }}
|
||
>
|
||
<rect width={MINIMAP_W} height={MINIMAP_H} fill={isDark ? '#0f172a' : '#f8fafc'} />
|
||
<g transform={`translate(${offsetX}, ${offsetY}) scale(${ratio})`}>
|
||
{layout.nodes.map((n) => (
|
||
<rect
|
||
key={n.id}
|
||
x={n.x}
|
||
y={n.y}
|
||
width={n.width}
|
||
height={n.height}
|
||
rx={6}
|
||
fill={MINIMAP_VARIANT_FILL[n.variant][isDark ? 'dark' : 'light']}
|
||
opacity={0.75}
|
||
/>
|
||
))}
|
||
</g>
|
||
<rect
|
||
className="sixstep-minimap-rect"
|
||
x={offsetX + viewX * ratio}
|
||
y={offsetY + viewY * ratio}
|
||
width={Math.max(6, viewW * ratio)}
|
||
height={Math.max(6, viewH * ratio)}
|
||
rx={3}
|
||
/>
|
||
</svg>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SixStepFlowFullscreen: React.FC<SixStepFlowFullscreenProps> = ({ 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<Set<string>>(new Set());
|
||
const [displayedNodeIds, setDisplayedNodeIds] = useState<Set<string>>(new Set());
|
||
const [enteringNodeIds, setEnteringNodeIds] = useState<Set<string>>(new Set());
|
||
const [exitingNodeIds, setExitingNodeIds] = useState<Set<string>>(new Set());
|
||
const [displayedEdgeIds, setDisplayedEdgeIds] = useState<Set<string>>(new Set());
|
||
const [exitingEdgeIds, setExitingEdgeIds] = useState<Set<string>>(new Set());
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
||
const transitionTimerRef = useRef<number | null>(null);
|
||
|
||
const nodeMap = useMemo(() => new Map(layout.nodes.map((node) => [node.id, node])), [layout.nodes]);
|
||
|
||
const targetVisibleNodeSet = useMemo(() => {
|
||
const visible = new Set<string>();
|
||
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<string>();
|
||
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<string>();
|
||
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 (
|
||
<div className={`fixed inset-0 z-50 flex flex-col ${isDark ? 'bg-slate-950/95' : 'bg-white/95'} backdrop-blur-sm`}>
|
||
<div className={`flex items-center justify-between gap-3 px-5 py-3 border-b ${
|
||
isDark ? 'border-slate-700/60 bg-slate-900/70' : 'border-red-100 bg-white'
|
||
}`}>
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${
|
||
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
|
||
} ${generating ? 'sixstep-pulse' : ''}`}>
|
||
<Compass className="w-5 h-5" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className={`text-base font-semibold ${
|
||
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
|
||
}`}>
|
||
{statusTitle}
|
||
</div>
|
||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||
全屏视图 · 完整内容展示
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
|
||
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
|
||
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
|
||
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
|
||
<ToolbarButton title="下载流程图(SVG)" onClick={handleDownload} isDark={isDark}><Download className="w-4 h-4" /></ToolbarButton>
|
||
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
|
||
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||
{Math.round(transform.scale * 100)}%
|
||
</span>
|
||
<ToolbarButton title="关闭全屏 (Esc)" onClick={onClose} isDark={isDark}>
|
||
<X className="w-4 h-4" />
|
||
</ToolbarButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`flex items-center gap-3 px-5 py-2 border-b text-[11px] flex-wrap ${
|
||
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/40' : 'border-red-100 text-gray-500 bg-white'
|
||
}`}>
|
||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||
const active = activePair === pair;
|
||
return (
|
||
<span key={pair} className="inline-flex items-center gap-1.5">
|
||
<span className={`inline-block w-6 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
|
||
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div
|
||
ref={containerRef}
|
||
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
|
||
onMouseDown={handlePanStart}
|
||
>
|
||
{renderedNodes.length <= 1 ? (
|
||
<div className={`h-full flex flex-col items-center justify-center text-center ${
|
||
isDark ? 'text-slate-500' : 'text-gray-400'
|
||
}`}>
|
||
<Megaphone className="w-12 h-12 opacity-40 mb-3" />
|
||
<div className={`text-base font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
|
||
<div className="text-sm mt-1">推演产出将以流程图形式呈现</div>
|
||
</div>
|
||
) : (
|
||
<svg ref={svgRef} width="100%" height="100%" className="block select-none">
|
||
<defs>
|
||
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
|
||
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
|
||
return (
|
||
<marker
|
||
key={pair}
|
||
id={`sixstep-fs-arrow-${pair}`}
|
||
viewBox="0 0 8 8"
|
||
refX="7"
|
||
refY="4"
|
||
markerWidth="6"
|
||
markerHeight="6"
|
||
orient="auto-start-reverse"
|
||
>
|
||
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
|
||
</marker>
|
||
);
|
||
})}
|
||
</defs>
|
||
|
||
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
|
||
{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 (
|
||
<path
|
||
key={edge.id}
|
||
d={buildEdgePath(from, to)}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth={isActive ? 2.4 : 1.8}
|
||
strokeOpacity={exiting ? 0 : isActive ? 1 : 0.75}
|
||
className={isActive ? 'sixstep-ant-line' : ''}
|
||
markerEnd={`url(#sixstep-fs-arrow-${edge.pair})`}
|
||
style={{
|
||
opacity: exiting ? 0 : 1,
|
||
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease`,
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{renderedNodes.map((node) => (
|
||
<DetailNode
|
||
key={node.id}
|
||
node={node}
|
||
isDark={isDark}
|
||
collapsible={node.children.length > 0}
|
||
collapsed={collapsedNodeIds.has(node.id)}
|
||
onToggleCollapse={toggleNodeCollapse}
|
||
entering={enteringNodeIds.has(node.id)}
|
||
exiting={exitingNodeIds.has(node.id)}
|
||
/>
|
||
))}
|
||
</g>
|
||
</svg>
|
||
)}
|
||
|
||
{targetVisibleNodeSet.size > 1 && (
|
||
<div className="absolute bottom-4 right-4">
|
||
<Minimap
|
||
layout={{
|
||
nodes: layout.nodes.filter((node) => targetVisibleNodeSet.has(node.id)),
|
||
width: layout.width,
|
||
height: layout.height,
|
||
}}
|
||
transform={transform}
|
||
viewportSize={viewportSize}
|
||
onJump={handleMinimapJump}
|
||
isDark={isDark}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className={`absolute bottom-4 left-4 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
|
||
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
|
||
}`}>
|
||
点击父节点可收起/展开子节点
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SixStepFlowFullscreen;
|