548 lines
22 KiB
TypeScript
548 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Bot,
|
||
Clapperboard,
|
||
FileOutput,
|
||
Film,
|
||
LayoutPanelTop,
|
||
ScanSearch,
|
||
Sparkles,
|
||
} from "lucide-react";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
getVideoWorkflowNodeDefinition,
|
||
getVideoWorkflowQuickConnectOutputs,
|
||
} from "@/lib/video-workflow/catalog";
|
||
import type { CanvasNode, WorkflowNodeData } from "@/types/canvas";
|
||
|
||
interface WorkflowNodeProps {
|
||
node: CanvasNode;
|
||
isSelected: boolean;
|
||
}
|
||
|
||
function toAlphaColor(color: string | undefined, alpha: number) {
|
||
const normalized = String(color || "").trim();
|
||
if (!normalized.startsWith("#")) return undefined;
|
||
|
||
const hex = normalized.slice(1);
|
||
if (hex.length !== 3 && hex.length !== 6) return undefined;
|
||
|
||
const full = hex.length === 3 ? hex.split("").map((char) => char + char).join("") : hex;
|
||
const int = Number.parseInt(full, 16);
|
||
if (Number.isNaN(int)) return undefined;
|
||
|
||
const red = (int >> 16) & 255;
|
||
const green = (int >> 8) & 255;
|
||
const blue = int & 255;
|
||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||
}
|
||
|
||
const GROUP_META = {
|
||
input: {
|
||
label: "输入",
|
||
icon: LayoutPanelTop,
|
||
},
|
||
analysis: {
|
||
label: "分析",
|
||
icon: ScanSearch,
|
||
},
|
||
storyboard: {
|
||
label: "分镜",
|
||
icon: Film,
|
||
},
|
||
generation: {
|
||
label: "生成",
|
||
icon: Sparkles,
|
||
},
|
||
character: {
|
||
label: "角色场景",
|
||
icon: Clapperboard,
|
||
},
|
||
tool: {
|
||
label: "工具",
|
||
icon: Bot,
|
||
},
|
||
output: {
|
||
label: "输出",
|
||
icon: FileOutput,
|
||
},
|
||
} as const;
|
||
|
||
const STATUS_LABELS = {
|
||
pending: "待配置",
|
||
generating: "处理中",
|
||
completed: "已完成",
|
||
failed: "异常",
|
||
} as const;
|
||
|
||
export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
|
||
const data = node.data as WorkflowNodeData;
|
||
const definition = getVideoWorkflowNodeDefinition(data.templateType);
|
||
const metadata = (data.metadata || {}) as Record<string, any>;
|
||
const quickConnectOutputs =
|
||
data.templateType && definition
|
||
? getVideoWorkflowQuickConnectOutputs(definition.type)
|
||
: [];
|
||
const resolvedGroup = (data.group || definition?.group || "tool") as keyof typeof GROUP_META;
|
||
const groupMeta = GROUP_META[resolvedGroup] || GROUP_META.tool;
|
||
const GroupIcon = groupMeta.icon;
|
||
const accentColor = data.accentColor || definition?.accentColor || "#e2e8f0";
|
||
const title = data.title || definition?.label || "工作流节点";
|
||
const description = data.description || definition?.description || "等待补充节点配置。";
|
||
const previewUrl =
|
||
String(metadata.previewMediaUrl || metadata.resultMediaUrl || "").trim();
|
||
const previewKind =
|
||
String(metadata.previewMediaKind || metadata.resultMediaKind || "").trim() === "video"
|
||
? "video"
|
||
: "image";
|
||
const storyboardShots = Array.isArray(metadata.storyboardShots)
|
||
? metadata.storyboardShots.slice(0, 4)
|
||
: [];
|
||
const analysisHighlights = Array.isArray(metadata.analysisHighlights)
|
||
? metadata.analysisHighlights.slice(0, 4)
|
||
: [];
|
||
const compareItems = Array.isArray(metadata.compareItems)
|
||
? metadata.compareItems.slice(0, 4)
|
||
: [];
|
||
const tableColumns = Array.isArray(metadata.tableColumns)
|
||
? metadata.tableColumns.slice(0, 4)
|
||
: [];
|
||
const tableRows = Array.isArray(metadata.tableRows)
|
||
? metadata.tableRows.slice(0, 4)
|
||
: [];
|
||
const characters = Array.isArray(metadata.characters)
|
||
? metadata.characters.slice(0, 3)
|
||
: [];
|
||
const scenes = Array.isArray(metadata.scenes) ? metadata.scenes.slice(0, 3) : [];
|
||
const characterAssets = Array.isArray(metadata.characterAssets)
|
||
? metadata.characterAssets.slice(0, 3)
|
||
: [];
|
||
const sceneAssets = Array.isArray(metadata.sceneAssets)
|
||
? metadata.sceneAssets.slice(0, 3)
|
||
: [];
|
||
const agentPlan = Array.isArray(metadata.agentPlan)
|
||
? metadata.agentPlan.slice(0, 4)
|
||
: [];
|
||
const deliverables = Array.isArray(metadata.deliverables)
|
||
? metadata.deliverables.slice(0, 4)
|
||
: [];
|
||
const linkedAgentProjectId = String(metadata.linkedAgentProjectId || "").trim();
|
||
const linkedAgentProjectTitle = String(metadata.linkedAgentProjectTitle || "").trim();
|
||
const importedAgentResultCount = Math.max(Number(metadata.importedAgentResultCount || 0), 0);
|
||
const pendingAgentResultCount = Math.max(Number(metadata.pendingAgentResultCount || 0), 0);
|
||
const agentResultGroups = Array.isArray(metadata.agentResultGroups)
|
||
? metadata.agentResultGroups.slice(0, 4)
|
||
: [];
|
||
const batchShotCount = Math.max(
|
||
Number(metadata.batchShotCount || 0),
|
||
storyboardShots.length
|
||
);
|
||
const batchResultCount = Number(metadata.batchResultCount || 0);
|
||
const batchFailedCount = Number(metadata.batchFailedCount || 0);
|
||
const batchMode =
|
||
String(metadata.batchMode || "").trim() === "gen-video" ? "视频批量" : "图片批量";
|
||
const accentGlow = toAlphaColor(accentColor, 0.18) || "rgba(59, 130, 246, 0.12)";
|
||
const accentLine = toAlphaColor(accentColor, 0.3) || "rgba(59, 130, 246, 0.2)";
|
||
const sectionClassName =
|
||
"rounded-2xl border border-border/60 bg-background/74 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.3)]";
|
||
const mutedKickerClassName =
|
||
"text-[11px] uppercase tracking-[0.18em] text-muted-foreground/80";
|
||
const itemClassName = "rounded-xl border border-border/50 bg-background/88 px-3 py-2";
|
||
const badgeClassName = "border-border/60 bg-background/82 text-foreground/80";
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative h-full w-full overflow-hidden rounded-[28px] border border-border/60 bg-background/95 shadow-[0_22px_55px_-30px_rgba(15,23,42,0.38)] backdrop-blur-xl transition-all",
|
||
isSelected &&
|
||
"border-primary/55 ring-2 ring-primary/15 shadow-[0_26px_60px_-28px_rgba(59,130,246,0.28)]"
|
||
)}
|
||
>
|
||
<div
|
||
className="pointer-events-none absolute inset-0"
|
||
style={{
|
||
backgroundImage: `radial-gradient(circle at top left, ${accentGlow} 0%, transparent 42%), linear-gradient(180deg, ${accentLine} 0%, transparent 18%)`,
|
||
}}
|
||
/>
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-border/70" />
|
||
|
||
<div className="relative flex h-full flex-col gap-4 p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-border/60 bg-background/90 shadow-sm">
|
||
<GroupIcon className="h-4 w-4" />
|
||
</span>
|
||
<span>{groupMeta.label}</span>
|
||
</div>
|
||
<div>
|
||
<h3 className="text-base font-semibold leading-tight text-foreground">{title}</h3>
|
||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p>
|
||
</div>
|
||
</div>
|
||
<Badge variant="outline" className={badgeClassName}>
|
||
{STATUS_LABELS[data.status] || STATUS_LABELS.pending}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>节点标识</div>
|
||
<div className="mt-1 font-mono text-xs text-foreground/80">{data.templateType}</div>
|
||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||
{data.summary || "把这个节点接在上游素材之后,用来承接处理或输出。"}
|
||
</p>
|
||
</div>
|
||
|
||
{batchShotCount > 0 || batchResultCount > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>批量执行</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{batchShotCount > 0 ? (
|
||
<Badge variant="outline" className={badgeClassName}>
|
||
镜头 {batchShotCount}
|
||
</Badge>
|
||
) : null}
|
||
{batchResultCount > 0 ? (
|
||
<Badge variant="outline" className={badgeClassName}>
|
||
结果 {batchResultCount}
|
||
</Badge>
|
||
) : null}
|
||
{batchFailedCount > 0 ? (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-amber-200/70 bg-amber-50/80 text-amber-700"
|
||
>
|
||
失败 {batchFailedCount}
|
||
</Badge>
|
||
) : null}
|
||
{batchResultCount > 0 ? (
|
||
<Badge variant="outline" className={badgeClassName}>
|
||
{batchMode}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{previewUrl ? (
|
||
<div className="overflow-hidden rounded-2xl border border-border/60 bg-slate-950/95 shadow-[0_16px_40px_-28px_rgba(15,23,42,0.65)]">
|
||
{previewKind === "video" ? (
|
||
<video
|
||
src={previewUrl}
|
||
className="h-36 w-full object-cover"
|
||
controls
|
||
muted
|
||
playsInline
|
||
/>
|
||
) : (
|
||
<img
|
||
src={previewUrl}
|
||
alt={title}
|
||
className="h-36 w-full object-cover"
|
||
/>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{compareItems.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>对比结果</div>
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
{compareItems.map((item, index) => {
|
||
const compareItem = (item || {}) as Record<string, any>;
|
||
const kind =
|
||
String(compareItem.kind || "").trim() === "video" ? "video" : "image";
|
||
const url = String(compareItem.url || "").trim();
|
||
if (!url) {
|
||
return null;
|
||
}
|
||
return (
|
||
<div
|
||
key={`${data.templateType}-compare-${index}`}
|
||
className="overflow-hidden rounded-2xl border border-border/60 bg-background/88"
|
||
>
|
||
<div className="border-b border-border/60 px-3 py-2">
|
||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
|
||
{String(compareItem.label || `对比 ${index + 1}`)}
|
||
</div>
|
||
<div className="mt-1 truncate text-xs font-medium text-foreground/90">
|
||
{String(compareItem.title || `素材 ${index + 1}`)}
|
||
</div>
|
||
</div>
|
||
{kind === "video" ? (
|
||
<video
|
||
src={url}
|
||
className="h-24 w-full object-cover"
|
||
controls
|
||
muted
|
||
playsInline
|
||
/>
|
||
) : (
|
||
<img
|
||
src={url}
|
||
alt={String(compareItem.title || `对比素材 ${index + 1}`)}
|
||
className="h-24 w-full object-cover"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{storyboardShots.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>镜头草案</div>
|
||
<div className="mt-2 space-y-2">
|
||
{storyboardShots.map((item, index) => {
|
||
const shot = (item || {}) as Record<string, any>;
|
||
return (
|
||
<div key={`${data.templateType}-shot-${index}`} className={itemClassName}>
|
||
<div className="text-xs font-semibold text-foreground/90">
|
||
{String(shot.title || `镜头 ${index + 1}`)}
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||
{String(shot.description || "")}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{tableColumns.length > 0 && tableRows.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>结构化表格</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{tableColumns.map((item, index) => {
|
||
const column = (item || {}) as Record<string, any>;
|
||
return (
|
||
<Badge
|
||
key={`${data.templateType}-column-${index}`}
|
||
variant="outline"
|
||
className={badgeClassName}
|
||
>
|
||
{String(column.label || column.key || `列 ${index + 1}`)}
|
||
</Badge>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mt-2 space-y-2">
|
||
{tableRows.map((item, index) => {
|
||
const row = (item || {}) as Record<string, any>;
|
||
return (
|
||
<div key={`${data.templateType}-row-${index}`} className={itemClassName}>
|
||
{tableColumns.map((columnItem, columnIndex) => {
|
||
const column = (columnItem || {}) as Record<string, any>;
|
||
const columnKey = String(column.key || `col-${columnIndex}`).trim();
|
||
const label = String(column.label || columnKey || `列 ${columnIndex + 1}`).trim();
|
||
const value = String(row[columnKey] || "").trim();
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
return (
|
||
<div
|
||
key={`${data.templateType}-row-${index}-column-${columnIndex}`}
|
||
className={columnIndex > 0 ? "mt-2" : ""}
|
||
>
|
||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
|
||
{label}
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-foreground/85">
|
||
{value}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{analysisHighlights.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>分析摘要</div>
|
||
<div className="mt-2 space-y-2">
|
||
{analysisHighlights.map((item, index) => (
|
||
<div
|
||
key={`${data.templateType}-analysis-${index}`}
|
||
className={cn(itemClassName, "text-xs leading-5 text-muted-foreground")}
|
||
>
|
||
{String(item || "")}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{characters.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>角色线索</div>
|
||
<div className="mt-2 space-y-2">
|
||
{characters.map((item, index) => {
|
||
const character = (item || {}) as Record<string, any>;
|
||
return (
|
||
<div key={`${data.templateType}-character-${index}`} className={itemClassName}>
|
||
<div className="text-xs font-semibold text-foreground/90">
|
||
{String(character.name || character.title || `角色 ${index + 1}`)}
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||
{String(character.description || character.prompt || "")}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{scenes.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>场景线索</div>
|
||
<div className="mt-2 space-y-2">
|
||
{scenes.map((item, index) => {
|
||
const scene = (item || {}) as Record<string, any>;
|
||
return (
|
||
<div key={`${data.templateType}-scene-${index}`} className={itemClassName}>
|
||
<div className="text-xs font-semibold text-foreground/90">
|
||
{String(scene.name || scene.title || `场景 ${index + 1}`)}
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||
{String(scene.description || scene.prompt || "")}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{characterAssets.length > 0 || sceneAssets.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>资产沉淀</div>
|
||
<div className="mt-2 space-y-2">
|
||
{[...characterAssets, ...sceneAssets].slice(0, 4).map((item, index) => {
|
||
const asset = (item || {}) as Record<string, any>;
|
||
return (
|
||
<div key={`${data.templateType}-asset-${index}`} className={itemClassName}>
|
||
<div className="text-xs font-semibold text-foreground/90">
|
||
{String(asset.name || asset.title || `资产 ${index + 1}`)}
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||
{String(asset.description || asset.prompt || "")}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{agentPlan.length > 0 || deliverables.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>代理计划</div>
|
||
<div className="mt-2 space-y-2">
|
||
{agentPlan.map((item, index) => (
|
||
<div
|
||
key={`${data.templateType}-plan-${index}`}
|
||
className={cn(itemClassName, "text-xs leading-5 text-muted-foreground")}
|
||
>
|
||
{String(item || "")}
|
||
</div>
|
||
))}
|
||
{deliverables.map((item, index) => (
|
||
<div
|
||
key={`${data.templateType}-deliverable-${index}`}
|
||
className="rounded-xl border border-dashed border-border/60 bg-background/82 px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||
>
|
||
交付物: {String(item || "")}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{linkedAgentProjectId ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>Agent 工作台</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<Badge variant="outline" className={badgeClassName}>
|
||
已连接
|
||
</Badge>
|
||
{importedAgentResultCount > 0 ? (
|
||
<Badge variant="outline" className={badgeClassName}>
|
||
已回流 {importedAgentResultCount}
|
||
</Badge>
|
||
) : null}
|
||
{pendingAgentResultCount > 0 ? (
|
||
<Badge variant="outline" className={badgeClassName}>
|
||
待回流 {pendingAgentResultCount}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-2 text-xs font-medium leading-5 text-foreground/90">
|
||
{linkedAgentProjectTitle || linkedAgentProjectId}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{agentResultGroups.length > 0 ? (
|
||
<div className={sectionClassName}>
|
||
<div className={mutedKickerClassName}>Agent 结果</div>
|
||
<div className="mt-2 space-y-2">
|
||
{agentResultGroups.map((item, index) => {
|
||
const group = (item || {}) as Record<string, any>;
|
||
const label = String(group.label || `结果组 ${index + 1}`).trim();
|
||
const count = Math.max(Number(group.count || 0), 0);
|
||
const importedCount = Math.max(Number(group.importedCount || 0), 0);
|
||
return (
|
||
<div
|
||
key={`${data.templateType}-agent-group-${index}`}
|
||
className={itemClassName}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-xs font-semibold text-foreground/90">{label}</div>
|
||
<div className="text-[11px] text-muted-foreground">
|
||
{importedCount}/{count}
|
||
</div>
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||
共 {count} 个结果,已回流 {importedCount} 个。
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-auto space-y-2">
|
||
<div className={mutedKickerClassName}>下一步推荐</div>
|
||
{quickConnectOutputs.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{quickConnectOutputs.slice(0, 4).map((item) => (
|
||
<span
|
||
key={`${data.templateType}-${item.type}`}
|
||
className="rounded-full border border-border/60 bg-background/90 px-2.5 py-1 text-xs text-foreground/80 shadow-sm"
|
||
>
|
||
{item.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-full border border-dashed border-border/70 bg-background/72 px-3 py-1.5 text-xs text-muted-foreground">
|
||
当前节点通常作为流程终点或独立处理节点
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|