gbmake-payload/sample/DisassemblyLinkedProductsPa...

568 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, useCallback, memo, useMemo } from 'react';
import {
Settings,
Plus,
Trash2,
Cpu,
Box,
BookOpen,
Move,
Target,
Ruler,
Zap,
Palette,
Spline,
ChevronRight,
Maximize2,
Wind,
RotateCw,
FileText,
DollarSign,
Activity,
GitBranch,
Layers,
Share2,
Battery,
HardDrive
} from 'lucide-react';
/**
* 工业蓝图风格样式系统
*/
const customStyles = `
.blueprint-grid {
background-image:
linear-gradient(#ccc 1px, transparent 1px),
linear-gradient(90deg, #ccc 1px, transparent 1px);
background-size: 40px 40px;
}
.drag-active {
cursor: grabbing !important;
}
.selection-box {
position: absolute;
border: 1px dashed #000;
background-color: rgba(0, 0, 0, 0.05);
pointer-events: none;
z-index: 200;
}
@keyframes line-flow {
from { stroke-dashoffset: 40; }
to { stroke-dashoffset: 0; }
}
@keyframes dash-flow-circle {
from { stroke-dashoffset: 40; }
to { stroke-dashoffset: 0; }
}
.leader-line {
animation: line-flow linear infinite;
pointer-events: none;
stroke-linecap: round;
transition: opacity 0.6s ease-out;
}
.rotating-origin {
animation: dash-flow-circle linear infinite;
pointer-events: none;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #000;
border-radius: 10px;
}
.node-pulse {
animation: node-pulse-kf 2s infinite;
}
@keyframes node-pulse-kf {
0% { box-shadow: 0 0 0 0px rgba(234, 179, 8, 0.4); }
100% { box-shadow: 0 0 0 10px rgba(234, 179, 8, 0); }
}
.junction-node {
transition: opacity 0.6s ease-out, transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
`;
// 工业随机颜色池
const INDUSTRIAL_PALETTE = [
'#262626', '#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#06b6d4', '#64748b'
];
// 零件实体组件
const PartItem = memo(({ part, isSelected, showExploded, onMouseDown, isDraggingAny }) => {
const x = showExploded ? part.target.x : part.origin.x;
const y = showExploded ? part.target.y : part.origin.y;
const getIcon = () => {
const n = part.name.toLowerCase();
if (n.includes('处理器') || n.includes('cpu')) return <Cpu size={36} strokeWidth={1} />;
if (n.includes('电池') || n.includes('energy')) return <Battery size={36} strokeWidth={1} />;
if (n.includes('存储') || n.includes('ssd')) return <HardDrive size={36} strokeWidth={1} />;
if (n.includes('冷却') || n.includes('fan')) return <Wind size={36} strokeWidth={1} />;
return <Box size={36} strokeWidth={1} />;
};
return (
<div
onMouseDown={(e) => onMouseDown(e, part.id, 'target')}
className={`absolute w-60 h-32 -translate-x-1/2 -translate-y-1/2 cursor-move pointer-events-auto z-40
${isSelected ? 'z-[45]' : ''}
${!isDraggingAny ? 'transition-all duration-700 cubic-bezier(0.25, 1, 0.5, 1)' : ''}`}
style={{ left: `${x}%`, top: `${y}%` }}
>
<div className={`w-full h-full border-2 border-neutral-900 bg-white flex flex-col p-0 transition-all overflow-visible relative
${isSelected ? 'border-black shadow-[12px_12px_0px_#000] ring-4 ring-yellow-400/30 -translate-y-1' : 'shadow-lg opacity-100'}`}>
<div className="absolute -top-3 -right-2 px-3 py-1 bg-yellow-400 border-2 border-black shadow-[3px_3px_0px_#000] rotate-3 z-50 flex items-center gap-1">
<span className="text-[8px] font-black opacity-40">COST:</span>
<span className="text-[10px] font-black text-black antialiased tracking-tighter">
${part.price || '0.00'}
</span>
</div>
<div className="h-1.5 w-full shrink-0" style={{backgroundColor: part.color}}></div>
<div className="flex-1 flex items-stretch overflow-hidden bg-white text-left">
<div className="w-20 bg-neutral-50 border-r border-neutral-100 flex items-center justify-center shrink-0 text-neutral-800">
{getIcon()}
</div>
<div className="flex-1 p-3 flex flex-col justify-center overflow-hidden">
<div className="text-[11px] font-black uppercase leading-tight text-neutral-900 tracking-tighter w-full truncate mb-1">
{part.name}
</div>
<div className="text-[9px] font-bold text-neutral-400 line-clamp-3 leading-[1.3] uppercase italic">
{part.description || 'SPECIFICATION_NOT_DEFINED'}
</div>
</div>
</div>
</div>
</div>
);
});
const INITIAL_GROUPS = [
{
id: 'g1',
title: '系统架构拓扑 - 多样性物料',
image: 'https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?auto=format&fit=crop&q=80&w=1600',
parts: [
{ id: 'p1-1', name: '独立感应器', description: '环境监测模块\nIP67工业防护等级\n实时光敏反馈回路', price: '45.00', color: '#10b981', lineColor: '#059669', origin: { x: 20, y: 70 }, target: { x: 15, y: 35 }, waypoint: { x: 20, y: 55 }, pointRadius: 30, lineWidth: 2, lineType: 'straight', dashLength: 4, circleDash: 2, lineSpeed: 2.0, circleSpeed: 4.0 },
{ id: 'p1-2', name: '处理器 A (主)', description: '核心逻辑运算单元\n共享总线节点\n分布式指令架构', price: '299.00', color: '#ef4444', lineColor: '#b91c1c', origin: { x: 50, y: 50 }, target: { x: 35, y: 15 }, waypoint: { x: 45, y: 35 }, pointRadius: 40, lineWidth: 2.5, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 },
{ id: 'p1-3', name: '处理器 B (从)', description: '冗余热备模块\n自动继承父级配色\n共享起点布线逻辑', price: '299.00', color: '#ef4444', lineColor: '#b91c1c', origin: { x: 50, y: 50 }, target: { x: 65, y: 15 }, waypoint: { x: 55, y: 35 }, pointRadius: 40, lineWidth: 2.5, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 },
{ id: 'p1-4', name: '电池模组', description: '5000mAh 动力包\n共享能源转接点\n内置热管理网格', price: '120.00', color: '#3b82f6', lineColor: '#1d4ed8', origin: { x: 80, y: 60 }, target: { x: 75, y: 85 }, waypoint: { x: 85, y: 75 }, pointRadius: 35, lineWidth: 2, lineType: 'polyline', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 5.0 },
]
}
];
const App = () => {
const [isEditMode, setIsEditMode] = useState(true);
const [isExploded, setIsExploded] = useState(true);
const [topoVisible, setTopoVisible] = useState(true); // 核心:控制线条与节点的显示
const [groups, setGroups] = useState(INITIAL_GROUPS);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [selectedPartIds, setSelectedPartIds] = useState([INITIAL_GROUPS[0].parts[1].id]);
const [dragState, setDragState] = useState({ active: false, type: 'target', affectedIds: [] });
const [canvasSize, setCanvasSize] = useState({ width: 1, height: 1 });
const canvasRef = useRef(null);
const dragStartRef = useRef({ x: 0, y: 0 });
const initialPositionsRef = useRef({});
const currentGroup = groups[activeGroupIndex];
const lastSelectedPart = currentGroup.parts.find(p => p.id === selectedPartIds[selectedPartIds.length - 1]);
// 监听炸开状态,实现逻辑上的延迟显示
useEffect(() => {
if (isEditMode) {
setTopoVisible(true);
return;
}
if (isExploded) {
// 炸开:组件移动耗时 700ms我们延迟 800ms 后显示线条
const timer = setTimeout(() => setTopoVisible(true), 800);
return () => clearTimeout(timer);
} else {
// 收回:线条立即消失
setTopoVisible(false);
}
}, [isExploded, isEditMode]);
const uniqueOrigins = useMemo(() => {
const seen = new Set();
return currentGroup.parts.filter(p => {
const key = `${Math.round(p.origin.x)}-${Math.round(p.origin.y)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}, [currentGroup.parts]);
const uniqueWaypoints = useMemo(() => {
const seen = new Set();
return currentGroup.parts.filter(p => {
if (p.lineType !== 'polyline') return false;
const key = `${Math.round(p.waypoint.x)}-${Math.round(p.waypoint.y)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}, [currentGroup.parts]);
useEffect(() => {
const updateSize = () => {
if (canvasRef.current) {
setCanvasSize({ width: canvasRef.current.offsetWidth, height: canvasRef.current.offsetHeight });
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
const updatePart = (partId, field, value) => {
setGroups(prev => {
const n = [...prev];
n[activeGroupIndex] = {
...n[activeGroupIndex],
parts: n[activeGroupIndex].parts.map(p => p.id === partId ? { ...p, [field]: value } : p)
};
return n;
});
};
const bulkUpdate = (field, value) => {
setGroups(prev => {
const n = [...prev];
n[activeGroupIndex] = {
...n[activeGroupIndex],
parts: n[activeGroupIndex].parts.map(p => selectedPartIds.includes(p.id) ? { ...p, [field]: value } : p)
};
return n;
});
};
const handleBranch = () => {
if (!lastSelectedPart) return;
const newId = 'p-branch-' + Date.now();
const newPart = {
...lastSelectedPart,
id: newId,
name: `${lastSelectedPart.name} 分支`,
target: { x: lastSelectedPart.target.x + 10, y: lastSelectedPart.target.y + 10 },
color: lastSelectedPart.color,
description: `基于 ${lastSelectedPart.name} 的拓扑分支\n自动同步父级配色与节点\n规格支持独立定义`
};
setGroups(prev => {
const n = [...prev];
n[activeGroupIndex].parts = [...n[activeGroupIndex].parts, newPart];
return n;
});
setSelectedPartIds([newId]);
};
const handleMouseDown = (e, partId, dragType) => {
if (!isEditMode) return;
e.stopPropagation();
const targetPart = currentGroup.parts.find(p => p.id === partId);
let affectedIds = [partId];
if (dragType === 'origin' || dragType === 'waypoint') {
affectedIds = currentGroup.parts.filter(p =>
Math.round(p[dragType].x) === Math.round(targetPart[dragType].x) &&
Math.round(p[dragType].y) === Math.round(targetPart[dragType].y)
).map(p => p.id);
}
setSelectedPartIds(affectedIds);
setDragState({ active: true, type: dragType, affectedIds });
const rect = canvasRef.current.getBoundingClientRect();
dragStartRef.current = {
x: ((e.clientX - rect.left) / rect.width) * 100,
y: ((e.clientY - rect.top) / rect.height) * 100
};
const initPos = {};
currentGroup.parts.forEach(p => {
if (affectedIds.includes(p.id)) {
initPos[p.id] = { ...p[dragType] };
}
});
initialPositionsRef.current = initPos;
};
const handleMouseMove = useCallback((e) => {
if (!isEditMode || !dragState.active || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const currentX = ((e.clientX - rect.left) / rect.width) * 100;
const currentY = ((e.clientY - rect.top) / rect.height) * 100;
const dx = currentX - dragStartRef.current.x;
const dy = currentY - dragStartRef.current.y;
const coordToUpdate = dragState.type;
setGroups(prev => {
const n = [...prev];
const g = n[activeGroupIndex];
n[activeGroupIndex] = {
...g,
parts: g.parts.map(p => {
if (dragState.affectedIds.includes(p.id)) {
const initPos = initialPositionsRef.current[p.id];
return {
...p,
[coordToUpdate]: {
x: Math.round((initPos.x + dx) * 10) / 10,
y: Math.round((initPos.y + dy) * 10) / 10
}
};
}
return p;
})
};
return n;
});
}, [dragState, isEditMode, activeGroupIndex]);
const handleMouseUp = useCallback(() => {
setDragState({ active: false, type: 'target', affectedIds: [] });
}, []);
useEffect(() => {
if (dragState.active) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragState.active, handleMouseMove, handleMouseUp]);
const getLeaderPath = (part) => {
if (part.lineType === 'polyline') {
return `M ${part.origin.x} ${part.origin.y} L ${part.waypoint.x} ${part.waypoint.y} L ${part.target.x} ${part.target.y}`;
}
return `M ${part.origin.x} ${part.origin.y} L ${part.target.x} ${part.target.y}`;
};
return (
<div className={`h-screen w-screen bg-[#f2f2f0] text-neutral-900 font-mono flex flex-col overflow-hidden relative selection:bg-black selection:text-white ${dragState.active ? 'drag-active' : ''}`}>
<style>{customStyles}</style>
<div className="fixed inset-0 pointer-events-none z-0 opacity-40 blueprint-grid"></div>
<header className="flex-none h-14 border-b-2 border-neutral-900 bg-white z-[110] flex items-center justify-between px-6 shadow-sm">
<div className="flex items-center gap-3">
<div className="bg-black text-white p-1 shadow-md"><Layers className="w-5 h-5" /></div>
<div>
<h1 className="text-lg font-black uppercase tracking-tighter leading-none text-neutral-900">Blueprint Topology</h1>
<p className="text-[10px] text-neutral-400 font-bold tracking-widest uppercase">Visual_Sync v15.0</p>
</div>
</div>
<div className="flex items-center bg-neutral-100 p-1 border-2 border-neutral-900 rounded-sm">
<button onClick={() => setIsEditMode(true)} className={`px-4 py-1 text-[10px] font-black transition-all ${isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}>编辑模式</button>
<button onClick={() => { setIsEditMode(false); setIsExploded(false); setSelectedPartIds([]); }} className={`px-4 py-1 text-[10px] font-black transition-all ${!isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}>预览展示</button>
</div>
</header>
<div className="flex-1 flex overflow-hidden relative">
{isEditMode && (
<aside className="w-80 bg-white border-r-2 border-neutral-900 z-[100] flex flex-col p-6 space-y-6 overflow-y-auto shadow-xl custom-scrollbar">
<div className="space-y-4 text-left">
<h2 className="text-sm font-black flex items-center justify-between italic text-neutral-800 border-b-2 border-neutral-100 pb-2">
<span className="flex items-center gap-2"><Settings size={16} /> 属性管理</span>
{selectedPartIds.length > 0 && <span className="text-[9px] bg-yellow-400 px-2 py-0.5 rounded-full font-bold shadow-sm uppercase">Linked: {selectedPartIds.length}</span>}
</h2>
{selectedPartIds.length > 0 ? (
<div className="space-y-5 animate-in fade-in slide-in-from-left-2 duration-300">
<button onClick={handleBranch} className="w-full py-3 bg-yellow-400 border-2 border-black text-[10px] font-black uppercase shadow-[4px_4px_0px_#000] active:translate-x-0.5 active:translate-y-0.5 transition-all flex items-center justify-center gap-2 hover:bg-yellow-300">
<GitBranch size={14} /> 基于当前节点分支
</button>
<div className="space-y-4 p-4 border-2 border-neutral-900 bg-neutral-50 rounded-sm shadow-sm text-left">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter">组件名称</label>
<input type="text" disabled={selectedPartIds.length > 1} value={lastSelectedPart?.name || ''} onChange={e => updatePart(lastSelectedPart.id, 'name', e.target.value)} className="w-full border-2 border-neutral-900 px-2 py-2 text-xs font-bold outline-none focus:bg-white transition-colors" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter flex items-center gap-1"><FileText size={10}/> 规格描述</label>
<textarea disabled={selectedPartIds.length > 1} value={lastSelectedPart?.description || ''} onChange={e => updatePart(lastSelectedPart.id, 'description', e.target.value)} rows="4" className="w-full border-2 border-neutral-900 px-2 py-2 text-[10px] font-bold outline-none focus:bg-white resize-none leading-relaxed" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter flex items-center gap-1"><Palette size={10}/> 顶部色块颜色</label>
<div className="flex items-center gap-2">
<input type="color" value={lastSelectedPart?.color || '#000000'} onChange={e => bulkUpdate('color', e.target.value)} className="w-full h-10 border-2 border-neutral-900 bg-white cursor-pointer p-1" />
<div className="w-10 h-10 border-2 border-neutral-900" style={{backgroundColor: lastSelectedPart?.color}}></div>
</div>
</div>
</div>
<div className="bg-neutral-900 p-4 rounded shadow-2xl space-y-4 text-white">
<label className="text-[9px] font-black text-yellow-400 uppercase tracking-widest flex items-center gap-2"><Zap size={12}/> Global_Sync</label>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase flex justify-between">
<span className="text-neutral-400 text-left">连线周期 (S)</span>
<span className="bg-white/10 px-1 rounded font-mono text-yellow-400">{lastSelectedPart?.lineSpeed || 2.0}s</span>
</label>
<input type="range" min="0.05" max="5.0" step="0.05" value={lastSelectedPart?.lineSpeed || 2.0} onChange={e => bulkUpdate('lineSpeed', parseFloat(e.target.value))} className="w-full accent-yellow-400 cursor-pointer h-1" />
</div>
</div>
<div className="space-y-3 pt-4 border-t-2 border-neutral-100">
<button onClick={() => setIsExploded(!isExploded)} className={`w-full py-4 border-2 border-neutral-900 text-[10px] font-black uppercase shadow-[6px_6px_0px_#000] active:translate-x-1 active:translate-y-1 transition-all ${isExploded ? 'bg-yellow-400 text-black' : 'bg-white'}`}>
{isExploded ? '视图: 炸开模式' : '视图: 组装模式'}
</button>
<button onClick={() => { setGroups(prev => { const n = [...prev]; n[activeGroupIndex].parts = currentGroup.parts.filter(p => !selectedPartIds.includes(p.id)); return n; }); setSelectedPartIds([]); }} className="w-full py-2.5 bg-red-50 text-red-600 border-2 border-red-200 text-[10px] font-black hover:bg-red-600 hover:text-white transition-all flex items-center justify-center gap-2 uppercase tracking-tighter"><Trash2 size={12} /> Delete_Nodes</button>
</div>
</div>
) : <div className="text-center py-12 text-neutral-300 text-[10px] font-bold uppercase italic border-2 border-dashed border-neutral-200 p-8 leading-relaxed">选择节点或物料卡<br/>进行拓扑逻辑管理</div>}
<button onClick={() => {
const randomColor = INDUSTRIAL_PALETTE[Math.floor(Math.random() * INDUSTRIAL_PALETTE.length)];
const newPart = { id: 'p' + Date.now(), name: '新独立物料', description: '待定义规格参数\n第二行规格\n第三行规格', price: '0.00', color: randomColor, lineColor: randomColor, origin: { x: Math.random()*20+40, y: Math.random()*20+40 }, target: { x: 50, y: 30 }, waypoint: { x: 50, y: 40 }, pointRadius: 40, lineWidth: 2.0, lineType: 'straight', dashLength: 2, circleDash: 1.5, lineSpeed: 2.0, circleSpeed: 4.0 };
setGroups(prev => { const n = [...prev]; n[activeGroupIndex].parts = [...n[activeGroupIndex].parts, newPart]; return n; });
setSelectedPartIds([newPart.id]);
}} className="w-full py-4 bg-white border-2 border-neutral-900 text-[10px] font-black shadow-[8px_8px_0px_#000] active:translate-x-1 active:translate-y-1 flex items-center justify-center gap-2 transition-all hover:bg-neutral-50"><Plus size={18} /> 添加独立物料</button>
</div>
</aside>
)}
<main className="flex-1 relative flex items-center justify-center p-20 overflow-visible" onMouseDown={() => isEditMode && setSelectedPartIds([])}>
<div ref={canvasRef} className={`relative w-full max-w-5xl aspect-video bg-white border-4 border-neutral-900 shadow-[48px_48px_0px_#ddd] transition-all duration-700`}>
<div className="absolute inset-0 z-10 pointer-events-none overflow-hidden">
<img src={currentGroup.image} alt={currentGroup.title} className="w-full h-full object-cover filter grayscale contrast-125" />
<div className="absolute inset-0 blueprint-grid opacity-20"></div>
</div>
{/* 线条渲染层 - 使用逻辑状态 topoVisible 控制显示 */}
<svg className="absolute inset-0 w-full h-full z-20 pointer-events-none overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none" shapeRendering="geometricPrecision">
{currentGroup.parts.map(part => {
const isSelected = selectedPartIds.includes(part.id);
const lw = (part.lineWidth || 2.5) * 0.35;
const actualLineColor = isSelected ? "#eab308" : (part.lineColor || part.color);
const dashArray = part.dashLength > 0 ? `${part.dashLength} ${lw * 4}` : "none";
return (
<path
key={`line-${part.id}`}
d={getLeaderPath(part)}
stroke={actualLineColor}
strokeWidth={isSelected ? lw * 1.8 : lw}
fill="none"
strokeDasharray={dashArray}
style={{
// topoVisible true 时才显示炸开模式有 800ms 延迟回收模式 0s 立即消失
opacity: topoVisible ? (isSelected ? 1 : 0.7) : 0,
animationDuration: `${part.lineSpeed || 2.0}s`
}}
className="leader-line"
/>
);
})}
</svg>
{/* 节点圆环渲染层 - 同步 topoVisible */}
<svg className="absolute inset-0 w-full h-full z-30 pointer-events-none overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none" shapeRendering="geometricPrecision">
{uniqueOrigins.map(part => {
const isSelected = selectedPartIds.includes(part.id);
const rx = (part.pointRadius / (canvasSize.width || 1)) * 100;
const ry = (part.pointRadius / (canvasSize.height || 1)) * 100;
return (
<g key={`origin-unique-${part.id}`} style={{
opacity: topoVisible ? 1 : 0,
transition: 'opacity 0.6s ease-out'
}}>
<ellipse cx={part.origin.x} cy={part.origin.y} rx={rx} ry={ry} fill="none" stroke={part.color} strokeWidth="0.05" opacity="0.15" />
<ellipse
cx={part.origin.x} cy={part.origin.y} rx={rx} ry={ry} fill="none"
stroke={isSelected ? "#fbbf24" : part.color}
strokeWidth={isSelected ? 0.8 : 0.3}
strokeDasharray={`${part.circleDash || 1.5}, 2.5`}
style={{ animationDuration: `${part.circleSpeed || 4}s` }}
className="rotating-origin"
/>
</g>
);
})}
</svg>
{/* 物料卡片渲染层 */}
<div className="absolute inset-0 z-40 pointer-events-none">
{currentGroup.parts.map(part => (
<PartItem
key={part.id} part={part} isSelected={selectedPartIds.includes(part.id)}
showExploded={isExploded} onMouseDown={handleMouseDown} isDraggingAny={dragState.active}
/>
))}
</div>
{/* 编辑句柄 - 起点 */}
{isEditMode && uniqueOrigins.map(part => (
<div
key={`h-origin-${part.id}`}
onMouseDown={(e) => handleMouseDown(e, part.id, 'origin')}
className={`absolute -translate-x-1/2 -translate-y-1/2 cursor-move z-[55] rounded-full transition-all
${selectedPartIds.includes(part.id) ? 'bg-yellow-400/20 node-pulse' : 'hover:bg-black/10'}`}
style={{ left: `${part.origin.x}%`, top: `${part.origin.y}%`, width: `${part.pointRadius * 1.5}px`, height: `${part.pointRadius * 1.5}px` }}
/>
))}
{/* 编辑句柄 - 中继点 - 同步 topoVisible */}
{uniqueWaypoints.map(part => {
const isSelected = selectedPartIds.includes(part.id);
return (
<div
key={`h-waypoint-${part.id}`}
onMouseDown={(e) => handleMouseDown(e, part.id, 'waypoint')}
className={`absolute w-4 h-4 -translate-x-1/2 -translate-y-1/2 bg-white border-2 rounded-full cursor-move z-[70] shadow-md junction-node
${isSelected ? 'border-yellow-500 scale-125 bg-yellow-50 shadow-lg' : 'border-neutral-900'}`}
style={{
left: `${part.waypoint.x}%`,
top: `${part.waypoint.y}%`,
opacity: topoVisible ? 1 : 0,
pointerEvents: topoVisible ? 'auto' : 'none'
}}
>
{(isSelected && isEditMode) && <div className="absolute -top-7 bg-black text-white text-[8px] px-1.5 py-0.5 font-black rounded uppercase whitespace-nowrap left-1/2 -translate-x-1/2">Junction</div>}
</div>
);
})}
</div>
<div className="absolute top-12 left-12">
<div className="bg-black text-white px-6 py-2.5 text-xs font-black uppercase tracking-[0.3em] italic flex items-center gap-4 shadow-[12px_12px_0px_#ccc]">
<Target size={18} className="text-yellow-400 animate-pulse" /> {currentGroup.title}
</div>
</div>
{!isEditMode && (
<div className="absolute bottom-12 flex flex-col items-center gap-4 z-[120]">
<button onClick={() => setIsExploded(!isExploded)} className="bg-black text-white px-16 py-6 font-black uppercase text-xs tracking-[0.4em] shadow-[18px_18px_0px_#ccc] hover:shadow-none hover:translate-x-2.5 hover:translate-y-2.5 transition-all flex items-center gap-5 group border-2 border-neutral-900 text-left">
{isExploded ? 'RESET_ASSEMBLY' : 'START_AXIS_ANIMATION'} <ChevronRight size={20} className="group-hover:translate-x-2 transition-transform" />
</button>
</div>
)}
</main>
</div>
<footer className="flex-none h-10 border-t-2 border-neutral-900 bg-white flex items-center justify-between px-6 z-[110] shadow-sm text-neutral-500">
<div className="flex items-center gap-6 text-left">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-wider text-neutral-400"><Ruler size={12} /> Alignment: Corrected_Axis</div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-wider text-neutral-400 opacity-60"><GitBranch size={12}/> Anim_Sync: Logic_State_v15.0</div>
</div>
<div className="text-[10px] font-mono opacity-40 uppercase tracking-tighter font-bold italic text-right">System_Engine_Stable</div>
</footer>
</div>
);
};
export default App;