123 lines
4.1 KiB
TypeScript
123 lines
4.1 KiB
TypeScript
"use client"
|
||
import { type ReactNode, useEffect, useRef } from "react"
|
||
import { Handle, Position } from "@xyflow/react"
|
||
import { CheckCircle2, Loader2, AlertCircle, Pin } from "lucide-react"
|
||
import { ResizeRight, ResizeBR } from "./resize-handle"
|
||
|
||
// 节点正文 zoom 范围:基准 320px (= 1),到 640px 时放大到 ~1.6
|
||
const BASE_WIDTH = 320
|
||
const MIN_ZOOM = 1
|
||
const MAX_ZOOM = 1.6
|
||
|
||
export type NodeKind = "input" | "process" | "ai" | "output"
|
||
export type NodeStatus = "pending" | "running" | "done" | "failed"
|
||
|
||
interface Props {
|
||
type: NodeKind
|
||
status: NodeStatus
|
||
icon?: ReactNode
|
||
title: string
|
||
subtitle?: string
|
||
width?: number | string // 默认 '100%':让外层 ReactFlow node wrapper (node.style.width) 决定,配合 NodeResizeControl 用户可拖
|
||
selected?: boolean
|
||
hasTarget?: boolean
|
||
hasSource?: boolean
|
||
pinned?: boolean // 钉下 → 锁定位置与尺寸,不可拖动、不显示 resize 把手
|
||
onTogglePin?: () => void
|
||
children?: ReactNode
|
||
}
|
||
|
||
const STATUS_DOT: Record<NodeStatus, string> = {
|
||
pending: "status-dot",
|
||
running: "status-dot status-dot--running",
|
||
done: "status-dot status-dot--done",
|
||
failed: "status-dot status-dot--failed",
|
||
}
|
||
|
||
const STATUS_LABEL: Record<NodeStatus, string> = {
|
||
pending: "待运行",
|
||
running: "运行中",
|
||
done: "完成",
|
||
failed: "失败",
|
||
}
|
||
|
||
export function NodeShell({
|
||
type,
|
||
status,
|
||
icon,
|
||
title,
|
||
subtitle,
|
||
width = "100%",
|
||
selected,
|
||
hasTarget = true,
|
||
hasSource = true,
|
||
pinned = false,
|
||
onTogglePin,
|
||
children,
|
||
}: Props) {
|
||
const rootRef = useRef<HTMLDivElement>(null)
|
||
const bodyRef = useRef<HTMLDivElement>(null)
|
||
// 卡片宽度变化 → 给 body 设 zoom,让内部文字 / 按钮 / 间距按比例放大
|
||
useEffect(() => {
|
||
const root = rootRef.current
|
||
const body = bodyRef.current
|
||
if (!root || !body) return
|
||
const ro = new ResizeObserver(() => {
|
||
const w = root.clientWidth
|
||
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, w / BASE_WIDTH))
|
||
body.style.zoom = String(zoom)
|
||
})
|
||
ro.observe(root)
|
||
return () => ro.disconnect()
|
||
}, [])
|
||
return (
|
||
<div
|
||
ref={rootRef}
|
||
className={`glass-node ${selected ? "glass-node--selected" : ""} ${status === "running" ? "glass-node--running" : ""} ${pinned ? "glass-node--pinned" : ""}`}
|
||
data-type={type}
|
||
style={{ width, height: "100%" }}
|
||
>
|
||
{hasTarget && <Handle type="target" position={Position.Left} />}
|
||
|
||
<div className="glass-node__header">
|
||
{icon ? <span className="inline-flex h-5 w-5 items-center justify-center">{icon}</span> : null}
|
||
<span className="text-[13px]">{title}</span>
|
||
<span className="ml-auto flex items-center gap-1.5">
|
||
{status === "running" ? <Loader2 className="h-3 w-3 animate-spin" /> :
|
||
status === "done" ? <CheckCircle2 className="h-3 w-3" /> :
|
||
status === "failed" ? <AlertCircle className="h-3 w-3" /> : null}
|
||
<span className={STATUS_DOT[status]} />
|
||
{onTogglePin && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onTogglePin() }}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
title={pinned ? "已钉住 · 点击取消(恢复可拖、可缩放)" : "钉住 · 锁定位置与尺寸"}
|
||
className={`nodrag inline-flex h-5 w-5 items-center justify-center rounded transition ${
|
||
pinned
|
||
? "bg-violet-500/85 text-white shadow"
|
||
: "text-white/55 hover:bg-white/15 hover:text-white"
|
||
}`}
|
||
>
|
||
<Pin className={`h-3 w-3 ${pinned ? "" : "rotate-45"}`} />
|
||
</button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
<div ref={bodyRef} className="glass-node__body">
|
||
{subtitle && (
|
||
<div className="text-[11px] mb-2 glass-node__kbd uppercase tracking-widest">
|
||
{subtitle} · {STATUS_LABEL[status]}
|
||
</div>
|
||
)}
|
||
{children}
|
||
</div>
|
||
|
||
{hasSource && <Handle type="source" position={Position.Right} />}
|
||
{!pinned && <ResizeRight />}
|
||
{!pinned && <ResizeBR />}
|
||
</div>
|
||
)
|
||
}
|