102 lines
3.3 KiB
TypeScript
102 lines
3.3 KiB
TypeScript
"use client"
|
|
import { type ReactNode } from "react"
|
|
import { Handle, Position } from "@xyflow/react"
|
|
import { CheckCircle2, Loader2, AlertCircle, Pin } from "lucide-react"
|
|
import { ResizeRight, ResizeBR } from "./resize-handle"
|
|
|
|
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) {
|
|
return (
|
|
<div
|
|
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 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>
|
|
)
|
|
}
|