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

123 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}