1679 lines
58 KiB
TypeScript
1679 lines
58 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Bell,
|
||
Box,
|
||
ChevronDown,
|
||
Clock3,
|
||
Copy,
|
||
Crown,
|
||
FileText,
|
||
GitBranch,
|
||
Headphones,
|
||
HelpCircle,
|
||
Image as ImageIcon,
|
||
Layers,
|
||
Loader2,
|
||
MousePointer2,
|
||
Music2,
|
||
Plus,
|
||
Save,
|
||
Search,
|
||
Share2,
|
||
Sparkles,
|
||
UserRound,
|
||
Video,
|
||
X,
|
||
Zap,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
type PointerEvent as ReactPointerEvent,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
|
||
import { cn } from "@/lib/utils";
|
||
import { canvasService } from "@/services/canvas";
|
||
|
||
type ActivePanel =
|
||
| "add"
|
||
| "workflow"
|
||
| "assets"
|
||
| "history"
|
||
| "help"
|
||
| "support"
|
||
| "skills"
|
||
| "share"
|
||
| "publish"
|
||
| "member"
|
||
| "notifications"
|
||
| "account"
|
||
| null;
|
||
|
||
type CapturedTool = {
|
||
type?: string;
|
||
toolKey?: string;
|
||
modelKey?: string;
|
||
modelName?: string;
|
||
vendor?: string;
|
||
scene?: string;
|
||
modes?: string[];
|
||
settings?: string[];
|
||
advancedSettings?: string[];
|
||
propertyKeys?: string[];
|
||
generateTypes?: unknown;
|
||
rules?: unknown;
|
||
metadata?: {
|
||
properties?: Record<string, unknown>;
|
||
config?: Record<string, unknown>;
|
||
generateTypes?: unknown;
|
||
rules?: unknown;
|
||
[key: string]: unknown;
|
||
};
|
||
};
|
||
|
||
type WorkflowPreset = {
|
||
id?: string | number;
|
||
uuid?: string;
|
||
name?: string;
|
||
title?: string;
|
||
description?: string;
|
||
cover?: string;
|
||
};
|
||
|
||
type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save";
|
||
|
||
type CloneNode = {
|
||
id: string;
|
||
kind: CloneNodeKind;
|
||
title: string;
|
||
subtitle?: string;
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
accent: string;
|
||
tool?: CapturedTool;
|
||
workflow?: WorkflowPreset;
|
||
inputType?: "image" | "video" | "audio" | "text";
|
||
draftValues?: Record<string, unknown>;
|
||
};
|
||
|
||
type CloneEdge = {
|
||
id: string;
|
||
source: string;
|
||
target: string;
|
||
};
|
||
|
||
type DragState =
|
||
| {
|
||
kind: "node";
|
||
id: string;
|
||
startX: number;
|
||
startY: number;
|
||
nodeX: number;
|
||
nodeY: number;
|
||
}
|
||
| {
|
||
kind: "canvas";
|
||
startX: number;
|
||
startY: number;
|
||
viewportX: number;
|
||
viewportY: number;
|
||
};
|
||
|
||
type LiblibVideoCanvasReplicaProps = {
|
||
canvasId: string;
|
||
isLoading?: boolean;
|
||
};
|
||
|
||
const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[];
|
||
const capturedCounts = (capturedCanvasModules as any).counts || {};
|
||
const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[];
|
||
|
||
const TOOL_TYPE_META: Record<
|
||
string,
|
||
{ label: string; icon: LucideIcon; accent: string; inputType?: CloneNode["inputType"] }
|
||
> = {
|
||
image: { label: "图片", icon: ImageIcon, accent: "#8b96ff", inputType: "image" },
|
||
video: { label: "视频", icon: Video, accent: "#8b96ff", inputType: "video" },
|
||
text: { label: "文本", icon: FileText, accent: "#f0d06b", inputType: "text" },
|
||
audio: { label: "音频", icon: Music2, accent: "#9ee6bb", inputType: "audio" },
|
||
compose: { label: "组合", icon: GitBranch, accent: "#ffb8c8" },
|
||
};
|
||
|
||
const PANEL_TABS = ["全部", "图片", "视频", "文本", "音频", "组合"] as const;
|
||
|
||
const STARTER_CARDS = [
|
||
{
|
||
title: "故事脚本生成",
|
||
subtitle: "脚本到分镜",
|
||
coverIndex: 0,
|
||
steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"],
|
||
},
|
||
{
|
||
title: "角色三视图",
|
||
subtitle: "角色资产沉淀",
|
||
coverIndex: 4,
|
||
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
|
||
},
|
||
{
|
||
title: "首帧图生视频",
|
||
subtitle: "参考图到成片",
|
||
coverIndex: 10,
|
||
steps: ["上传图片", "首帧图", "可灵 3.0", "预览"],
|
||
},
|
||
{
|
||
title: "音频生视频",
|
||
subtitle: "音频驱动画面",
|
||
coverIndex: 17,
|
||
steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"],
|
||
},
|
||
];
|
||
|
||
const TOOLBAR_ITEMS: Array<{ id: Exclude<ActivePanel, null>; label: string; icon: LucideIcon }> = [
|
||
{ id: "add", label: "添加模块", icon: Plus },
|
||
{ id: "workflow", label: "工作流", icon: GitBranch },
|
||
{ id: "assets", label: "素材", icon: Layers },
|
||
{ id: "history", label: "历史", icon: Clock3 },
|
||
{ id: "help", label: "帮助", icon: HelpCircle },
|
||
{ id: "support", label: "客服", icon: Headphones },
|
||
];
|
||
|
||
const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 };
|
||
|
||
function getToolType(tool?: CapturedTool) {
|
||
return String(tool?.type || "video").trim() || "video";
|
||
}
|
||
|
||
function getToolLabel(tool?: CapturedTool) {
|
||
return String(tool?.modelName || tool?.scene || tool?.modelKey || tool?.toolKey || "未命名模块").trim();
|
||
}
|
||
|
||
function getToolModes(tool?: CapturedTool) {
|
||
return (tool?.modes || []).map(String).filter(Boolean).slice(0, 4);
|
||
}
|
||
|
||
function getToolProperties(tool?: CapturedTool) {
|
||
const properties = tool?.metadata?.properties;
|
||
if (!properties || typeof properties !== "object") return [];
|
||
return Object.entries(properties).filter(([key]) => key !== "modeType");
|
||
}
|
||
|
||
function getToolRules(tool?: CapturedTool) {
|
||
const rules = tool?.rules || tool?.metadata?.rules;
|
||
return Array.isArray(rules) ? rules : [];
|
||
}
|
||
|
||
function requiresMedia(tool?: CapturedTool) {
|
||
return getToolRules(tool).some((rule) => {
|
||
if (!rule || typeof rule !== "object") return false;
|
||
const required = (rule as { require?: unknown }).require;
|
||
return Array.isArray(required) && required.map(String).includes("media");
|
||
});
|
||
}
|
||
|
||
function getPropertyLabel(key: string, config: unknown) {
|
||
if (config && typeof config === "object" && "displayName" in config) {
|
||
const value = (config as { displayName?: unknown }).displayName;
|
||
if (typeof value === "string" && value.trim()) return value;
|
||
}
|
||
const labels: Record<string, string> = {
|
||
count: "生成数量",
|
||
duration: "时长",
|
||
enableSound: "生成音效",
|
||
frameRate: "帧率",
|
||
instrumental: "纯音乐",
|
||
lora: "风格",
|
||
prompt: "提示词",
|
||
quality: "清晰度",
|
||
ratio: "比例",
|
||
resolution: "分辨率",
|
||
searchable: "联网搜索",
|
||
search_enabled: "联网搜索",
|
||
slowMotion: "慢动作",
|
||
slowmo: "减速强度",
|
||
specifiedModel: "模型",
|
||
stability: "稳定性",
|
||
style: "模型",
|
||
template: "风格",
|
||
voice: "声音",
|
||
};
|
||
return labels[key] || key;
|
||
}
|
||
|
||
function getOptions(config: unknown) {
|
||
if (Array.isArray(config)) return config;
|
||
if (config && typeof config === "object" && Array.isArray((config as { enum?: unknown[] }).enum)) {
|
||
return (config as { enum: unknown[] }).enum;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function getOptionValue(option: unknown) {
|
||
if (option && typeof option === "object" && "value" in option) {
|
||
return String((option as { value?: unknown }).value ?? "");
|
||
}
|
||
return String(option ?? "");
|
||
}
|
||
|
||
function getOptionLabel(option: unknown) {
|
||
if (option && typeof option === "object" && "displayName" in option) {
|
||
const label = (option as { displayName?: unknown }).displayName;
|
||
if (label) return String(label);
|
||
}
|
||
return getOptionValue(option);
|
||
}
|
||
|
||
function getDefaultValue(key: string, config: unknown) {
|
||
if (config && typeof config === "object" && "default" in config) {
|
||
return String((config as { default?: unknown }).default ?? "");
|
||
}
|
||
const options = getOptions(config);
|
||
if (options.length > 0) return getOptionValue(options[0]);
|
||
if (typeof config === "boolean") return config ? "1" : "0";
|
||
if (key === "prompt") return "";
|
||
return "";
|
||
}
|
||
|
||
function makeId(prefix: string) {
|
||
return `${prefix}-${crypto.randomUUID()}`;
|
||
}
|
||
|
||
function serializeNodes(nodes: CloneNode[]) {
|
||
return nodes.map((node) => ({
|
||
id: node.id,
|
||
type: "workflow",
|
||
position: { x: node.x, y: node.y },
|
||
size: { width: node.width, height: node.height },
|
||
data: {
|
||
title: node.title,
|
||
description: node.subtitle || "",
|
||
status: "pending",
|
||
templateType: `libtv-${node.kind}`,
|
||
metadata: {
|
||
liblibCloneNode: node,
|
||
},
|
||
},
|
||
connections: [],
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
}));
|
||
}
|
||
|
||
function serializeEdges(edges: CloneEdge[]) {
|
||
return edges.map((edge) => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
type: "default",
|
||
}));
|
||
}
|
||
|
||
function restoreNodes(canvasData: unknown): CloneNode[] {
|
||
const nodes = (canvasData as { nodes?: unknown[] } | null)?.nodes;
|
||
if (!Array.isArray(nodes)) return [];
|
||
return nodes
|
||
.map((item) => {
|
||
const metadata = ((item as any)?.data?.metadata || {}) as Record<string, unknown>;
|
||
const clone = metadata.liblibCloneNode as CloneNode | undefined;
|
||
return clone && typeof clone.id === "string" ? clone : null;
|
||
})
|
||
.filter(Boolean) as CloneNode[];
|
||
}
|
||
|
||
function restoreEdges(canvasData: unknown): CloneEdge[] {
|
||
const edges = (canvasData as { edges?: unknown[] } | null)?.edges;
|
||
if (!Array.isArray(edges)) return [];
|
||
return edges
|
||
.map((edge) => ({
|
||
id: String((edge as any).id || makeId("edge")),
|
||
source: String((edge as any).source || ""),
|
||
target: String((edge as any).target || ""),
|
||
}))
|
||
.filter((edge) => edge.source && edge.target);
|
||
}
|
||
|
||
function createInputNode(inputType: CloneNode["inputType"], x: number, y: number): CloneNode {
|
||
const title =
|
||
inputType === "video"
|
||
? "上传视频"
|
||
: inputType === "audio"
|
||
? "上传音频"
|
||
: inputType === "text"
|
||
? "输入文本"
|
||
: "上传图片";
|
||
return {
|
||
id: makeId("input"),
|
||
kind: "input",
|
||
title,
|
||
subtitle:
|
||
inputType === "video"
|
||
? "添加视频素材、参考视频或首尾帧"
|
||
: inputType === "audio"
|
||
? "添加音乐、语音或音效参考"
|
||
: inputType === "text"
|
||
? "输入脚本、分镜或提示词"
|
||
: "添加参考图、角色图或关键帧素材",
|
||
inputType,
|
||
x,
|
||
y,
|
||
width: 158,
|
||
height: 158,
|
||
accent: "#8790ff",
|
||
};
|
||
}
|
||
|
||
function createToolNode(tool: CapturedTool, x: number, y: number): CloneNode {
|
||
const type = getToolType(tool);
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
const isAudio = type === "audio";
|
||
const isText = type === "text";
|
||
return {
|
||
id: makeId("tool"),
|
||
kind: isAudio ? "audio" : isText ? "text" : "model",
|
||
title: getToolLabel(tool),
|
||
subtitle: tool.vendor || meta.label,
|
||
x,
|
||
y,
|
||
width: isText ? 210 : 194,
|
||
height: isText ? 122 : 116,
|
||
accent: meta.accent,
|
||
tool,
|
||
draftValues: {},
|
||
};
|
||
}
|
||
|
||
function createWorkflowNode(workflow: WorkflowPreset, x: number, y: number): CloneNode {
|
||
return {
|
||
id: makeId("workflow"),
|
||
kind: "workflow",
|
||
title: String(workflow.name || workflow.title || "工作流预设"),
|
||
subtitle: workflow.description || "LibTV workflow",
|
||
x,
|
||
y,
|
||
width: 230,
|
||
height: 116,
|
||
accent: "#8b96ff",
|
||
workflow,
|
||
draftValues: {},
|
||
};
|
||
}
|
||
|
||
function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: number): CloneNode {
|
||
return {
|
||
id: makeId(kind),
|
||
kind,
|
||
title,
|
||
subtitle: kind === "preview" ? "预览结果" : kind === "save" ? "保存到本地" : "输入内容",
|
||
x,
|
||
y,
|
||
width: kind === "text" ? 210 : 194,
|
||
height: kind === "text" ? 122 : 116,
|
||
accent: kind === "save" || kind === "preview" ? "#8b96ff" : "#f0d06b",
|
||
inputType: kind === "text" ? "text" : undefined,
|
||
draftValues: {},
|
||
};
|
||
}
|
||
|
||
export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: LiblibVideoCanvasReplicaProps) {
|
||
const router = useRouter();
|
||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||
const dragRef = useRef<DragState | null>(null);
|
||
const [nodes, setNodes] = useState<CloneNode[]>([]);
|
||
const [edges, setEdges] = useState<CloneEdge[]>([]);
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
const [viewport, setViewport] = useState(DEFAULT_VIEWPORT);
|
||
const [projectTitle, setProjectTitle] = useState("未命名");
|
||
const [draftTitle, setDraftTitle] = useState("未命名");
|
||
const [isTitleEditing, setIsTitleEditing] = useState(false);
|
||
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
|
||
const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部");
|
||
const [toolSearch, setToolSearch] = useState("");
|
||
const [isProjectLoading, setIsProjectLoading] = useState(true);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [shareEnabled, setShareEnabled] = useState(false);
|
||
const [publishPublic, setPublishPublic] = useState(false);
|
||
|
||
const selectedNode = useMemo(
|
||
() => nodes.find((node) => node.id === selectedNodeId) || null,
|
||
[nodes, selectedNodeId]
|
||
);
|
||
|
||
const filteredTools = useMemo(() => {
|
||
const search = toolSearch.trim().toLowerCase();
|
||
return capturedTools.filter((tool) => {
|
||
const type = getToolType(tool);
|
||
const tabMatched =
|
||
toolTab === "全部" ||
|
||
(toolTab === "图片" && type === "image") ||
|
||
(toolTab === "视频" && type === "video") ||
|
||
(toolTab === "文本" && type === "text") ||
|
||
(toolTab === "音频" && type === "audio") ||
|
||
(toolTab === "组合" && type === "compose");
|
||
if (!tabMatched) return false;
|
||
if (!search) return true;
|
||
return [tool.modelName, tool.vendor, tool.scene, tool.modelKey, tool.toolKey, ...(tool.modes || [])]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(search);
|
||
});
|
||
}, [toolSearch, toolTab]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
|
||
async function loadProject() {
|
||
setIsProjectLoading(true);
|
||
const result = await canvasService.getProject(canvasId);
|
||
if (!active) return;
|
||
if (result.success && result.data) {
|
||
setProjectTitle(result.data.title || "未命名");
|
||
setDraftTitle(result.data.title || "未命名");
|
||
const restoredNodes = restoreNodes(result.data.canvasData);
|
||
const restoredEdges = restoreEdges(result.data.canvasData);
|
||
setNodes(restoredNodes);
|
||
setEdges(restoredEdges);
|
||
} else {
|
||
setProjectTitle("未命名");
|
||
setDraftTitle("未命名");
|
||
await canvasService.saveCanvasState(
|
||
canvasId,
|
||
{
|
||
nodes: [],
|
||
edges: [],
|
||
selectedNodeIds: [],
|
||
viewport: DEFAULT_VIEWPORT,
|
||
tool: "select",
|
||
},
|
||
"video"
|
||
);
|
||
}
|
||
setIsProjectLoading(false);
|
||
}
|
||
|
||
void loadProject();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [canvasId]);
|
||
|
||
const saveCanvas = useCallback(async () => {
|
||
setIsSaving(true);
|
||
const result = await canvasService.saveCanvasState(
|
||
canvasId,
|
||
{
|
||
nodes: serializeNodes(nodes) as any,
|
||
edges: serializeEdges(edges) as any,
|
||
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [],
|
||
viewport,
|
||
tool: "select",
|
||
},
|
||
"video"
|
||
);
|
||
if (result.success) {
|
||
toast.success("已保存");
|
||
} else {
|
||
toast.error(result.error || "保存失败");
|
||
}
|
||
setIsSaving(false);
|
||
}, [canvasId, edges, nodes, selectedNodeId, viewport]);
|
||
|
||
const commitTitle = useCallback(async () => {
|
||
const nextTitle = draftTitle.trim() || "未命名";
|
||
setProjectTitle(nextTitle);
|
||
setDraftTitle(nextTitle);
|
||
setIsTitleEditing(false);
|
||
await canvasService.updateProject(canvasId, { title: nextTitle }, "video");
|
||
}, [canvasId, draftTitle]);
|
||
|
||
const addNodes = useCallback((nextNodes: CloneNode[], nextEdges: CloneEdge[]) => {
|
||
setNodes((current) => [...current, ...nextNodes]);
|
||
setEdges((current) => [...current, ...nextEdges]);
|
||
const last = nextNodes[nextNodes.length - 1];
|
||
if (last) setSelectedNodeId(last.id);
|
||
}, []);
|
||
|
||
const addToolFlow = useCallback(
|
||
(tool: CapturedTool) => {
|
||
const type = getToolType(tool);
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
const origin = { x: 420 + nodes.length * 18, y: 330 + nodes.length * 10 };
|
||
const newNodes: CloneNode[] = [];
|
||
const newEdges: CloneEdge[] = [];
|
||
|
||
if (meta.inputType && type !== "text") {
|
||
const input = createInputNode(meta.inputType, origin.x, origin.y);
|
||
const model = createToolNode(tool, origin.x + 300, origin.y + 18);
|
||
newNodes.push(input, model);
|
||
newEdges.push({ id: makeId("edge"), source: input.id, target: model.id });
|
||
} else {
|
||
newNodes.push(createToolNode(tool, origin.x + 120, origin.y + 12));
|
||
}
|
||
|
||
addNodes(newNodes, newEdges);
|
||
setActivePanel(null);
|
||
toast.success(`已添加 ${getToolLabel(tool)}`);
|
||
},
|
||
[addNodes, nodes.length]
|
||
);
|
||
|
||
const addWorkflowPreset = useCallback(
|
||
(workflow: WorkflowPreset, index = 0) => {
|
||
const node = createWorkflowNode(workflow, 440 + index * 24, 350 + index * 18);
|
||
addNodes([node], []);
|
||
setActivePanel(null);
|
||
toast.success("已添加工作流");
|
||
},
|
||
[addNodes]
|
||
);
|
||
|
||
const createStarter = useCallback(
|
||
(starter: (typeof STARTER_CARDS)[number]) => {
|
||
const baseX = 380;
|
||
const baseY = 325;
|
||
const newNodes = starter.steps.map((title, index) => {
|
||
if (/上传图片|首帧图/.test(title)) return createInputNode("image", baseX + index * 270, baseY);
|
||
if (/上传音频|音频/.test(title) && index === 0) return createInputNode("audio", baseX + index * 270, baseY);
|
||
if (/脚本|描述/.test(title)) return createSimpleNode(title, "text", baseX + index * 270, baseY + 18);
|
||
if (/预览/.test(title)) return createSimpleNode(title, "preview", baseX + index * 270, baseY + 18);
|
||
if (/保存/.test(title)) return createSimpleNode(title, "save", baseX + index * 270, baseY + 18);
|
||
const matchedTool = capturedTools.find((tool) => getToolLabel(tool).includes(title) || title.includes(getToolLabel(tool)));
|
||
return matchedTool
|
||
? createToolNode(matchedTool, baseX + index * 270, baseY + 18)
|
||
: createSimpleNode(title, "workflow", baseX + index * 270, baseY + 18);
|
||
});
|
||
const newEdges = newNodes.slice(1).map((node, index) => ({
|
||
id: makeId("edge"),
|
||
source: newNodes[index].id,
|
||
target: node.id,
|
||
}));
|
||
addNodes(newNodes, newEdges);
|
||
},
|
||
[addNodes]
|
||
);
|
||
|
||
const updateNodeDraft = useCallback((key: string, value: unknown) => {
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === selectedNodeId
|
||
? { ...node, draftValues: { ...(node.draftValues || {}), [key]: value } }
|
||
: node
|
||
)
|
||
);
|
||
}, [selectedNodeId]);
|
||
|
||
const startNodeDrag = useCallback((event: ReactPointerEvent, node: CloneNode) => {
|
||
event.stopPropagation();
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
setSelectedNodeId(node.id);
|
||
dragRef.current = {
|
||
kind: "node",
|
||
id: node.id,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
nodeX: node.x,
|
||
nodeY: node.y,
|
||
};
|
||
}, []);
|
||
|
||
const startCanvasDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
|
||
setSelectedNodeId(null);
|
||
dragRef.current = {
|
||
kind: "canvas",
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
viewportX: viewport.x,
|
||
viewportY: viewport.y,
|
||
};
|
||
}, [viewport.x, viewport.y]);
|
||
|
||
const onPointerMove = useCallback(
|
||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||
const drag = dragRef.current;
|
||
if (!drag) return;
|
||
if (drag.kind === "node") {
|
||
const dx = (event.clientX - drag.startX) / viewport.zoom;
|
||
const dy = (event.clientY - drag.startY) / viewport.zoom;
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === drag.id
|
||
? { ...node, x: Math.round(drag.nodeX + dx), y: Math.round(drag.nodeY + dy) }
|
||
: node
|
||
)
|
||
);
|
||
return;
|
||
}
|
||
setViewport((current) => ({
|
||
...current,
|
||
x: Math.round(drag.viewportX + event.clientX - drag.startX),
|
||
y: Math.round(drag.viewportY + event.clientY - drag.startY),
|
||
}));
|
||
},
|
||
[viewport.zoom]
|
||
);
|
||
|
||
const onPointerUp = useCallback(() => {
|
||
dragRef.current = null;
|
||
}, []);
|
||
|
||
const zoomBy = useCallback((delta: number) => {
|
||
setViewport((current) => ({
|
||
...current,
|
||
zoom: Math.min(2.4, Math.max(0.3, Number((current.zoom + delta).toFixed(2)))),
|
||
}));
|
||
}, []);
|
||
|
||
const copyShareLink = useCallback(async () => {
|
||
const url =
|
||
typeof window === "undefined"
|
||
? `/canvas/${canvasId}?mode=video&workspaceType=video`
|
||
: window.location.href;
|
||
await navigator.clipboard?.writeText(url).catch(() => undefined);
|
||
setShareEnabled(true);
|
||
toast.success("分享链接已复制");
|
||
}, [canvasId]);
|
||
|
||
const isBusy = isLoading || isProjectLoading;
|
||
|
||
return (
|
||
<div
|
||
className="relative h-dvh w-full overflow-hidden bg-[#060708] text-white"
|
||
onPointerDown={startCanvasDrag}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
ref={canvasRef}
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.16)_1px,transparent_1px)] bg-[length:16px_16px] opacity-[0.22]" />
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0,rgba(0,0,0,0.34)_72%)]" />
|
||
|
||
<div
|
||
className="absolute left-0 top-0 h-[4000px] w-[4000px] origin-top-left"
|
||
style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})` }}
|
||
>
|
||
<CloneEdges edges={edges} nodes={nodes} />
|
||
{nodes.map((node) => (
|
||
<CloneCanvasNode
|
||
key={node.id}
|
||
node={node}
|
||
selected={node.id === selectedNodeId}
|
||
onPointerDown={(event) => startNodeDrag(event, node)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{nodes.length === 0 && !activePanel ? (
|
||
<div className="-translate-x-1/2 -translate-y-1/2 absolute left-1/2 top-[51%] z-20 w-[900px] max-w-[calc(100vw-180px)]">
|
||
<div className="mb-8 flex items-center justify-center gap-2 text-[13px] text-white/58">
|
||
<MousePointer2 className="h-4 w-4" />
|
||
双击画布 自由生成节点
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{STARTER_CARDS.map((card) => {
|
||
const cover = capturedWorkflows[card.coverIndex]?.cover || capturedWorkflows[0]?.cover || "";
|
||
return (
|
||
<button
|
||
className="relative h-[56px] overflow-hidden rounded-[8px] border border-white/10 bg-[#18191b] px-6 text-left text-[13px] shadow-[0_18px_48px_rgba(0,0,0,0.34)] transition hover:border-white/26 hover:bg-[#202124]"
|
||
key={card.title}
|
||
onClick={() => createStarter(card)}
|
||
type="button"
|
||
>
|
||
{cover ? (
|
||
<span
|
||
className="absolute inset-y-0 right-0 w-[58%] bg-cover bg-center opacity-70"
|
||
style={{
|
||
backgroundImage: `linear-gradient(90deg, rgba(24,25,27,0), rgba(24,25,27,0.12)), url(${cover})`,
|
||
}}
|
||
/>
|
||
) : null}
|
||
<span className="relative z-10 block font-medium text-white">{card.title}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<TopBar
|
||
draftTitle={draftTitle}
|
||
isSaving={isSaving}
|
||
isTitleEditing={isTitleEditing}
|
||
onAccount={() => setActivePanel("account")}
|
||
onBell={() => setActivePanel("notifications")}
|
||
onCommitTitle={commitTitle}
|
||
onDraftTitleChange={setDraftTitle}
|
||
onMember={() => setActivePanel("member")}
|
||
onSave={saveCanvas}
|
||
onShare={() => setActivePanel("share")}
|
||
onSkills={() => setActivePanel("skills")}
|
||
onStartTitleEdit={() => setIsTitleEditing(true)}
|
||
projectTitle={projectTitle}
|
||
routerBack={() => router.push("/")}
|
||
/>
|
||
|
||
<LeftToolbar activePanel={activePanel} onOpen={(panel) => setActivePanel((current) => current === panel ? null : panel)} />
|
||
|
||
<ZoomDock
|
||
zoom={viewport.zoom}
|
||
onCenter={() => setViewport(DEFAULT_VIEWPORT)}
|
||
onZoomIn={() => zoomBy(0.1)}
|
||
onZoomOut={() => zoomBy(-0.1)}
|
||
/>
|
||
|
||
{activePanel === "add" ? (
|
||
<AddModulePanel
|
||
filteredTools={filteredTools}
|
||
search={toolSearch}
|
||
tab={toolTab}
|
||
onAddTool={addToolFlow}
|
||
onClose={() => setActivePanel(null)}
|
||
onSearchChange={setToolSearch}
|
||
onTabChange={setToolTab}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "workflow" ? (
|
||
<WorkflowPanel
|
||
workflows={capturedWorkflows}
|
||
onAdd={addWorkflowPreset}
|
||
onClose={() => setActivePanel(null)}
|
||
/>
|
||
) : null}
|
||
|
||
{selectedNode ? (
|
||
<InspectorPanel
|
||
node={selectedNode}
|
||
onChangeProperty={updateNodeDraft}
|
||
onClose={() => setSelectedNodeId(null)}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "assets" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="素材库">
|
||
<EmptyState icon={Layers} title="暂无素材" description="素材库、上传和项目素材接口待接入。" />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "history" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="历史记录">
|
||
<div className="space-y-2">
|
||
{nodes.length === 0 ? (
|
||
<EmptyState icon={Clock3} title="暂无历史" description="节点执行、草稿版本和回滚记录待接入。" />
|
||
) : (
|
||
nodes.slice(-10).reverse().map((node) => (
|
||
<button
|
||
className="flex w-full items-center justify-between rounded-[8px] border border-white/8 bg-white/5 px-3 py-2 text-left hover:bg-white/8"
|
||
key={node.id}
|
||
onClick={() => setSelectedNodeId(node.id)}
|
||
type="button"
|
||
>
|
||
<span>
|
||
<span className="block text-[13px] text-white/86">{node.title}</span>
|
||
<span className="text-[11px] text-white/38">{node.subtitle || node.kind}</span>
|
||
</span>
|
||
<ChevronDown className="-rotate-90 h-4 w-4 text-white/38" />
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "help" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="快捷键">
|
||
<div className="grid gap-2 text-[13px] text-white/70">
|
||
{[
|
||
["V", "选择"],
|
||
["Space", "拖动画布"],
|
||
["双击画布", "自由生成节点"],
|
||
["连接点拖拽", "连接模块"],
|
||
["⌘ / Ctrl + S", "保存画布"],
|
||
].map(([key, value]) => (
|
||
<div className="flex items-center justify-between rounded-[8px] bg-white/5 px-3 py-2" key={key}>
|
||
<span>{value}</span>
|
||
<span className="rounded-[6px] bg-white/10 px-2 py-1 text-white/58">{key}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "support" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="支持中心">
|
||
<EmptyState icon={Headphones} title="联系客服" description="工单、客服和帮助文档入口待接入。" />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "skills" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="LibTV Skills">
|
||
<div className="space-y-4">
|
||
<EmptyState icon={Sparkles} title="OpenAPI Access Key" description="Access Key 管理弹窗和权限接口待接入。" />
|
||
<div className="grid grid-cols-5 gap-2">
|
||
{Object.entries(capturedCounts.byType || {}).map(([type, count]) => {
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
return (
|
||
<div className="rounded-[8px] bg-white/6 p-3 text-center" key={type}>
|
||
<div className="text-[18px] text-white">{String(count)}</div>
|
||
<div className="mt-1 text-[11px] text-white/42">{meta.label}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "share" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="分享">
|
||
<div className="space-y-4">
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-4">
|
||
<div className="font-medium">分享链接</div>
|
||
<div className="mt-1 text-[12px] text-white/42">复制当前画布链接,权限控制待接入。</div>
|
||
<button
|
||
className="mt-4 h-10 w-full rounded-[8px] bg-white font-semibold text-black hover:bg-white/90"
|
||
onClick={() => void copyShareLink()}
|
||
type="button"
|
||
>
|
||
<Copy className="mr-2 inline h-4 w-4" />
|
||
复制链接
|
||
</button>
|
||
</div>
|
||
<ToggleRow checked={shareEnabled} label="启用分享" onChange={() => setShareEnabled((value) => !value)} />
|
||
<button
|
||
className="h-10 w-full rounded-[8px] bg-[#2f7df6] font-semibold text-white hover:bg-[#3f8bff]"
|
||
onClick={() => setActivePanel("publish")}
|
||
type="button"
|
||
>
|
||
发布作品
|
||
</button>
|
||
</div>
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "publish" ? (
|
||
<PublishModal
|
||
projectTitle={projectTitle}
|
||
publishPublic={publishPublic}
|
||
onClose={() => setActivePanel(null)}
|
||
onTogglePublic={() => setPublishPublic((value) => !value)}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "member" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="会员中心">
|
||
<EmptyState icon={Crown} title="会员权益" description="会员超市、特惠活动、积分和支付接口待接入。" />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "notifications" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="通知">
|
||
<EmptyState icon={Bell} title="暂无通知" description="生成任务、审核和系统消息待接入。" />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "account" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="账号">
|
||
<EmptyState icon={UserRound} title="当前账号" description="账号中心、额度和设置入口待接入。" />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{isBusy ? (
|
||
<div className="absolute inset-0 z-[120] flex items-center justify-center bg-[#151515]">
|
||
<div className="flex flex-col items-center gap-3 text-white/72">
|
||
<Loader2 className="h-7 w-7 animate-spin" />
|
||
<span className="text-sm">项目加载中...</span>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TopBar({
|
||
draftTitle,
|
||
isSaving,
|
||
isTitleEditing,
|
||
onAccount,
|
||
onBell,
|
||
onCommitTitle,
|
||
onDraftTitleChange,
|
||
onMember,
|
||
onSave,
|
||
onShare,
|
||
onSkills,
|
||
onStartTitleEdit,
|
||
projectTitle,
|
||
routerBack,
|
||
}: {
|
||
draftTitle: string;
|
||
isSaving: boolean;
|
||
isTitleEditing: boolean;
|
||
onAccount: () => void;
|
||
onBell: () => void;
|
||
onCommitTitle: () => void;
|
||
onDraftTitleChange: (value: string) => void;
|
||
onMember: () => void;
|
||
onSave: () => void;
|
||
onShare: () => void;
|
||
onSkills: () => void;
|
||
onStartTitleEdit: () => void;
|
||
projectTitle: string;
|
||
routerBack: () => void;
|
||
}) {
|
||
return (
|
||
<header className="pointer-events-none absolute left-0 right-0 top-0 z-40 flex h-[78px] items-start justify-between px-4 pt-5">
|
||
<div className="pointer-events-auto flex items-center gap-4">
|
||
<button className="flex items-center gap-2" onClick={routerBack} type="button">
|
||
<span className="relative h-[15px] w-[23px] rounded-[2px] border border-white/90">
|
||
<span className="absolute -right-[5px] top-[4px] h-[7px] w-[11px] rounded-[1px] bg-white" />
|
||
</span>
|
||
<span className="font-bold italic tracking-wide text-white">LibTV</span>
|
||
</button>
|
||
<div className="h-6 w-px bg-white/14" />
|
||
{isTitleEditing ? (
|
||
<input
|
||
autoFocus
|
||
className="h-9 w-[220px] rounded-[8px] border border-white/14 bg-[#1f1f1f] px-3 text-[14px] text-white outline-none"
|
||
onBlur={onCommitTitle}
|
||
onChange={(event) => onDraftTitleChange(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") onCommitTitle();
|
||
}}
|
||
value={draftTitle}
|
||
/>
|
||
) : (
|
||
<button className="text-[16px] font-semibold text-white/82" onClick={onStartTitleEdit} type="button">
|
||
{projectTitle || "未命名"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="pointer-events-auto flex items-center gap-2">
|
||
<TopButton onClick={onSkills}>LibTV Skills</TopButton>
|
||
<TopButton onClick={onSave} title="保存">
|
||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||
</TopButton>
|
||
<TopButton onClick={onShare} title="分享">
|
||
<Share2 className="h-4 w-4" />
|
||
</TopButton>
|
||
<TopButton onClick={onBell} title="通知">
|
||
<Bell className="h-4 w-4" />
|
||
</TopButton>
|
||
<TopButton className="gap-2 px-4 text-cyan-300" onClick={onMember}>
|
||
<Crown className="h-4 w-4" />
|
||
会员超市
|
||
</TopButton>
|
||
<TopButton className="gap-2 px-4 text-yellow-200" onClick={onMember}>
|
||
<Zap className="h-4 w-4" />
|
||
会员特惠
|
||
</TopButton>
|
||
<TopButton onClick={onAccount} title="账号">
|
||
<UserRound className="h-4 w-4" />
|
||
</TopButton>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function TopButton({
|
||
children,
|
||
className,
|
||
onClick,
|
||
title,
|
||
}: {
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
onClick: () => void;
|
||
title?: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex h-[46px] min-w-[46px] items-center justify-center rounded-[8px] border border-white/8 bg-[#202020] px-3 text-[14px] text-white/72 shadow-[0_12px_30px_rgba(0,0,0,0.28)] transition hover:bg-[#2a2a2a] hover:text-white",
|
||
className
|
||
)}
|
||
onClick={onClick}
|
||
title={title}
|
||
type="button"
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function LeftToolbar({
|
||
activePanel,
|
||
onOpen,
|
||
}: {
|
||
activePanel: ActivePanel;
|
||
onOpen: (panel: Exclude<ActivePanel, null>) => void;
|
||
}) {
|
||
return (
|
||
<aside
|
||
className="-translate-y-1/2 absolute left-4 top-1/2 z-40 flex flex-col gap-2 rounded-[8px] border border-white/9 bg-[#202020] p-2 shadow-[0_22px_60px_rgba(0,0,0,0.45)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
{TOOLBAR_ITEMS.map((item, index) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex h-11 w-11 items-center justify-center rounded-[8px] text-white/70 transition hover:bg-white/10 hover:text-white",
|
||
activePanel === item.id && "bg-white text-black hover:bg-white hover:text-black",
|
||
index === 0 && "mb-1"
|
||
)}
|
||
key={item.id}
|
||
onClick={() => onOpen(item.id)}
|
||
title={item.label}
|
||
type="button"
|
||
>
|
||
<Icon className="h-5 w-5" />
|
||
</button>
|
||
);
|
||
})}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function ZoomDock({
|
||
onCenter,
|
||
onZoomIn,
|
||
onZoomOut,
|
||
zoom,
|
||
}: {
|
||
onCenter: () => void;
|
||
onZoomIn: () => void;
|
||
onZoomOut: () => void;
|
||
zoom: number;
|
||
}) {
|
||
return (
|
||
<div
|
||
className="absolute bottom-4 left-4 z-40 flex items-center gap-2 rounded-[8px] border border-white/9 bg-[#202020] p-2 text-white/70 shadow-[0_18px_48px_rgba(0,0,0,0.42)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<button className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-white text-black" type="button">
|
||
<MousePointer2 className="h-4 w-4" />
|
||
</button>
|
||
<button className="flex h-10 w-10 items-center justify-center rounded-[8px] hover:bg-white/10" type="button">
|
||
<Box className="h-4 w-4" />
|
||
</button>
|
||
<button className="flex h-10 w-10 items-center justify-center rounded-[8px] hover:bg-white/10" type="button">
|
||
<GitBranch className="h-4 w-4" />
|
||
</button>
|
||
<div className="mx-1 h-6 w-px bg-white/10" />
|
||
<button className="h-10 w-10 rounded-[8px] text-[18px] hover:bg-white/10" onClick={onZoomOut} type="button">
|
||
-
|
||
</button>
|
||
<button className="h-10 min-w-[74px] rounded-[8px] text-[15px] hover:bg-white/10" onClick={onCenter} type="button">
|
||
{Math.round(zoom * 100)}%
|
||
</button>
|
||
<button className="h-10 w-10 rounded-[8px] text-[18px] hover:bg-white/10" onClick={onZoomIn} type="button">
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CloneEdges({ edges, nodes }: { edges: CloneEdge[]; nodes: CloneNode[] }) {
|
||
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
||
return (
|
||
<svg className="pointer-events-none absolute left-0 top-0 h-[4000px] w-[4000px] overflow-visible">
|
||
{edges.map((edge) => {
|
||
const source = nodeMap.get(edge.source);
|
||
const target = nodeMap.get(edge.target);
|
||
if (!source || !target) return null;
|
||
const startX = source.x + source.width;
|
||
const startY = source.y + source.height / 2;
|
||
const endX = target.x;
|
||
const endY = target.y + target.height / 2;
|
||
const curve = Math.max(90, Math.abs(endX - startX) * 0.42);
|
||
return (
|
||
<path
|
||
d={`M ${startX} ${startY} C ${startX + curve} ${startY}, ${endX - curve} ${endY}, ${endX} ${endY}`}
|
||
fill="none"
|
||
key={edge.id}
|
||
stroke="#7c86ff"
|
||
strokeLinecap="round"
|
||
strokeOpacity="0.95"
|
||
strokeWidth="4"
|
||
/>
|
||
);
|
||
})}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function CloneCanvasNode({
|
||
node,
|
||
onPointerDown,
|
||
selected,
|
||
}: {
|
||
node: CloneNode;
|
||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||
selected: boolean;
|
||
}) {
|
||
const Icon = node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon;
|
||
const isInput = node.kind === "input";
|
||
const isWarm = node.kind === "text" || node.kind === "audio";
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"absolute cursor-grab select-none shadow-[0_14px_34px_rgba(0,0,0,0.35)] active:cursor-grabbing",
|
||
selected && "z-20"
|
||
)}
|
||
data-canvas-overlay="true"
|
||
onPointerDown={onPointerDown}
|
||
style={{ height: node.height, left: node.x, top: node.y, width: node.width }}
|
||
>
|
||
<span className="absolute left-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
|
||
<span className="absolute right-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
|
||
{isInput ? (
|
||
<div
|
||
className={cn(
|
||
"flex h-full w-full flex-col items-center justify-center rounded-[4px] bg-[#f8f9ff] text-center text-[#2a2d3a]",
|
||
selected && "outline outline-2 outline-[#8b96ff]"
|
||
)}
|
||
>
|
||
<span className="mb-3 flex h-8 w-8 items-center justify-center rounded-[8px] border border-[#dfe3ff] bg-white text-[#7a85ff]">
|
||
<Icon className="h-4 w-4" />
|
||
</span>
|
||
<div className="font-semibold text-[14px]">{node.title}</div>
|
||
<div className="mt-2 line-clamp-3 px-5 text-[11px] leading-5 text-[#6f7482]">{node.subtitle}</div>
|
||
<button className="mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]" type="button">
|
||
选择文件
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={cn(
|
||
"relative flex h-full w-full items-center gap-4 overflow-hidden rounded-[26px] border bg-[#f6f7fb] px-5 text-[#20222a]",
|
||
isWarm && "bg-[#fff0f3]",
|
||
node.kind === "workflow" && "bg-[#f6f7fb]",
|
||
selected ? "border-[#8b96ff]" : "border-white/45"
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-black/5 bg-white/70 text-[#8b96ff] shadow-[inset_0_0_16px_rgba(255,255,255,0.7)]",
|
||
isWarm && "text-[#ff9eae]"
|
||
)}
|
||
>
|
||
{node.kind === "workflow" ? <GitBranch className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="mb-1 text-[12px] text-[#697080]">{node.subtitle || "工具"}</div>
|
||
<div className="truncate font-semibold text-[20px] tracking-tight">{node.title}</div>
|
||
</div>
|
||
<div className="flex h-[58px] w-8 items-center justify-center rounded-full border border-black/8 bg-white/62 text-center text-[13px] leading-4 text-[#272a32]">
|
||
待<br />配置
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AddModulePanel({
|
||
filteredTools,
|
||
onAddTool,
|
||
onClose,
|
||
onSearchChange,
|
||
onTabChange,
|
||
search,
|
||
tab,
|
||
}: {
|
||
filteredTools: CapturedTool[];
|
||
onAddTool: (tool: CapturedTool) => void;
|
||
onClose: () => void;
|
||
onSearchChange: (value: string) => void;
|
||
onTabChange: (tab: (typeof PANEL_TABS)[number]) => void;
|
||
search: string;
|
||
tab: (typeof PANEL_TABS)[number];
|
||
}) {
|
||
return (
|
||
<section
|
||
className="absolute left-[88px] top-[110px] z-50 flex h-[min(720px,calc(100vh-140px))] w-[408px] flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.48)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="border-white/8 border-b p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-semibold text-[16px] text-white">添加节点</div>
|
||
<div className="mt-1 text-[12px] text-white/42">工具 {capturedCounts.tools || capturedTools.length} / 工作流 {capturedCounts.workflows || capturedWorkflows.length}</div>
|
||
</div>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="mt-4 flex items-center gap-2 rounded-[8px] border border-white/10 bg-black/24 px-3 py-2">
|
||
<Search className="h-4 w-4 text-white/38" />
|
||
<input
|
||
className="min-w-0 flex-1 bg-transparent text-[13px] text-white outline-none placeholder:text-white/30"
|
||
onChange={(event) => onSearchChange(event.target.value)}
|
||
placeholder="搜索模型、厂商、模式"
|
||
value={search}
|
||
/>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{PANEL_TABS.map((item) => (
|
||
<button
|
||
className={cn(
|
||
"rounded-[8px] border border-white/8 px-2.5 py-1 text-[12px] text-white/58 hover:bg-white/8 hover:text-white",
|
||
tab === item && "border-white/20 bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
key={item}
|
||
onClick={() => onTabChange(item)}
|
||
type="button"
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||
<div className="grid gap-2">
|
||
{filteredTools.map((tool) => {
|
||
const type = getToolType(tool);
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
const Icon = meta.icon;
|
||
return (
|
||
<button
|
||
className="group rounded-[8px] border border-white/8 bg-[#2a2a2a] p-3 text-left transition hover:border-white/18 hover:bg-[#323232]"
|
||
key={`${tool.toolKey || tool.modelKey}-${tool.modelName}`}
|
||
onClick={() => onAddTool(tool)}
|
||
type="button"
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-white text-black">
|
||
<Icon className="h-4 w-4" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate font-medium text-[14px] text-white">{getToolLabel(tool)}</span>
|
||
<span className="mt-1 flex flex-wrap gap-1.5">
|
||
<span className="rounded-[6px] bg-white/8 px-1.5 py-0.5 text-[11px] text-white/54">{tool.vendor || meta.label}</span>
|
||
{getToolModes(tool).map((mode) => (
|
||
<span className="rounded-[6px] bg-white/8 px-1.5 py-0.5 text-[11px] text-white/46" key={mode}>
|
||
{mode}
|
||
</span>
|
||
))}
|
||
</span>
|
||
</span>
|
||
<Plus className="mt-1 h-4 w-4 text-white/34 group-hover:text-white" />
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function WorkflowPanel({
|
||
onAdd,
|
||
onClose,
|
||
workflows,
|
||
}: {
|
||
onAdd: (workflow: WorkflowPreset, index: number) => void;
|
||
onClose: () => void;
|
||
workflows: WorkflowPreset[];
|
||
}) {
|
||
return (
|
||
<section
|
||
className="absolute left-[88px] top-[110px] z-50 flex h-[min(720px,calc(100vh-140px))] w-[420px] flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.48)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="flex items-center justify-between border-white/8 border-b p-4">
|
||
<div>
|
||
<div className="font-semibold text-[16px]">工作流</div>
|
||
<div className="mt-1 text-[12px] text-white/42">{workflows.length} 个预设</div>
|
||
</div>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||
<div className="grid gap-3">
|
||
{workflows.map((workflow, index) => (
|
||
<button
|
||
className="group overflow-hidden rounded-[8px] border border-white/8 bg-[#2a2a2a] text-left hover:border-white/18 hover:bg-[#323232]"
|
||
key={`${workflow.id || ""}-${workflow.uuid || workflow.name}`}
|
||
onClick={() => onAdd(workflow, index)}
|
||
type="button"
|
||
>
|
||
{workflow.cover ? (
|
||
<div
|
||
className="h-24 bg-cover bg-center"
|
||
style={{ backgroundImage: `linear-gradient(90deg, rgba(0,0,0,0.74), rgba(0,0,0,0.18)), url(${workflow.cover})` }}
|
||
/>
|
||
) : null}
|
||
<div className="p-3">
|
||
<div className="font-medium text-white">{workflow.name || workflow.title || "未命名"}</div>
|
||
<div className="mt-1 line-clamp-2 text-[12px] text-white/46">{workflow.description || "LibTV workflow"}</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function InspectorPanel({
|
||
node,
|
||
onChangeProperty,
|
||
onClose,
|
||
}: {
|
||
node: CloneNode;
|
||
onChangeProperty: (key: string, value: unknown) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const tool = node.tool;
|
||
const type = getToolType(tool);
|
||
const properties = getToolProperties(tool);
|
||
const showUpload = node.kind === "input" || requiresMedia(tool);
|
||
return (
|
||
<aside
|
||
className="absolute right-4 top-[124px] z-50 w-[476px] overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="flex items-start justify-between border-white/8 border-b px-6 py-5">
|
||
<div>
|
||
<div className="font-semibold text-[18px]">{node.title}</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<span className="rounded-[6px] bg-white/8 px-2 py-1 text-[12px] text-white/58">
|
||
{node.inputType || type || node.kind}
|
||
</span>
|
||
{tool?.vendor ? (
|
||
<span className="rounded-[6px] bg-[#5b5220] px-2 py-1 text-[12px] text-[#d7c66a]">{tool.vendor}</span>
|
||
) : null}
|
||
{getToolModes(tool).map((mode) => (
|
||
<span className="rounded-[6px] bg-white/8 px-2 py-1 text-[12px] text-white/48" key={mode}>
|
||
{mode}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="space-y-4 p-6">
|
||
{showUpload ? (
|
||
<button
|
||
className="flex h-[74px] w-full items-center justify-center rounded-[8px] border border-white/10 bg-white/5 text-[14px] text-white/52 hover:bg-white/8 hover:text-white"
|
||
onClick={() => toast.info("上传/素材选择接口待接入。")}
|
||
type="button"
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加素材
|
||
</button>
|
||
) : null}
|
||
{properties.length > 0 ? (
|
||
<div className="space-y-4">
|
||
{properties.map(([key, config]) => (
|
||
<PropertyField
|
||
config={config}
|
||
key={key}
|
||
propertyKey={key}
|
||
value={node.draftValues?.[key]}
|
||
onChange={(value) => onChangeProperty(key, value)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-[8px] border border-white/10 bg-white/5 px-5 py-6 text-[14px] text-white/42">
|
||
此模块未暴露可配置参数。
|
||
</div>
|
||
)}
|
||
<button
|
||
className="h-[64px] w-full rounded-[8px] bg-white font-bold text-[16px] text-black hover:bg-white/90"
|
||
onClick={() => toast.info("生成接口待接入。")}
|
||
type="button"
|
||
>
|
||
生成
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function PropertyField({
|
||
config,
|
||
onChange,
|
||
propertyKey,
|
||
value,
|
||
}: {
|
||
config: unknown;
|
||
onChange: (value: unknown) => void;
|
||
propertyKey: string;
|
||
value: unknown;
|
||
}) {
|
||
const label = getPropertyLabel(propertyKey, config);
|
||
const options = getOptions(config);
|
||
const current = String(value ?? getDefaultValue(propertyKey, config));
|
||
const component = config && typeof config === "object" && "component" in config ? String((config as any).component || "") : "";
|
||
|
||
if (propertyKey === "prompt") {
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">{label}</span>
|
||
<textarea
|
||
className="min-h-[96px] w-full rounded-[8px] border border-white/10 bg-black/18 px-3 py-2 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-white/24"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder="请输入提示词"
|
||
value={String(value ?? "")}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
if (component === "switch" || typeof config === "boolean") {
|
||
const checked = current === "1" || current === "true";
|
||
return <ToggleRow checked={checked} label={label} onChange={() => onChange(checked ? "0" : "1")} />;
|
||
}
|
||
|
||
if (options.length > 0) {
|
||
if (options.length > 8) {
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">{label}</span>
|
||
<select
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-[13px] text-white outline-none"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
value={current}
|
||
>
|
||
{options.map((option) => (
|
||
<option className="bg-[#202020] text-white" key={getOptionValue(option)} value={getOptionValue(option)}>
|
||
{getOptionLabel(option)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
);
|
||
}
|
||
return (
|
||
<div>
|
||
<div className="mb-2 text-[13px] text-white/72">{label}</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{options.map((option) => {
|
||
const optionValue = getOptionValue(option);
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"rounded-[8px] border border-white/8 px-3 py-1.5 text-[12px] text-white/62 hover:bg-white/8 hover:text-white",
|
||
current === optionValue && "border-white/20 bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
key={optionValue}
|
||
onClick={() => onChange(optionValue)}
|
||
type="button"
|
||
>
|
||
{getOptionLabel(option)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (config && typeof config === "object" && "maxCount" in config) {
|
||
return (
|
||
<div>
|
||
<div className="mb-2 text-[13px] text-white/72">{label}</div>
|
||
<button
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-left text-[13px] text-white/46 hover:border-white/20 hover:text-white"
|
||
onClick={() => toast.info(`${label}选择器待接入。`)}
|
||
type="button"
|
||
>
|
||
选择{label}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">{label}</span>
|
||
<input
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-[13px] text-white outline-none"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
value={String(value ?? "")}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function ToggleRow({
|
||
checked,
|
||
label,
|
||
onChange,
|
||
}: {
|
||
checked: boolean;
|
||
label: string;
|
||
onChange: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between rounded-[8px] border border-white/8 bg-white/5 px-4 py-3">
|
||
<span className="text-[13px] text-white/76">{label}</span>
|
||
<button className={cn("relative h-6 w-11 rounded-full transition", checked ? "bg-white" : "bg-white/18")} onClick={onChange} type="button">
|
||
<span className={cn("absolute top-1 h-4 w-4 rounded-full transition", checked ? "left-6 bg-black" : "left-1 bg-white")} />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LibModal({
|
||
children,
|
||
onClose,
|
||
title,
|
||
width = 520,
|
||
}: {
|
||
children: React.ReactNode;
|
||
onClose: () => void;
|
||
title: string;
|
||
width?: number;
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/64 backdrop-blur-[3px]" data-canvas-overlay="true">
|
||
<div
|
||
className="max-h-[86vh] overflow-hidden rounded-[8px] border border-white/12 bg-[#202020] text-white shadow-[0_30px_100px_rgba(0,0,0,0.58)]"
|
||
style={{ width: `min(${width}px, calc(100vw - 32px))` }}
|
||
>
|
||
<div className="flex items-center justify-between border-white/8 border-b px-5 py-4">
|
||
<h2 className="font-semibold text-[16px]">{title}</h2>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="max-h-[calc(86vh-65px)] overflow-y-auto p-5">{children}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PublishModal({
|
||
onClose,
|
||
onTogglePublic,
|
||
projectTitle,
|
||
publishPublic,
|
||
}: {
|
||
onClose: () => void;
|
||
onTogglePublic: () => void;
|
||
projectTitle: string;
|
||
publishPublic: boolean;
|
||
}) {
|
||
const cover = capturedWorkflows[0]?.cover || "";
|
||
return (
|
||
<LibModal onClose={onClose} title="发布作品到 LibTV" width={480}>
|
||
<div className="space-y-4">
|
||
{cover ? (
|
||
<div className="h-20 rounded-t-[8px] bg-cover bg-center" style={{ backgroundImage: `linear-gradient(90deg, rgba(0,0,0,.72), rgba(0,0,0,.12)), url(${cover})` }} />
|
||
) : null}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<UploadBox label="选择视频" />
|
||
<UploadBox label="选择封面" hint="建议上传横版图片" />
|
||
</div>
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">作品名称 *</span>
|
||
<input className="h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-[13px] text-white outline-none" defaultValue={projectTitle || "未命名"} />
|
||
</label>
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">作品描述</span>
|
||
<textarea className="min-h-[80px] w-full rounded-[8px] border border-white/10 bg-black/18 px-3 py-2 text-[13px] text-white outline-none" placeholder="请输入作品描述" />
|
||
</label>
|
||
<ToggleRow checked={publishPublic} label="公开画布" onChange={onTogglePublic} />
|
||
<button
|
||
className="h-10 w-full rounded-[8px] bg-[#2f7df6] font-semibold text-white hover:bg-[#3f8bff]"
|
||
onClick={() => toast.info("发布接口待接入。")}
|
||
type="button"
|
||
>
|
||
发布并投稿
|
||
</button>
|
||
</div>
|
||
</LibModal>
|
||
);
|
||
}
|
||
|
||
function UploadBox({ hint, label }: { hint?: string; label: string }) {
|
||
return (
|
||
<button
|
||
className="flex h-28 flex-col items-center justify-center rounded-[8px] border border-white/10 bg-white/8 text-white/56 hover:bg-white/12 hover:text-white"
|
||
onClick={() => toast.info(`${label}接口待接入。`)}
|
||
type="button"
|
||
>
|
||
<Plus className="h-5 w-5" />
|
||
<span className="mt-2 text-[13px]">{label}</span>
|
||
{hint ? <span className="mt-1 text-[11px] text-white/34">{hint}</span> : null}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function EmptyState({
|
||
description,
|
||
icon: Icon,
|
||
title,
|
||
}: {
|
||
description: string;
|
||
icon: LucideIcon;
|
||
title: string;
|
||
}) {
|
||
return (
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-6 text-center">
|
||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-[8px] bg-white/8 text-white/72">
|
||
<Icon className="h-5 w-5" />
|
||
</div>
|
||
<div className="mt-4 font-semibold">{title}</div>
|
||
<p className="mx-auto mt-2 max-w-[380px] text-[13px] text-white/48 leading-6">{description}</p>
|
||
</div>
|
||
);
|
||
}
|