Files
20260512-skg-tk/web/components/nodes/node-shell.tsx

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>
)
}