521 lines
20 KiB
TypeScript
521 lines
20 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Bot,
|
|
Compass,
|
|
Crosshair,
|
|
Maximize2,
|
|
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_ABBREV,
|
|
VARIANT_LABEL,
|
|
buildEdgePath,
|
|
buildLayout,
|
|
type LayoutEdge,
|
|
type LayoutNode,
|
|
} from './flowLayout';
|
|
|
|
interface SixStepFlowSandboxProps {
|
|
message?: SixStepMessage;
|
|
width: number;
|
|
onResizeStart: (e: React.MouseEvent) => void;
|
|
onClose: () => void;
|
|
onOpenFullscreen: () => void;
|
|
}
|
|
|
|
const COMPACT_OPTIONS = {
|
|
nodeWidth: 120,
|
|
nodeHeight: 60,
|
|
levelGap: 80,
|
|
nodeGap: 24,
|
|
paddingX: 32,
|
|
paddingY: 28,
|
|
};
|
|
|
|
const VARIANT_GRADIENT: Record<LayoutNode['variant'], { 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' },
|
|
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300' },
|
|
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300' },
|
|
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300' },
|
|
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300' },
|
|
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300' },
|
|
};
|
|
|
|
const VARIANT_ICON: Record<LayoutNode['variant'], React.ComponentType<{ className?: string }>> = {
|
|
agent: Bot,
|
|
purpose: Target,
|
|
actor: UserCheck,
|
|
behavior: TrendingUp,
|
|
driver: Zap,
|
|
narrative: ScrollText,
|
|
};
|
|
|
|
// Colored icon wrapper per variant (dark / light)
|
|
const VARIANT_ICON_CLS: Record<LayoutNode['variant'], { dark: string; light: string }> = {
|
|
agent: { dark: 'bg-slate-600/60 text-slate-200', light: 'bg-slate-100 text-slate-600' },
|
|
purpose: { dark: 'bg-violet-500/25 text-violet-200', light: 'bg-orange-100 text-orange-600' },
|
|
actor: { dark: 'bg-cyan-500/25 text-cyan-200', light: 'bg-red-100 text-red-600' },
|
|
behavior: { dark: 'bg-emerald-500/25 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' },
|
|
driver: { dark: 'bg-teal-500/25 text-teal-200', light: 'bg-teal-100 text-teal-700' },
|
|
narrative: { dark: 'bg-amber-500/25 text-amber-200', light: 'bg-amber-100 text-amber-600' },
|
|
};
|
|
|
|
const SCALE_MIN = 0.3;
|
|
const SCALE_MAX = 2.5;
|
|
|
|
const buildExpertRootDetail = (expert: ExpertCard): string => {
|
|
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
|
|
return segments.join(' · ') || '已确认专家';
|
|
};
|
|
|
|
const CompactNode: React.FC<{
|
|
node: LayoutNode;
|
|
isDark: boolean;
|
|
appearing: boolean;
|
|
collapsible: boolean;
|
|
collapsed: boolean;
|
|
onToggleCollapse?: (nodeId: string) => void;
|
|
}> = ({ node, isDark, appearing, collapsible, collapsed, onToggleCollapse }) => {
|
|
const Icon = VARIANT_ICON[node.variant];
|
|
const grad = VARIANT_GRADIENT[node.variant][isDark ? 'dark' : '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
|
|
className={appearing ? 'sixstep-node-pop' : ''}
|
|
onMouseDown={handleMouseDown}
|
|
onClick={handleClick}
|
|
style={canToggle ? { cursor: 'pointer' } : undefined}
|
|
>
|
|
{appearing && (
|
|
<circle
|
|
cx={node.x + node.width / 2}
|
|
cy={node.y + node.height / 2}
|
|
r={0}
|
|
stroke={isDark ? '#22d3ee' : '#ef4444'}
|
|
className="sixstep-ring-burst"
|
|
/>
|
|
)}
|
|
<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 flex items-center justify-center gap-1.5 px-2 shadow-sm`}>
|
|
{collapsible && (
|
|
<div
|
|
className={`absolute right-1.5 top-1.5 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={`w-7 h-7 rounded-lg flex items-center justify-center ${VARIANT_ICON_CLS[node.variant][isDark ? 'dark' : 'light']}`}>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
</div>
|
|
<span className={`text-sm font-bold tracking-wider ${isDark ? 'text-white' : 'text-slate-800'}`}>
|
|
{VARIANT_ABBREV[node.variant]}
|
|
</span>
|
|
</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>
|
|
);
|
|
|
|
const SixStepFlowSandbox: React.FC<SixStepFlowSandboxProps> = ({ message, width, onResizeStart, onClose, onOpenFullscreen }) => {
|
|
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, {
|
|
...COMPACT_OPTIONS,
|
|
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 [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
|
|
|
// Track newly appearing nodes for animation
|
|
const seenIdsRef = useRef<Set<string>>(new Set());
|
|
const [appearingIds, setAppearingIds] = useState<Set<string>>(new Set());
|
|
|
|
const nodeMap = useMemo(() => {
|
|
return new Map(layout.nodes.map((node) => [node.id, node]));
|
|
}, [layout.nodes]);
|
|
|
|
const visibleNodeSet = 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 visibleNodes = useMemo(
|
|
() => layout.nodes.filter((node) => visibleNodeSet.has(node.id)),
|
|
[layout.nodes, visibleNodeSet],
|
|
);
|
|
|
|
const visibleEdges = useMemo(
|
|
() => layout.edges.filter((edge) => visibleNodeSet.has(edge.fromId) && visibleNodeSet.has(edge.toId)),
|
|
[layout.edges, visibleNodeSet],
|
|
);
|
|
|
|
const toggleNodeCollapse = (nodeId: string) => {
|
|
const targetNode = nodeMap.get(nodeId);
|
|
if (!targetNode || targetNode.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 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(() => {
|
|
const newIds: string[] = [];
|
|
visibleNodes.forEach((n) => {
|
|
if (!seenIdsRef.current.has(n.id)) {
|
|
seenIdsRef.current.add(n.id);
|
|
newIds.push(n.id);
|
|
}
|
|
});
|
|
if (newIds.length > 0) {
|
|
setAppearingIds((prev) => {
|
|
const next = new Set(prev);
|
|
newIds.forEach((id) => next.add(id));
|
|
return next;
|
|
});
|
|
const timer = window.setTimeout(() => {
|
|
setAppearingIds((prev) => {
|
|
const next = new Set(prev);
|
|
newIds.forEach((id) => next.delete(id));
|
|
return next;
|
|
});
|
|
}, 700);
|
|
return () => window.clearTimeout(timer);
|
|
}
|
|
}, [visibleNodes]);
|
|
|
|
// 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 || layout.width <= 0 || layout.height <= 0) {
|
|
setTransform({ tx: 0, ty: 0, scale: 1 });
|
|
return;
|
|
}
|
|
const rect = container.getBoundingClientRect();
|
|
const scale = Math.min((rect.width - 40) / layout.width, (rect.height - 40) / layout.height, 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 });
|
|
|
|
// Auto-fit on first render with content
|
|
const fitAppliedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!fitAppliedRef.current && layout.nodes.length > 1) {
|
|
fitToView();
|
|
fitAppliedRef.current = true;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [layout.nodes.length]);
|
|
|
|
return (
|
|
<div
|
|
className={`relative h-full flex flex-col border-l ${
|
|
isDark ? 'border-slate-700/60 bg-slate-900/60' : 'border-red-100 bg-white'
|
|
}`}
|
|
style={{ width }}
|
|
>
|
|
<div className="sixstep-resize-handle" onMouseDown={onResizeStart} />
|
|
|
|
<div className={`flex items-center justify-between gap-3 px-4 py-3 border-b ${
|
|
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-red-100 bg-gradient-to-r from-red-50/60 to-orange-50/40'
|
|
}`}>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div className={`w-8 h-8 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-4 h-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className={`text-sm font-semibold truncate ${
|
|
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
|
|
}`}>
|
|
{statusTitle}
|
|
</div>
|
|
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
|
简化视图 · 节点缩写显示
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className={`p-1.5 rounded-lg transition-colors ${
|
|
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
|
|
}`}
|
|
title="关闭沙箱"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`flex items-center gap-1 px-3 py-2 border-b ${
|
|
isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'
|
|
}`}>
|
|
<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>
|
|
<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>
|
|
<div className="flex-1" />
|
|
<ToolbarButton title="全屏查看完整流程图" onClick={onOpenFullscreen} isDark={isDark}>
|
|
<Maximize2 className="w-4 h-4" />
|
|
</ToolbarButton>
|
|
</div>
|
|
|
|
<div className={`flex items-center gap-3 px-3 py-1.5 border-b text-[11px] flex-wrap ${
|
|
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/30' : '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-5 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}
|
|
>
|
|
{visibleNodes.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-10 h-10 opacity-40 mb-3" />
|
|
<div className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
|
|
<div className="text-xs mt-1">推演产出将以流程图形式呈现</div>
|
|
</div>
|
|
) : (
|
|
<svg
|
|
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-sandbox-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})`}>
|
|
{visibleEdges.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;
|
|
return (
|
|
<path
|
|
key={edge.id}
|
|
d={buildEdgePath(from, to)}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={isActive ? 2.2 : 1.6}
|
|
strokeOpacity={isActive ? 1 : 0.7}
|
|
className={isActive ? 'sixstep-ant-line' : ''}
|
|
markerEnd={`url(#sixstep-sandbox-arrow-${edge.pair})`}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{visibleNodes.map((node) => (
|
|
<CompactNode
|
|
key={node.id}
|
|
node={node}
|
|
isDark={isDark}
|
|
appearing={appearingIds.has(node.id)}
|
|
collapsible={node.children.length > 0}
|
|
collapsed={collapsedNodeIds.has(node.id)}
|
|
onToggleCollapse={toggleNodeCollapse}
|
|
/>
|
|
))}
|
|
</g>
|
|
</svg>
|
|
)}
|
|
|
|
<div className={`absolute bottom-3 left-3 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'
|
|
}`}>
|
|
{VARIANT_LABEL.purpose}=md · {VARIANT_LABEL.actor}=xwt · {VARIANT_LABEL.behavior}=yqxw · {VARIANT_LABEL.driver}=qdys · {VARIANT_LABEL.narrative}=xs
|
|
</div>
|
|
<div className={`absolute bottom-3 right-3 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 SixStepFlowSandbox;
|