按钮精简 编辑
This commit is contained in:
parent
6e75c34faf
commit
482bcda16d
|
|
@ -0,0 +1,515 @@
|
|||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Cpu,
|
||||
Settings,
|
||||
Anchor,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
Image,
|
||||
Layers,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
RotateCw,
|
||||
Target,
|
||||
Move
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Disassembly Edit/Preview Page
|
||||
* 融合 DisassemblyPages(视觉展示)与 DisassemblyLinkedProductsPage(交互编辑)
|
||||
*
|
||||
* 编辑模式:左侧属性面板 + 点击选中导航组件 + 支持新增/删除/修改名称/代码/图片
|
||||
* 预览模式:与 DisassemblyPages v7.2.0 一致的工业草稿风格展示
|
||||
*/
|
||||
|
||||
const styles = `
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
@keyframes node-pulse-kf {
|
||||
0% { box-shadow: 0 0 0 0px rgba(234, 179, 8, 0.5); }
|
||||
100% { box-shadow: 0 0 0 10px rgba(234, 179, 8, 0); }
|
||||
}
|
||||
.blueprint-grid {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
.blueprint-grid::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
|
||||
background-size: 10px 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.assembly-view {
|
||||
filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05);
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.assembly-view:hover {
|
||||
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1);
|
||||
}
|
||||
.leader-line-svg {
|
||||
transition: stroke 0.4s ease, stroke-width 0.4s ease;
|
||||
}
|
||||
.text-label-container {
|
||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.scan-effect {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 100%;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.01), transparent);
|
||||
animation: scanline 15s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.node-selected {
|
||||
animation: node-pulse-kf 2s infinite;
|
||||
outline: 2px solid #eab308;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #000; border-radius: 10px; }
|
||||
.part-hover { transition: all 0.35s cubic-bezier(0.23, 1, 0.32, 1); }
|
||||
`;
|
||||
|
||||
const DEFAULT_PARTS = [
|
||||
{ id: 'p1', name: '环境监控', code: 'ENV-X', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?auto=format&fit=crop&q=80&w=200' },
|
||||
{ id: 'p2', name: '能源模组', code: 'BATT-V8', img: 'https://images.unsplash.com/photo-1619641259501-c88f28c6e355?auto=format&fit=crop&q=80&w=200' },
|
||||
{ id: 'p3', name: '雷达阵列', code: 'LDR-07', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?auto=format&fit=crop&q=80&w=200' },
|
||||
{ id: 'p4', name: '核心总成', code: 'CORE-MAX', img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80&w=200' },
|
||||
{ id: 'p5', name: '液压单元', code: 'HYD-02', img: 'https://images.unsplash.com/photo-1635350736475-c8cef4b21906?auto=format&fit=crop&q=80&w=200' },
|
||||
{ id: 'p6', name: '散热单元', code: 'COOL-F2', img: 'https://images.unsplash.com/photo-1635350736475-c8cef4b21906?auto=format&fit=crop&q=80&w=200' },
|
||||
{ id: 'p7', name: '存储阵列', code: 'DATA-2T', img: 'https://images.unsplash.com/photo-1544006659-f0b21f04cb1d?auto=format&fit=crop&q=80&w=200' },
|
||||
];
|
||||
|
||||
const DEFAULT_ASSEMBLY = 'https://images.unsplash.com/photo-1581092160562-40aa08e78837?auto=format&fit=crop&q=80&w=1600';
|
||||
|
||||
const ICON_SIZE = 80;
|
||||
const CENTER_AXIS_Y = 180;
|
||||
const OFFSET_Y = 40;
|
||||
|
||||
// ─── 单个组件节点(共用于编辑 & 预览)─────────────────────────────────────
|
||||
function PartNode({ part, index, isHovered, isSelected, isEditMode, onHover, onClick }) {
|
||||
const isUp = index % 2 === 0;
|
||||
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y;
|
||||
const SVG_CENTER_X = 50;
|
||||
const SVG_CENTER_Y = 40;
|
||||
|
||||
const lineColor = isSelected
|
||||
? '#eab308'
|
||||
: isHovered
|
||||
? '#000'
|
||||
: '#f5f5f5';
|
||||
const lineWidth = isSelected ? '2.5' : isHovered ? '2.5' : '1';
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => onHover(part.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => isEditMode && onClick(part.id)}
|
||||
className={`group flex flex-col items-center relative flex-1
|
||||
${isEditMode ? 'cursor-pointer' : 'cursor-default'}
|
||||
${isSelected ? 'z-30' : 'z-10'}`}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{/* 垂直引导线 */}
|
||||
<svg
|
||||
className="absolute top-0 left-1/2 -translate-x-1/2 w-[100px] h-[400px] overflow-visible pointer-events-none z-0"
|
||||
viewBox="0 0 100 400"
|
||||
>
|
||||
<path
|
||||
d={`M ${SVG_CENTER_X} ${SVG_CENTER_Y} L ${SVG_CENTER_X} ${boxTop}`}
|
||||
fill="none"
|
||||
stroke={lineColor}
|
||||
strokeWidth={lineWidth}
|
||||
strokeDasharray={isSelected || isHovered ? 'none' : '3,3'}
|
||||
className="leader-line-svg"
|
||||
/>
|
||||
<circle cx={SVG_CENTER_X} cy={SVG_CENTER_Y} r="2.5"
|
||||
fill={isSelected ? '#eab308' : isHovered ? '#000' : '#e0e0e0'} />
|
||||
<circle cx={SVG_CENTER_X} cy={boxTop} r={isSelected || isHovered ? '4' : '2'}
|
||||
fill={isSelected ? '#eab308' : isHovered ? '#000' : '#e0e0e0'} />
|
||||
</svg>
|
||||
|
||||
{/* 图标节点 */}
|
||||
<div
|
||||
className={`relative flex items-center justify-center transition-all duration-500 z-10 part-hover rounded-sm
|
||||
${isHovered || isSelected ? 'scale-110 -translate-y-2' : ''}
|
||||
${isSelected ? 'node-selected' : ''}`}
|
||||
style={{ width: `${ICON_SIZE}px`, height: `${ICON_SIZE}px` }}
|
||||
>
|
||||
<img src={part.img} className="w-16 h-16 object-contain relative z-10" alt={part.name} />
|
||||
{(isHovered || isSelected) && (
|
||||
<div className="absolute inset-0 bg-neutral-900/5 blur-2xl rounded-full scale-125" />
|
||||
)}
|
||||
{isEditMode && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-neutral-800 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded-full z-20">
|
||||
<Move size={9} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标签容器 */}
|
||||
<div
|
||||
className={`absolute flex flex-col items-center whitespace-nowrap text-label-container z-30
|
||||
${isHovered || isSelected ? (isUp ? '-translate-y-1' : 'translate-y-1') : ''}`}
|
||||
style={{ top: `${boxTop}px`, left: '50%', transform: 'translateX(-50%)' }}
|
||||
>
|
||||
{/* 接线端子 */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full border-2 border-white mb-3 transition-all
|
||||
${isSelected ? 'bg-yellow-400 scale-150' : isHovered ? 'bg-neutral-900 scale-125' : 'bg-neutral-200'}`}
|
||||
/>
|
||||
{/* 指示器 */}
|
||||
<div className="flex items-center gap-1.5 mb-1.5 opacity-60">
|
||||
{isUp ? <ChevronUp size={8} /> : <div className="w-[8px]" />}
|
||||
<div className={`text-[8px] font-black uppercase tracking-widest transition-colors
|
||||
${isHovered || isSelected ? 'text-neutral-900' : 'text-neutral-300'}`}>
|
||||
CODE.0{index + 1}
|
||||
</div>
|
||||
{!isUp ? <ChevronDown size={8} /> : <div className="w-[8px]" />}
|
||||
</div>
|
||||
{/* 大字名称 */}
|
||||
<div className={`text-2xl font-black uppercase tracking-tighter transition-all duration-300
|
||||
${isSelected ? 'text-yellow-500 scale-105' : isHovered ? 'text-neutral-900 scale-105' : 'text-neutral-400'}`}>
|
||||
{part.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主组件 ────────────────────────────────────────────────────────────────
|
||||
export default function App() {
|
||||
const [isEditMode, setIsEditMode] = useState(true);
|
||||
const [parts, setParts] = useState(DEFAULT_PARTS);
|
||||
const [assemblyImg, setAssemblyImg] = useState(DEFAULT_ASSEMBLY);
|
||||
const [hoveredId, setHoveredId] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [imgInputVal, setImgInputVal] = useState('');
|
||||
|
||||
const selectedPart = parts.find(p => p.id === selectedId) || null;
|
||||
const idxOf = (id) => parts.findIndex(p => p.id === id);
|
||||
|
||||
// ── 更新选中组件字段 ──
|
||||
const updateField = useCallback((field, value) => {
|
||||
if (!selectedId) return;
|
||||
setParts(prev => prev.map(p => p.id === selectedId ? { ...p, [field]: value } : p));
|
||||
}, [selectedId]);
|
||||
|
||||
// ── 新增组件 ──
|
||||
const addPart = () => {
|
||||
const newPart = {
|
||||
id: 'p-' + Date.now(),
|
||||
name: '新组件',
|
||||
code: 'NEW-00',
|
||||
img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80&w=200',
|
||||
};
|
||||
setParts(prev => [...prev, newPart]);
|
||||
setSelectedId(newPart.id);
|
||||
};
|
||||
|
||||
// ── 删除选中组件 ──
|
||||
const deletePart = () => {
|
||||
if (!selectedId) return;
|
||||
setParts(prev => prev.filter(p => p.id !== selectedId));
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
// ── 上移 / 下移 ──
|
||||
const movePart = (dir) => {
|
||||
const idx = idxOf(selectedId);
|
||||
if (idx < 0) return;
|
||||
const next = idx + dir;
|
||||
if (next < 0 || next >= parts.length) return;
|
||||
setParts(prev => {
|
||||
const arr = [...prev];
|
||||
[arr[idx], arr[next]] = [arr[next], arr[idx]];
|
||||
return arr;
|
||||
});
|
||||
};
|
||||
|
||||
// ── 切换模式 ──
|
||||
const enterPreview = () => {
|
||||
setIsEditMode(false);
|
||||
setSelectedId(null);
|
||||
};
|
||||
const enterEdit = () => setIsEditMode(true);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-[#ffffff] text-neutral-900 font-mono flex flex-col overflow-hidden select-none">
|
||||
<style>{styles}</style>
|
||||
|
||||
{/* ── 顶部标题栏 ────────────────────────────────────── */}
|
||||
<header className="h-14 border-b-2 border-neutral-900 bg-white flex items-center justify-between px-8 z-50 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-1.5 bg-neutral-900 rounded-sm">
|
||||
<Cpu size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-[13px] font-black uppercase tracking-[0.35em] leading-none text-neutral-900">
|
||||
Disassembly_Editor
|
||||
</h1>
|
||||
<p className="text-[9px] text-neutral-400 mt-0.5 font-bold tracking-tighter">
|
||||
EDIT_PREVIEW_v1.0.0 // VISUAL_ENHANCED
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式切换 */}
|
||||
<div className="flex items-center bg-neutral-100 p-1 border-2 border-neutral-900 rounded-sm">
|
||||
<button
|
||||
onClick={enterEdit}
|
||||
className={`px-5 py-1.5 text-[10px] font-black transition-all uppercase tracking-widest
|
||||
${isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}
|
||||
>
|
||||
编辑模式
|
||||
</button>
|
||||
<button
|
||||
onClick={enterPreview}
|
||||
className={`px-5 py-1.5 text-[10px] font-black transition-all uppercase tracking-widest
|
||||
${!isEditMode ? 'bg-black text-white shadow-inner' : 'text-neutral-500 hover:text-black'}`}
|
||||
>
|
||||
预览展示
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-neutral-400">
|
||||
<div className="flex flex-col items-end text-[9px] font-black uppercase tracking-widest">
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-neutral-900 rounded-full animate-pulse" />
|
||||
{isEditMode ? 'MODE: EDIT' : 'MODE: PREVIEW'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-neutral-200" />
|
||||
<Settings size={16} className="hover:text-neutral-900 cursor-pointer transition-colors" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 主体区域 ────────────────────────────────────── */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
{/* ── 左侧编辑面板(仅编辑模式)─────────────────── */}
|
||||
{isEditMode && (
|
||||
<aside className="w-72 bg-white border-r-2 border-neutral-900 z-40 flex flex-col overflow-y-auto custom-scrollbar shadow-xl">
|
||||
|
||||
{/* 中央图片设置 */}
|
||||
<div className="p-5 border-b-2 border-neutral-100 space-y-3">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-widest text-neutral-500 flex items-center gap-2">
|
||||
<Image size={12} /> 中央组装图
|
||||
</h2>
|
||||
<div className="relative w-full aspect-video border-2 border-neutral-200 overflow-hidden bg-neutral-50">
|
||||
<img src={assemblyImg} className="w-full h-full object-cover filter grayscale" alt="Assembly" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={imgInputVal}
|
||||
onChange={e => setImgInputVal(e.target.value)}
|
||||
placeholder="粘贴图片 URL..."
|
||||
className="w-full border-2 border-neutral-900 px-2 py-2 text-[10px] font-bold outline-none focus:bg-yellow-50 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { if (imgInputVal.trim()) { setAssemblyImg(imgInputVal.trim()); setImgInputVal(''); } }}
|
||||
className="w-full py-2 bg-neutral-900 text-white text-[10px] font-black uppercase tracking-widest hover:bg-neutral-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<RotateCw size={12} /> 替换图片
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 组件列表 */}
|
||||
<div className="p-5 border-b-2 border-neutral-100 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-widest text-neutral-500 flex items-center gap-2">
|
||||
<Layers size={12} /> 组件列表
|
||||
<span className="bg-neutral-100 border border-neutral-300 text-[9px] px-1.5 py-0.5 rounded font-bold">{parts.length}</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={addPart}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-black text-white text-[9px] font-black uppercase hover:bg-neutral-700 transition-colors shadow-[2px_2px_0px_#ccc]"
|
||||
>
|
||||
<Plus size={10} /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto custom-scrollbar">
|
||||
{parts.map((p, i) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 border text-left transition-all
|
||||
${selectedId === p.id
|
||||
? 'border-yellow-400 bg-yellow-50 shadow-[2px_2px_0px_#000]'
|
||||
: 'border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50'}`}
|
||||
>
|
||||
<img src={p.img} className="w-7 h-7 object-contain bg-neutral-100 border border-neutral-200 flex-none" alt="" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-black uppercase truncate">{p.name}</div>
|
||||
<div className="text-[8px] text-neutral-400 font-bold">{p.code} · #{i + 1}</div>
|
||||
</div>
|
||||
{selectedId === p.id && <div className="w-1.5 h-1.5 rounded-full bg-yellow-400 flex-none" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选中组件属性编辑 */}
|
||||
{selectedPart ? (
|
||||
<div className="flex-1 p-5 space-y-5">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-widest text-neutral-500 flex items-center justify-between">
|
||||
<span className="flex items-center gap-2"><FileText size={12} /> 属性编辑</span>
|
||||
<span className="text-[8px] bg-yellow-400 px-2 py-0.5 font-bold border border-yellow-500">{selectedPart.code}</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 p-4 border-2 border-neutral-900 bg-neutral-50 rounded-sm shadow-sm">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-neutral-400 uppercase tracking-tighter">组件名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedPart.name}
|
||||
onChange={e => updateField('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">编号代码</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedPart.code}
|
||||
onChange={e => updateField('code', 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">图片 URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedPart.img}
|
||||
onChange={e => updateField('img', e.target.value)}
|
||||
className="w-full border-2 border-neutral-900 px-2 py-2 text-[10px] font-bold outline-none focus:bg-white transition-colors"
|
||||
/>
|
||||
<img src={selectedPart.img} alt="" className="w-full h-20 object-contain bg-neutral-100 border border-neutral-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 排序 & 删除 */}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => movePart(-1)} className="flex-1 py-2 border-2 border-neutral-900 text-[10px] font-black uppercase hover:bg-neutral-50 transition-all flex items-center justify-center gap-1 shadow-[3px_3px_0px_#ddd]">
|
||||
<ChevronUp size={12} /> 上移
|
||||
</button>
|
||||
<button onClick={() => movePart(1)} className="flex-1 py-2 border-2 border-neutral-900 text-[10px] font-black uppercase hover:bg-neutral-50 transition-all flex items-center justify-center gap-1 shadow-[3px_3px_0px_#ddd]">
|
||||
<ChevronDown size={12} /> 下移
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={deletePart}
|
||||
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_Node
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center text-neutral-300 text-[10px] font-bold uppercase italic border-2 border-dashed border-neutral-200 p-8 leading-relaxed">
|
||||
<Target size={24} className="mx-auto mb-3 opacity-30" />
|
||||
点击左侧列表或画布<br />节点以选中组件
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* ── 右侧主画布 ─────────────────────────────────── */}
|
||||
<main
|
||||
className="flex-1 relative flex flex-col items-center justify-center p-12 overflow-hidden"
|
||||
onClick={() => isEditMode && setSelectedId(null)}
|
||||
>
|
||||
<div className="absolute inset-0 blueprint-grid" />
|
||||
<div className="scan-effect" />
|
||||
|
||||
{/* 中心工业总成图 */}
|
||||
<div className="relative w-full flex-1 max-w-4xl max-h-[35vh] flex items-center justify-center z-10">
|
||||
{/* 角标 */}
|
||||
<div className="absolute -inset-4 pointer-events-none opacity-40">
|
||||
<div className="absolute top-0 left-0 w-12 h-12 border-t border-l border-neutral-600" />
|
||||
<div className="absolute bottom-0 right-0 w-12 h-12 border-b border-r border-neutral-600" />
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={assemblyImg}
|
||||
className="max-w-full max-h-full object-contain assembly-view"
|
||||
alt="Central Assembly System"
|
||||
/>
|
||||
|
||||
{/* 编辑模式标注 */}
|
||||
{isEditMode && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="border-2 border-dashed border-yellow-400/40 w-full h-full rounded-sm" />
|
||||
<span className="absolute bottom-2 right-2 text-[8px] font-black text-yellow-500 uppercase bg-white/80 px-2 py-1 border border-yellow-300">
|
||||
双击替换图片
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部组件导航 */}
|
||||
<div
|
||||
className="w-full max-w-[1300px] mt-12 flex justify-between z-20 pb-72 px-10"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{parts.map((part, index) => (
|
||||
<PartNode
|
||||
key={part.id}
|
||||
part={part}
|
||||
index={index}
|
||||
isHovered={hoveredId === part.id}
|
||||
isSelected={selectedId === part.id}
|
||||
isEditMode={isEditMode}
|
||||
onHover={setHoveredId}
|
||||
onClick={(id) => setSelectedId(prev => prev === id ? null : id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 预览模式按钮 */}
|
||||
{!isEditMode && (
|
||||
<div className="absolute bottom-12 flex flex-col items-center z-[120]">
|
||||
<button
|
||||
onClick={enterEdit}
|
||||
className="bg-black text-white px-14 py-5 font-black uppercase text-[10px] 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"
|
||||
>
|
||||
ENTER_EDIT_MODE <ChevronRight size={18} className="group-hover:translate-x-2 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* ── 页脚 ───────────────────────────────────────── */}
|
||||
<footer className="h-10 bg-white border-t-2 border-neutral-900 flex items-center justify-between px-8 text-[9px] font-bold text-neutral-300 uppercase tracking-[0.4em]">
|
||||
<div className="flex gap-8 items-center">
|
||||
<div className="flex items-center gap-2 text-neutral-900">
|
||||
<Anchor size={12} className="opacity-20" />
|
||||
<span className="tracking-widest opacity-70 italic">Disassembly_EditPreview</span>
|
||||
</div>
|
||||
<span className="opacity-20">|</span>
|
||||
<span className="opacity-40 tracking-tighter">
|
||||
{parts.length} NODE{parts.length !== 1 ? 'S' : ''} // {isEditMode ? 'EDIT' : 'PREVIEW'}_LOCK
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-neutral-900 font-black px-3 py-1 bg-neutral-50 border border-neutral-200">v1.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,568 @@
|
|||
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;
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
Activity,
|
||||
Settings,
|
||||
Zap,
|
||||
Cpu,
|
||||
MousePointer2,
|
||||
Anchor,
|
||||
ChevronUp,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 工业草稿风格 v7.2.0 (视觉增强版)
|
||||
* 核心优化:
|
||||
* 1. 视觉聚焦:显著放大底部组件图标,提升细节可见度。
|
||||
* 2. 极致简约:移除底部元数据标签,精简文字层次,聚焦核心名称。
|
||||
* 3. 比例优化:维持 V 型交错布局,优化大尺寸图标下的物理连接感。
|
||||
*/
|
||||
|
||||
const styles = `
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
.blueprint-grid {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
.blueprint-grid::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
|
||||
background-size: 10px 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.assembly-view {
|
||||
filter: drop-shadow(0 15px 30px rgba(0,0,0,0.1)) contrast(1.05);
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.assembly-view:hover {
|
||||
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.15)) contrast(1.1);
|
||||
}
|
||||
.leader-line-svg {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
.text-label-container {
|
||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.scan-effect {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 100%;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.01), transparent);
|
||||
animation: scanline 15s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SYSTEM_PARTS = [
|
||||
{ id: 'p1', name: '环境监控', code: 'ENV-X', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?auto=format&fit=crop&q=80&w=200', link: '#' },
|
||||
{ id: 'p2', name: '能源模组', code: 'BATT-V8', img: 'https://images.unsplash.com/photo-1619641259501-c88f28c6e355?auto=format&fit=crop&q=80&w=200', link: '#' },
|
||||
{ id: 'p3', name: '雷达阵列', code: 'LDR-07', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?auto=format&fit=crop&q=80&w=200', link: '#' },
|
||||
{ id: 'p4', name: '核心总成', code: 'CORE-MAX', img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80&w=200', link: '#' },
|
||||
{ id: 'p5', name: '液压单元', code: 'HYD-02', img: 'https://images.unsplash.com/photo-1635350736475-c8cef4b21906?auto=format&fit=crop&q=80&w=200', link: '#' },
|
||||
{ id: 'p6', name: '散热单元', code: 'COOL-F2', img: 'https://images.unsplash.com/photo-1635350736475-c8cef4b21906?auto=format&fit=crop&q=80&w=200', link: '#' },
|
||||
{ id: 'p7', name: '存储阵列', code: 'DATA-2T', img: 'https://images.unsplash.com/photo-1544006659-f0b21f04cb1d?auto=format&fit=crop&q=80&w=200', link: '#' }
|
||||
];
|
||||
|
||||
const ASSEMBLY_IMAGE = "https://images.unsplash.com/photo-1581092160562-40aa08e78837?auto=format&fit=crop&q=80&w=1600";
|
||||
|
||||
export default function App() {
|
||||
const [hoveredId, setHoveredId] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-[#ffffff] text-neutral-900 font-mono flex flex-col overflow-hidden select-none">
|
||||
<style>{styles}</style>
|
||||
|
||||
{/* 顶部标题栏 */}
|
||||
<header className="h-16 border-b border-neutral-200 bg-white flex items-center justify-between px-10 z-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-neutral-900 rounded-sm">
|
||||
<Cpu size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-[13px] font-black uppercase tracking-[0.4em] leading-none text-neutral-900">Linear_Draft_Nav</h1>
|
||||
<p className="text-[9px] text-neutral-400 mt-1 font-bold tracking-tighter">V7.2.0 // ENHANCED_VISUAL_ALIGN</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 text-neutral-400">
|
||||
<div className="flex flex-col items-end text-[9px] font-black uppercase tracking-widest">
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-neutral-900 rounded-full animate-pulse"></div>
|
||||
STATUS: NOMINAL
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 w-[1px] bg-neutral-100"></div>
|
||||
<Settings size={18} className="hover:text-neutral-900 cursor-pointer transition-colors" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主画布区域 */}
|
||||
<main className="flex-1 relative flex flex-col items-center justify-center p-12 overflow-hidden">
|
||||
<div className="absolute inset-0 blueprint-grid"></div>
|
||||
<div className="scan-effect"></div>
|
||||
|
||||
{/* 中心工业总成图 */}
|
||||
<div className="relative w-full flex-1 max-w-4xl max-h-[35vh] flex items-center justify-center z-10">
|
||||
|
||||
{/* 适配型角标 */}
|
||||
<div className="absolute -inset-4 pointer-events-none opacity-40">
|
||||
<div className="absolute top-0 left-0 w-12 h-12 border-t border-l border-neutral-600"></div>
|
||||
<div className="absolute bottom-0 right-0 w-12 h-12 border-b border-r border-neutral-600"></div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={ASSEMBLY_IMAGE}
|
||||
className="max-w-full max-h-full object-contain assembly-view"
|
||||
alt="Central Assembly System"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部组件导航集 */}
|
||||
<div className="w-full max-w-[1300px] mt-12 flex justify-between z-20 pb-72 px-10">
|
||||
{SYSTEM_PARTS.map((part, index) => {
|
||||
const isHovered = hoveredId === part.id;
|
||||
const isUp = index % 2 === 0;
|
||||
|
||||
// --- 物理常量:优化后的图标与交错位移 ---
|
||||
const ICON_SIZE = 80; // 放大图标容器 (原 64)
|
||||
const CENTER_AXIS_Y = 180;
|
||||
const OFFSET_Y = 40;
|
||||
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y;
|
||||
|
||||
// --- 垂直引导线逻辑 ---
|
||||
const SVG_CENTER_X = 50;
|
||||
const SVG_CENTER_Y = 40; // 适配大尺寸图标的起始点
|
||||
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
onMouseEnter={() => setHoveredId(part.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className="group flex flex-col items-center cursor-pointer relative flex-1"
|
||||
style={{ minWidth: '0' }}
|
||||
>
|
||||
{/* 垂直引导线 */}
|
||||
<svg
|
||||
className="absolute top-0 left-1/2 -translate-x-1/2 w-[100px] h-[400px] overflow-visible pointer-events-none z-0"
|
||||
viewBox={`0 0 100 400`}
|
||||
>
|
||||
<path
|
||||
d={`M ${SVG_CENTER_X} ${SVG_CENTER_Y} L ${SVG_CENTER_X} ${boxTop}`}
|
||||
fill="none"
|
||||
stroke={isHovered ? "#000" : "#f5f5f5"}
|
||||
strokeWidth={isHovered ? "2.5" : "1"}
|
||||
strokeDasharray={isHovered ? "none" : "3,3"}
|
||||
className="leader-line-svg"
|
||||
/>
|
||||
<circle cx={SVG_CENTER_X} cy={SVG_CENTER_Y} r="2.5" fill={isHovered ? "#000" : "#e0e0e0"} />
|
||||
<circle cx={SVG_CENTER_X} cy={boxTop} r={isHovered ? "4" : "2"} fill={isHovered ? "#000" : "#e0e0e0"} />
|
||||
</svg>
|
||||
|
||||
{/* 图标节点 - 已放大图片尺寸 */}
|
||||
<div
|
||||
className={`relative flex items-center justify-center transition-all duration-500 z-10
|
||||
${isHovered ? 'scale-110 -translate-y-2' : 'opacity-100 hover:opacity-100'}`}
|
||||
style={{ width: `${ICON_SIZE}px`, height: `${ICON_SIZE}px` }}
|
||||
>
|
||||
<img src={part.img} className="w-16 h-16 object-contain relative z-10" alt={part.name} />
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-neutral-900/5 blur-2xl rounded-full scale-125" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标签容器 - 移除底部元数据 */}
|
||||
<div
|
||||
className={`absolute flex flex-col items-center whitespace-nowrap text-label-container z-30
|
||||
${isHovered ? (isUp ? '-translate-y-1' : 'translate-y-1') : ''}`}
|
||||
style={{
|
||||
top: `${boxTop}px`,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
>
|
||||
{/* 接线端子 */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full border-2 border-white mb-3 transition-all
|
||||
${isHovered ? 'bg-neutral-900 scale-125' : 'bg-neutral-200'}`}
|
||||
/>
|
||||
|
||||
{/* 顶部指示器 */}
|
||||
<div className="flex items-center gap-1.5 mb-1.5 opacity-60">
|
||||
{isUp ? <ChevronUp size={8} /> : <div className="w-[8px]" />}
|
||||
<div className={`text-[8px] font-black uppercase tracking-widest transition-colors
|
||||
${isHovered ? 'text-neutral-900' : 'text-neutral-300'}`}>
|
||||
CODE.0{index + 1}
|
||||
</div>
|
||||
{!isUp ? <ChevronDown size={8} /> : <div className="w-[8px]" />}
|
||||
</div>
|
||||
|
||||
{/* 大字名称 */}
|
||||
<div className={`text-2xl font-black uppercase tracking-tighter transition-all duration-300
|
||||
${isHovered ? 'text-neutral-900 scale-105' : 'text-neutral-400'}`}>
|
||||
{part.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="h-10 bg-white border-t border-neutral-100 flex items-center justify-between px-10 text-[9px] font-bold text-neutral-300 uppercase tracking-[0.4em]">
|
||||
<div className="flex gap-10 items-center">
|
||||
<div className="flex items-center gap-2 text-neutral-900">
|
||||
<Anchor size={14} className="opacity-10" />
|
||||
<span className="tracking-widest opacity-80 italic">Visual_Enhanced_Array</span>
|
||||
</div>
|
||||
<span className="opacity-10">|</span>
|
||||
<span className="opacity-40 tracking-tighter">DESIGN_LOCK_V7.2.0</span>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-neutral-900 font-black px-3 py-1 bg-neutral-50 border border-neutral-100">B_7.2.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { createRequire } from 'module'
|
||||
const require = createRequire(import.meta.url)
|
||||
const { Pool } = require('d:/Project/gbmake/gb-payload/node_modules/.pnpm/pg@8.16.3/node_modules/pg')
|
||||
const pool = new Pool({ connectionString: 'postgres://gb-payload:123123@localhost/gb-payload' })
|
||||
const t = await pool.query(`SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename LIKE 'disassem%' ORDER BY tablename`)
|
||||
console.log('Disassembly tables:', t.rows.map((r: any) => r.tablename))
|
||||
const m = await pool.query('SELECT name FROM payload_migrations ORDER BY id')
|
||||
console.log('Migrations:', m.rows.map((r: any) => r.name))
|
||||
await pool.end()
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
/**
|
||||
* Fix script: Re-insert historical migration records and apply new migration
|
||||
* using a direct pg connection (no Payload init to avoid dev-mode push loops).
|
||||
*
|
||||
* Run with: pnpm tsx --env-file=.env scripts/fix-migrations.ts
|
||||
*/
|
||||
import { createRequire } from 'module'
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const { Pool } = require(
|
||||
'd:/Project/gbmake/gb-payload/node_modules/.pnpm/pg@8.16.3/node_modules/pg'
|
||||
)
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL ?? 'postgres://gb-payload:123123@localhost/gb-payload',
|
||||
})
|
||||
|
||||
async function run(sql: string, params: any[] = []) {
|
||||
const result = await pool.query(sql, params)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
// ── Check current migration state ────────────────────────────────────────────
|
||||
console.log('📋 Checking payload_migrations table...')
|
||||
const existing = await run(`SELECT name FROM payload_migrations ORDER BY id`)
|
||||
const existingNames = new Set(existing.map((r: any) => r.name))
|
||||
console.log('Currently recorded:', existingNames.size > 0 ? [...existingNames] : '(none)')
|
||||
|
||||
// ── Mark all historical migrations as done ────────────────────────────────────
|
||||
const historyMigrations = [
|
||||
'20260208_171142',
|
||||
'20260212_193303',
|
||||
'20260212_202303',
|
||||
'20260222_170233',
|
||||
'hero_slider_simplify',
|
||||
'product_recommendations_simplify',
|
||||
]
|
||||
|
||||
for (const name of historyMigrations) {
|
||||
if (!existingNames.has(name)) {
|
||||
await run(
|
||||
`INSERT INTO payload_migrations (name, batch, updated_at, created_at) VALUES ($1, 1, now(), now())`,
|
||||
[name]
|
||||
)
|
||||
console.log(`✅ Marked done: ${name}`)
|
||||
} else {
|
||||
console.log(`⏭️ Already recorded: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skip if new migration already applied ─────────────────────────────────────
|
||||
if (existingNames.has('20260223_disassembly_refactor')) {
|
||||
console.log('\n⏭️ Migration 20260223_disassembly_refactor already applied.')
|
||||
await pool.end()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── Show what disassembly tables exist ───────────────────────────────────────
|
||||
const disasmTables = await run(
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename LIKE 'disassembly%' ORDER BY tablename`
|
||||
)
|
||||
console.log('\nCurrent disassembly tables:', disasmTables.map((r: any) => r.tablename))
|
||||
|
||||
console.log('\n🔧 Applying: 20260223_disassembly_refactor ...')
|
||||
|
||||
try {
|
||||
// 1. disassembly_components
|
||||
await run(`CREATE TABLE IF NOT EXISTS disassembly_components (
|
||||
id serial PRIMARY KEY NOT NULL,
|
||||
label varchar NOT NULL,
|
||||
start_coordinate_x numeric DEFAULT 0 NOT NULL,
|
||||
start_coordinate_y numeric DEFAULT 0 NOT NULL,
|
||||
start_radius numeric DEFAULT 20 NOT NULL,
|
||||
updated_at timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
created_at timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_components_updated_at_idx ON disassembly_components USING btree (updated_at)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_components_created_at_idx ON disassembly_components USING btree (created_at)`)
|
||||
console.log(' ✅ disassembly_components')
|
||||
|
||||
// 2. disassembly_linked_products
|
||||
await run(`CREATE TABLE IF NOT EXISTS disassembly_linked_products (
|
||||
id serial PRIMARY KEY NOT NULL,
|
||||
coordinate_x numeric DEFAULT 0 NOT NULL,
|
||||
coordinate_y numeric DEFAULT 0 NOT NULL,
|
||||
product_name varchar,
|
||||
updated_at timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
created_at timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_linked_products_updated_at_idx ON disassembly_linked_products USING btree (updated_at)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_linked_products_created_at_idx ON disassembly_linked_products USING btree (created_at)`)
|
||||
console.log(' ✅ disassembly_linked_products')
|
||||
|
||||
// 3. disassembly_components_rels
|
||||
await run(`CREATE TABLE IF NOT EXISTS disassembly_components_rels (
|
||||
id serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
parent_id integer NOT NULL,
|
||||
path varchar NOT NULL,
|
||||
disassembly_linked_products_id integer
|
||||
)`)
|
||||
await run(`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'disassembly_components_rels_parent_fk') THEN
|
||||
ALTER TABLE disassembly_components_rels ADD CONSTRAINT disassembly_components_rels_parent_fk
|
||||
FOREIGN KEY (parent_id) REFERENCES disassembly_components(id) ON DELETE cascade;
|
||||
END IF;
|
||||
END $$`)
|
||||
await run(`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'disassembly_components_rels_disassembly_linked_products_fk') THEN
|
||||
ALTER TABLE disassembly_components_rels ADD CONSTRAINT disassembly_components_rels_disassembly_linked_products_fk
|
||||
FOREIGN KEY (disassembly_linked_products_id) REFERENCES disassembly_linked_products(id) ON DELETE cascade;
|
||||
END IF;
|
||||
END $$`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_components_rels_order_idx ON disassembly_components_rels USING btree ("order")`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_components_rels_parent_idx ON disassembly_components_rels USING btree (parent_id)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_components_rels_path_idx ON disassembly_components_rels USING btree (path)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_components_rels_disassembly_linked_products_id_idx ON disassembly_components_rels USING btree (disassembly_linked_products_id)`)
|
||||
console.log(' ✅ disassembly_components_rels')
|
||||
|
||||
// 4. disassembly_linked_products_rels
|
||||
await run(`CREATE TABLE IF NOT EXISTS disassembly_linked_products_rels (
|
||||
id serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
parent_id integer NOT NULL,
|
||||
path varchar NOT NULL,
|
||||
products_id integer,
|
||||
preorder_products_id integer
|
||||
)`)
|
||||
await run(`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'disassembly_linked_products_rels_parent_fk') THEN
|
||||
ALTER TABLE disassembly_linked_products_rels ADD CONSTRAINT disassembly_linked_products_rels_parent_fk
|
||||
FOREIGN KEY (parent_id) REFERENCES disassembly_linked_products(id) ON DELETE cascade;
|
||||
END IF;
|
||||
END $$`)
|
||||
await run(`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'disassembly_linked_products_rels_products_fk') THEN
|
||||
ALTER TABLE disassembly_linked_products_rels ADD CONSTRAINT disassembly_linked_products_rels_products_fk
|
||||
FOREIGN KEY (products_id) REFERENCES products(id) ON DELETE cascade;
|
||||
END IF;
|
||||
END $$`)
|
||||
await run(`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'disassembly_linked_products_rels_preorder_products_fk') THEN
|
||||
ALTER TABLE disassembly_linked_products_rels ADD CONSTRAINT disassembly_linked_products_rels_preorder_products_fk
|
||||
FOREIGN KEY (preorder_products_id) REFERENCES preorder_products(id) ON DELETE cascade;
|
||||
END IF;
|
||||
END $$`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_linked_products_rels_order_idx ON disassembly_linked_products_rels USING btree ("order")`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_linked_products_rels_parent_idx ON disassembly_linked_products_rels USING btree (parent_id)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_linked_products_rels_path_idx ON disassembly_linked_products_rels USING btree (path)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_linked_products_rels_products_id_idx ON disassembly_linked_products_rels USING btree (products_id)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_linked_products_rels_preorder_products_id_idx ON disassembly_linked_products_rels USING btree (preorder_products_id)`)
|
||||
console.log(' ✅ disassembly_linked_products_rels')
|
||||
|
||||
// 5. Update disassembly_pages_rels
|
||||
await run(`ALTER TABLE disassembly_pages_rels DROP CONSTRAINT IF EXISTS disassembly_pages_rels_products_fk`)
|
||||
await run(`ALTER TABLE disassembly_pages_rels DROP CONSTRAINT IF EXISTS disassembly_pages_rels_preorder_products_fk`)
|
||||
await run(`DROP INDEX IF EXISTS disassembly_pages_rels_products_id_idx`)
|
||||
await run(`DROP INDEX IF EXISTS disassembly_pages_rels_preorder_products_id_idx`)
|
||||
const pc = await run(`SELECT 1 FROM information_schema.columns WHERE table_name='disassembly_pages_rels' AND column_name='products_id'`)
|
||||
if (pc.length > 0) await run(`ALTER TABLE disassembly_pages_rels DROP COLUMN products_id`)
|
||||
const prc = await run(`SELECT 1 FROM information_schema.columns WHERE table_name='disassembly_pages_rels' AND column_name='preorder_products_id'`)
|
||||
if (prc.length > 0) await run(`ALTER TABLE disassembly_pages_rels DROP COLUMN preorder_products_id`)
|
||||
await run(`ALTER TABLE disassembly_pages_rels ADD COLUMN IF NOT EXISTS disassembly_components_id integer`)
|
||||
await run(`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'disassembly_pages_rels_disassembly_components_fk') THEN
|
||||
ALTER TABLE disassembly_pages_rels ADD CONSTRAINT disassembly_pages_rels_disassembly_components_fk
|
||||
FOREIGN KEY (disassembly_components_id) REFERENCES disassembly_components(id) ON DELETE cascade;
|
||||
END IF;
|
||||
END $$`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS disassembly_pages_rels_disassembly_components_id_idx ON disassembly_pages_rels USING btree (disassembly_components_id)`)
|
||||
console.log(' ✅ disassembly_pages_rels updated')
|
||||
|
||||
// 6. Update payload_locked_documents_rels
|
||||
await run(`ALTER TABLE payload_locked_documents_rels ADD COLUMN IF NOT EXISTS disassembly_components_id integer`)
|
||||
await run(`ALTER TABLE payload_locked_documents_rels ADD COLUMN IF NOT EXISTS disassembly_linked_products_id integer`)
|
||||
await run(`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payload_locked_documents_rels_disassembly_components_fk') THEN
|
||||
ALTER TABLE payload_locked_documents_rels ADD CONSTRAINT payload_locked_documents_rels_disassembly_components_fk
|
||||
FOREIGN KEY (disassembly_components_id) REFERENCES disassembly_components(id) ON DELETE cascade;
|
||||
END IF;
|
||||
END $$`)
|
||||
await run(`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payload_locked_documents_rels_disassembly_linked_products_fk') THEN
|
||||
ALTER TABLE payload_locked_documents_rels ADD CONSTRAINT payload_locked_documents_rels_disassembly_linked_products_fk
|
||||
FOREIGN KEY (disassembly_linked_products_id) REFERENCES disassembly_linked_products(id) ON DELETE cascade;
|
||||
END IF;
|
||||
END $$`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS payload_locked_documents_rels_disassembly_components_id_idx ON payload_locked_documents_rels USING btree (disassembly_components_id)`)
|
||||
await run(`CREATE INDEX IF NOT EXISTS payload_locked_documents_rels_disassembly_linked_products_id_idx ON payload_locked_documents_rels USING btree (disassembly_linked_products_id)`)
|
||||
console.log(' ✅ payload_locked_documents_rels updated')
|
||||
|
||||
// 7. Drop old array tables
|
||||
await run(`DROP TABLE IF EXISTS disassembly_pages_components_linked_products CASCADE`)
|
||||
await run(`DROP TABLE IF EXISTS disassembly_pages_components CASCADE`)
|
||||
console.log(' ✅ Old array tables dropped')
|
||||
|
||||
// 8. Record migration
|
||||
await run(
|
||||
`INSERT INTO payload_migrations (name, batch, updated_at, created_at) VALUES ($1, 2, now(), now())`,
|
||||
['20260223_disassembly_refactor']
|
||||
)
|
||||
console.log('\n🎉 Migration 20260223_disassembly_refactor applied successfully!')
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('\n❌ Error:', err?.message ?? err)
|
||||
await pool.end()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await pool.end()
|
||||
process.exit(0)
|
||||
Loading…
Reference in New Issue