126 lines
4.7 KiB
TypeScript
126 lines
4.7 KiB
TypeScript
'use client'
|
|
|
|
import React from 'react'
|
|
import { type Area, imgUrl } from './types'
|
|
|
|
// ── 常量 ─────────────────────────────────────────────────────────────────────
|
|
export const ICON_SIZE = 80
|
|
export const CENTER_AXIS_Y = 180
|
|
export const OFFSET_Y = 40
|
|
|
|
// ── SVG 箭头 ──────────────────────────────────────────────────────────────────
|
|
const ChevronUp = () => (
|
|
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
<polyline points="18 15 12 9 6 15" />
|
|
</svg>
|
|
)
|
|
const ChevronDown = () => (
|
|
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
<polyline points="6 9 12 15 18 9" />
|
|
</svg>
|
|
)
|
|
|
|
// ── AreaNode ──────────────────────────────────────────────────────────────────
|
|
|
|
interface Props {
|
|
area: Area
|
|
index: number
|
|
isHovered: boolean
|
|
onHover: (id: string | null) => void
|
|
onEdit: (id: string) => void
|
|
}
|
|
|
|
export default function AreaNode({ area, index, isHovered, onHover, onEdit }: Props) {
|
|
const isUp = index % 2 === 0
|
|
const boxTop = isUp ? CENTER_AXIS_Y - OFFSET_Y : CENTER_AXIS_Y + OFFSET_Y
|
|
const thumb = imgUrl(area.thumbnailImage)
|
|
|
|
return (
|
|
// 固定高度容器,避免 hover 时触发画布 reflow
|
|
<div
|
|
style={{ flex: 1, minWidth: 0, height: 360, position: 'relative' }}
|
|
onMouseEnter={() => onHover(area.id)}
|
|
onMouseLeave={() => onHover(null)}
|
|
onClick={() => onEdit(area.id)}
|
|
>
|
|
{/* 整体节点包裹(绝对定位,不参与 flex 布局计算)*/}
|
|
<div
|
|
className="dep-node-wrap"
|
|
title={`点击编辑:${area.name}`}
|
|
style={{ position: 'absolute', inset: 0 }}
|
|
>
|
|
{/* 引线 */}
|
|
<svg
|
|
style={{
|
|
position: 'absolute', top: 0, left: '50%',
|
|
transform: 'translateX(-50%)', width: 100, height: 400,
|
|
overflow: 'visible', pointerEvents: 'none', zIndex: 0,
|
|
}}
|
|
viewBox="0 0 100 400"
|
|
>
|
|
<path
|
|
d={`M 50 40 L 50 ${boxTop}`}
|
|
fill="none"
|
|
stroke={isHovered ? '#000' : '#f5f5f5'}
|
|
strokeWidth={isHovered ? '2.5' : '1'}
|
|
strokeDasharray={isHovered ? 'none' : '3,3'}
|
|
className="dep-leader"
|
|
/>
|
|
<circle cx="50" cy="40" r="2.5" fill={isHovered ? '#000' : '#e0e0e0'} />
|
|
<circle cx="50" cy={boxTop} r={isHovered ? 4 : 2} fill={isHovered ? '#000' : '#e0e0e0'} />
|
|
</svg>
|
|
|
|
{/* 图标 */}
|
|
<div
|
|
className="dep-icon-node"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0, left: '50%', transform: 'translateX(-50%)',
|
|
display: 'flex', alignItems: 'center',
|
|
justifyContent: 'center', width: ICON_SIZE, height: ICON_SIZE, zIndex: 10,
|
|
}}
|
|
>
|
|
{thumb ? (
|
|
<img
|
|
src={thumb}
|
|
style={{ width: 64, height: 64, objectFit: 'contain', zIndex: 10, position: 'relative' }}
|
|
alt={area.name}
|
|
/>
|
|
) : (
|
|
<div style={{
|
|
width: 64, height: 64, display: 'flex', alignItems: 'center',
|
|
justifyContent: 'center', border: '1px dashed #d4d4d4', background: '#fafafa',
|
|
}}>
|
|
<span style={{
|
|
fontSize: 7, color: '#a3a3a3', textTransform: 'uppercase',
|
|
fontWeight: 700, letterSpacing: '.1em', textAlign: 'center', lineHeight: 1.4,
|
|
}}>NO<br />IMG</span>
|
|
</div>
|
|
)}
|
|
<div className="dep-glow" />
|
|
</div>
|
|
|
|
{/* 标签 */}
|
|
<div
|
|
className="dep-label"
|
|
style={{
|
|
position: 'absolute', display: 'flex', flexDirection: 'column',
|
|
alignItems: 'center', whiteSpace: 'nowrap',
|
|
top: `${boxTop}px`, left: '50%',
|
|
transform: `translateX(-50%)${isHovered ? (isUp ? ' translateY(-4px)' : ' translateY(4px)') : ''}`,
|
|
zIndex: 30,
|
|
}}
|
|
>
|
|
<div className="dep-terminal-dot" />
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, opacity: 0.6 }}>
|
|
{isUp ? <ChevronUp /> : <div style={{ width: 8 }} />}
|
|
<span className="dep-code">CODE.{String(index + 1).padStart(2, '0')}</span>
|
|
{!isUp ? <ChevronDown /> : <div style={{ width: 8 }} />}
|
|
</div>
|
|
<div className="dep-name">{area.name}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|