1736 lines
61 KiB
TypeScript
1736 lines
61 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Bell,
|
||
Box,
|
||
Clock3,
|
||
Copy,
|
||
Crown,
|
||
Eye,
|
||
FileImage,
|
||
FileText,
|
||
FolderOpen,
|
||
GitBranch,
|
||
Headphones,
|
||
HelpCircle,
|
||
KeyRound,
|
||
Layers,
|
||
Loader2,
|
||
Lock,
|
||
Minus,
|
||
MousePointer2,
|
||
Music2,
|
||
Plus,
|
||
Save,
|
||
Search,
|
||
Share2,
|
||
UserRound,
|
||
Video,
|
||
X,
|
||
Zap,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
|
||
import { CanvasWorkspace } from "@/components/canvas/CanvasWorkspace";
|
||
import { canvasService } from "@/services/canvas";
|
||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||
import { cn } from "@/lib/utils";
|
||
import type { CanvasNode, Position, WorkflowNodeData } from "@/types/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 TargetNodeKind = "image" | "video" | "text" | "audio" | "compose" | "workflow" | "preview" | "save";
|
||
|
||
interface 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 Array<{
|
||
id?: string | number;
|
||
name?: string;
|
||
title?: string;
|
||
description?: string;
|
||
}>;
|
||
|
||
const TOOL_TYPE_META: Record<
|
||
string,
|
||
{ label: string; icon: LucideIcon; color: string; nodeKind: TargetNodeKind }
|
||
> = {
|
||
image: { label: "图片", icon: FileImage, color: "#70d6ff", nodeKind: "image" },
|
||
video: { label: "视频", icon: Video, color: "#9b8cff", nodeKind: "video" },
|
||
text: { label: "文本", icon: FileText, color: "#ffd166", nodeKind: "text" },
|
||
audio: { label: "音频", icon: Music2, color: "#80ed99", nodeKind: "audio" },
|
||
compose: { label: "组合", icon: GitBranch, color: "#ff8fab", nodeKind: "compose" },
|
||
};
|
||
|
||
const STARTER_CARDS: Array<{
|
||
title: string;
|
||
subtitle: string;
|
||
nodes: Array<{ label: string; kind: TargetNodeKind }>;
|
||
tone: string;
|
||
}> = [
|
||
{
|
||
title: "故事脚本生成",
|
||
subtitle: "脚本到分镜",
|
||
nodes: [
|
||
{ label: "故事脚本", kind: "text" },
|
||
{ label: "分镜拆解", kind: "compose" },
|
||
{ label: "视频生成", kind: "video" },
|
||
{ label: "预览", kind: "preview" },
|
||
],
|
||
tone: "from-slate-800 via-zinc-900 to-black",
|
||
},
|
||
{
|
||
title: "角色三视图",
|
||
subtitle: "角色资产沉淀",
|
||
nodes: [
|
||
{ label: "角色描述", kind: "text" },
|
||
{ label: "角色三视图", kind: "image" },
|
||
{ label: "角色视频", kind: "video" },
|
||
{ label: "保存", kind: "save" },
|
||
],
|
||
tone: "from-red-950 via-zinc-900 to-black",
|
||
},
|
||
{
|
||
title: "首帧图生视频",
|
||
subtitle: "参考图到成片",
|
||
nodes: [
|
||
{ label: "首帧图", kind: "image" },
|
||
{ label: "镜头提示词", kind: "text" },
|
||
{ label: "图生视频", kind: "video" },
|
||
{ label: "预览", kind: "preview" },
|
||
],
|
||
tone: "from-stone-800 via-zinc-900 to-black",
|
||
},
|
||
{
|
||
title: "音频生视频",
|
||
subtitle: "音频驱动画面",
|
||
nodes: [
|
||
{ label: "音频输入", kind: "audio" },
|
||
{ label: "画面描述", kind: "text" },
|
||
{ label: "音频生视频", kind: "video" },
|
||
{ label: "预览", kind: "preview" },
|
||
],
|
||
tone: "from-emerald-950 via-zinc-900 to-black",
|
||
},
|
||
];
|
||
|
||
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 PANEL_TABS = ["全部", "图片", "视频", "文本", "音频", "组合"] as const;
|
||
|
||
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 inferNodeKindForTool(tool: CapturedTool): TargetNodeKind {
|
||
const type = getToolType(tool);
|
||
const modes = (tool.modes || []).map((mode) => String(mode).toLowerCase());
|
||
const name = `${tool.modelName || ""} ${tool.scene || ""} ${tool.toolKey || ""}`.toLowerCase();
|
||
|
||
if (type === "audio") return "audio";
|
||
if (type === "text") return "text";
|
||
if (type === "compose") return "compose";
|
||
if (name.includes("保存")) return "save";
|
||
if (name.includes("预览")) return "preview";
|
||
if (type === "image" && modes.some((mode) => mode.includes("video"))) return "video";
|
||
if (type === "image") return "image";
|
||
return "video";
|
||
}
|
||
|
||
function getToolModes(tool: CapturedTool) {
|
||
return (tool.modes || []).map(String).filter(Boolean).slice(0, 3);
|
||
}
|
||
|
||
function getVisibleOrigin(viewport: { x: number; y: number; zoom: number }) {
|
||
return {
|
||
x: (-viewport.x + 320) / viewport.zoom,
|
||
y: (-viewport.y + 220) / viewport.zoom,
|
||
};
|
||
}
|
||
|
||
function patchNodeForCapturedTool(node: CanvasNode, tool: CapturedTool): Partial<CanvasNode> {
|
||
const label = getToolLabel(tool);
|
||
const metadata = {
|
||
...((node.data as any)?.metadata || {}),
|
||
source: "liblib-capture",
|
||
backendStatus: "placeholder",
|
||
liblibTool: {
|
||
type: tool.type || "",
|
||
toolKey: tool.toolKey || "",
|
||
modelKey: tool.modelKey || "",
|
||
modelName: tool.modelName || "",
|
||
vendor: tool.vendor || "",
|
||
scene: tool.scene || "",
|
||
modes: tool.modes || [],
|
||
settings: tool.settings || [],
|
||
advancedSettings: tool.advancedSettings || [],
|
||
propertyKeys: tool.propertyKeys || [],
|
||
properties: tool.metadata?.properties || {},
|
||
config: tool.metadata?.config || {},
|
||
generateTypes: tool.generateTypes || tool.metadata?.generateTypes || {},
|
||
rules: tool.rules || tool.metadata?.rules || [],
|
||
},
|
||
};
|
||
|
||
if (node.type === "workflow") {
|
||
const data = node.data as WorkflowNodeData;
|
||
return {
|
||
data: {
|
||
...data,
|
||
title: label,
|
||
description: `${tool.vendor || "LibLib"} / ${tool.scene || tool.modelKey || "模型模块"}`,
|
||
summary: "前端模块已迁移,真实生成接口待后续接入。",
|
||
metadata,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (node.type === "image") {
|
||
return {
|
||
data: {
|
||
...(node.data as any),
|
||
title: label,
|
||
prompt: "上传或连接上游图片后可接入生成。",
|
||
status: "pending",
|
||
metadata,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (node.type === "video") {
|
||
return {
|
||
data: {
|
||
...(node.data as any),
|
||
prompt: "上传或连接上游视频后可接入生成。",
|
||
status: "pending",
|
||
metadata,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (node.type === "text") {
|
||
return {
|
||
data: {
|
||
...(node.data as any),
|
||
content: `${label}\n\n在这里输入提示词、脚本或约束。`,
|
||
metadata,
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
...(node.data as any),
|
||
metadata,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildTargetCanvasNode({
|
||
description,
|
||
kind,
|
||
metadata,
|
||
position,
|
||
title,
|
||
}: {
|
||
description?: string;
|
||
kind: TargetNodeKind;
|
||
metadata?: Record<string, unknown>;
|
||
position: Position;
|
||
title: string;
|
||
}): Omit<CanvasNode, "id" | "createdAt" | "updatedAt"> {
|
||
const baseMetadata = {
|
||
source: "liblib-target-replica",
|
||
backendStatus: "placeholder",
|
||
...metadata,
|
||
};
|
||
|
||
if (kind === "image") {
|
||
return {
|
||
type: "image",
|
||
position,
|
||
size: { width: 260, height: 220 },
|
||
data: {
|
||
src: "",
|
||
status: "pending",
|
||
title,
|
||
prompt: description || "图片模块前端已就绪,生成接口待接入。",
|
||
note: "上传、参考图、结果图能力后续接入。",
|
||
metadata: baseMetadata,
|
||
},
|
||
connections: [],
|
||
};
|
||
}
|
||
|
||
if (kind === "video") {
|
||
return {
|
||
type: "video",
|
||
position,
|
||
size: { width: 300, height: 220 },
|
||
data: {
|
||
src: "",
|
||
status: "pending",
|
||
prompt: description || "视频模块前端已就绪,生成接口待接入。",
|
||
metadata: baseMetadata,
|
||
},
|
||
connections: [],
|
||
};
|
||
}
|
||
|
||
if (kind === "text") {
|
||
return {
|
||
type: "text",
|
||
position,
|
||
size: { width: 300, height: 170 },
|
||
data: {
|
||
content: `${title}\n\n输入提示词、脚本、镜头描述或参数。`,
|
||
fontSize: 15,
|
||
color: "#f4f4f5",
|
||
lineHeight: 1.55,
|
||
borderWidth: 1,
|
||
borderColor: "rgba(255,255,255,0.12)",
|
||
borderRadius: 8,
|
||
padding: 18,
|
||
backgroundColor: "rgba(31,31,31,0.96)",
|
||
metadata: baseMetadata,
|
||
},
|
||
connections: [],
|
||
};
|
||
}
|
||
|
||
const iconTitle =
|
||
kind === "audio"
|
||
? "音频模块"
|
||
: kind === "preview"
|
||
? "预览模块"
|
||
: kind === "save"
|
||
? "保存模块"
|
||
: "工作流模块";
|
||
|
||
return {
|
||
type: "workflow",
|
||
position,
|
||
size: { width: kind === "compose" ? 340 : 280, height: 170 },
|
||
data: {
|
||
templateType: `liblib-${kind}-${title}`,
|
||
title,
|
||
description: description || iconTitle,
|
||
summary: "前端节点已迁移,后端能力留空待补。",
|
||
status: "pending",
|
||
group: kind === "audio" ? "generation" : kind === "save" || kind === "preview" ? "output" : "tool",
|
||
accentColor:
|
||
kind === "audio"
|
||
? "#80ed99"
|
||
: kind === "save" || kind === "preview"
|
||
? "#70d6ff"
|
||
: "#ff8fab",
|
||
metadata: baseMetadata,
|
||
},
|
||
connections: [],
|
||
};
|
||
}
|
||
|
||
function getNodeLiblibTool(node: CanvasNode | null) {
|
||
const metadata = ((node?.data as any)?.metadata || {}) as Record<string, any>;
|
||
return (metadata.liblibTool || null) as
|
||
| (CapturedTool & {
|
||
properties?: Record<string, unknown>;
|
||
config?: Record<string, unknown>;
|
||
generateTypes?: unknown;
|
||
rules?: unknown;
|
||
})
|
||
| null;
|
||
}
|
||
|
||
function getNodeDraftValues(node: CanvasNode | null) {
|
||
const metadata = ((node?.data as any)?.metadata || {}) as Record<string, any>;
|
||
return (metadata.draftValues || {}) as Record<string, unknown>;
|
||
}
|
||
|
||
function getPropertyEntries(tool: ReturnType<typeof getNodeLiblibTool>) {
|
||
const properties = tool?.properties;
|
||
if (!properties || typeof properties !== "object") return [];
|
||
return Object.entries(properties).filter(([key]) => key !== "modeType");
|
||
}
|
||
|
||
function getPropertyLabel(key: string, config: unknown) {
|
||
if (config && typeof config === "object" && "displayName" in config) {
|
||
const displayName = (config as { displayName?: unknown }).displayName;
|
||
if (typeof displayName === "string" && displayName.trim()) return displayName;
|
||
}
|
||
const labels: Record<string, string> = {
|
||
count: "生成数量",
|
||
frameRate: "帧率",
|
||
lora: "风格",
|
||
media: "素材",
|
||
prompt: "提示词",
|
||
quality: "分辨率",
|
||
ratio: "比例",
|
||
resolution: "分辨率",
|
||
searchable: "联网搜索",
|
||
seed: "种子",
|
||
style: "模型",
|
||
template: "风格",
|
||
};
|
||
return labels[key] || key;
|
||
}
|
||
|
||
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 normalizeEnumOptions(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 getDefaultPropertyValue(key: string, config: unknown) {
|
||
if (config && typeof config === "object" && "default" in config) {
|
||
return String((config as { default?: unknown }).default ?? "");
|
||
}
|
||
const options = normalizeEnumOptions(config);
|
||
if (options.length > 0) return getOptionValue(options[0]);
|
||
if (typeof config === "boolean") return config ? "true" : "false";
|
||
if (key === "prompt") return "";
|
||
return "";
|
||
}
|
||
|
||
function rulesRequireMedia(tool: ReturnType<typeof getNodeLiblibTool>) {
|
||
const rules = tool?.rules;
|
||
if (!Array.isArray(rules)) return false;
|
||
return rules.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 GlassButton({
|
||
active,
|
||
children,
|
||
className,
|
||
disabled,
|
||
onClick,
|
||
title,
|
||
}: {
|
||
active?: boolean;
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
disabled?: boolean;
|
||
onClick?: () => void;
|
||
title?: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex h-10 min-w-10 items-center justify-center rounded-[8px] border border-white/8 bg-[#1d1d1d] px-3 text-[#d9d9d9] text-sm shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_12px_30px_rgba(0,0,0,0.28)] transition hover:bg-[#282828] hover:text-white disabled:cursor-not-allowed disabled:opacity-60",
|
||
active && "border-white/18 bg-[#313131] text-white",
|
||
className
|
||
)}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
title={title}
|
||
type="button"
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function FloatingModal({
|
||
children,
|
||
onClose,
|
||
title,
|
||
width = 520,
|
||
}: {
|
||
children: React.ReactNode;
|
||
onClose: () => void;
|
||
title: string;
|
||
width?: number;
|
||
}) {
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/68 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.56)]"
|
||
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="flex h-8 w-8 items-center justify-center rounded-[8px] text-white/62 transition 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>
|
||
);
|
||
}
|
||
|
||
export function LiblibVideoCanvasReplica({
|
||
canvasId,
|
||
isLoading = false,
|
||
}: LiblibVideoCanvasReplicaProps) {
|
||
const router = useRouter();
|
||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||
const tool = useCanvasStore(canvasSelectors.tool);
|
||
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
||
const setProject = useCanvasStore((state) => state.setProject);
|
||
const setProjectTitle = useCanvasStore((state) => state.setProjectTitle);
|
||
const setTool = useCanvasStore((state) => state.setTool);
|
||
const setViewport = useCanvasStore((state) => state.setViewport);
|
||
const addNode = useCanvasStore((state) => state.addNode);
|
||
const addEdge = useCanvasStore((state) => state.addEdge);
|
||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||
const clearSelection = useCanvasStore((state) => state.clearSelection);
|
||
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||
const clearCanvas = useCanvasStore((state) => state.clearCanvas);
|
||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
|
||
const [toolSearch, setToolSearch] = useState("");
|
||
const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部");
|
||
const [isTitleEditing, setIsTitleEditing] = useState(false);
|
||
const [draftTitle, setDraftTitle] = useState(projectTitle || "未命名");
|
||
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) => selectedNodeIds.includes(node.id)) || null,
|
||
[nodes, selectedNodeIds]
|
||
);
|
||
const isBusy = isLoading || isProjectLoading;
|
||
|
||
useEffect(() => {
|
||
if (!isTitleEditing) {
|
||
setDraftTitle(projectTitle || "未命名视频画布");
|
||
}
|
||
}, [isTitleEditing, projectTitle]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
|
||
async function loadTargetCanvas() {
|
||
setIsProjectLoading(true);
|
||
clearCanvas();
|
||
|
||
const result = await canvasService.getProject(canvasId);
|
||
if (!active) return;
|
||
|
||
if (result.success && result.data) {
|
||
setProject(canvasId, result.data.title || "未命名视频画布");
|
||
if (result.data.canvasData) {
|
||
loadCanvas(result.data.canvasData);
|
||
}
|
||
setIsProjectLoading(false);
|
||
return;
|
||
}
|
||
|
||
setProject(canvasId, "未命名视频画布");
|
||
await canvasService
|
||
.saveCanvasState(
|
||
canvasId,
|
||
{
|
||
nodes: [],
|
||
edges: [],
|
||
selectedNodeIds: [],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
tool: "select",
|
||
},
|
||
"video"
|
||
)
|
||
.catch(() => undefined);
|
||
if (active) {
|
||
setIsProjectLoading(false);
|
||
}
|
||
}
|
||
|
||
void loadTargetCanvas();
|
||
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [canvasId, clearCanvas, loadCanvas, setProject]);
|
||
|
||
const filteredTools = useMemo(() => {
|
||
const normalizedSearch = toolSearch.trim().toLowerCase();
|
||
return capturedTools.filter((item) => {
|
||
const type = getToolType(item);
|
||
const tabMatched =
|
||
toolTab === "全部" ||
|
||
(toolTab === "图片" && type === "image") ||
|
||
(toolTab === "视频" && type === "video") ||
|
||
(toolTab === "文本" && type === "text") ||
|
||
(toolTab === "音频" && type === "audio") ||
|
||
(toolTab === "组合" && type === "compose");
|
||
if (!tabMatched) return false;
|
||
if (!normalizedSearch) return true;
|
||
return [
|
||
item.modelName,
|
||
item.vendor,
|
||
item.scene,
|
||
item.toolKey,
|
||
item.modelKey,
|
||
...(item.modes || []),
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(normalizedSearch);
|
||
});
|
||
}, [toolSearch, toolTab]);
|
||
|
||
const saveCanvas = useCallback(async () => {
|
||
setIsSaving(true);
|
||
try {
|
||
const result = await canvasService.saveCanvasState(canvasId, getCanvasState(), "video");
|
||
if (!result.success) {
|
||
toast.error(result.error || "保存失败");
|
||
return;
|
||
}
|
||
toast.success("视频画布已保存");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}, [canvasId, getCanvasState]);
|
||
|
||
const commitTitle = useCallback(async () => {
|
||
const nextTitle = draftTitle.trim() || "未命名";
|
||
setProjectTitle(nextTitle);
|
||
setIsTitleEditing(false);
|
||
await canvasService.updateProject(canvasId, { title: nextTitle }, "video");
|
||
}, [canvasId, draftTitle, setProjectTitle]);
|
||
|
||
const getPlacement = useCallback(
|
||
(size: { width: number; height: number }, index = 0): Position => {
|
||
const origin = getVisibleOrigin(viewport);
|
||
const selected = selectedNode;
|
||
if (selected) {
|
||
return {
|
||
x: selected.position.x + selected.size.width + 180 + index * 48,
|
||
y: selected.position.y + selected.size.height / 2 - size.height / 2 + index * 36,
|
||
};
|
||
}
|
||
return {
|
||
x: origin.x + index * 110,
|
||
y: origin.y + index * 24,
|
||
};
|
||
},
|
||
[selectedNode, viewport]
|
||
);
|
||
|
||
const createTargetNode = useCallback(
|
||
(
|
||
payload: {
|
||
description?: string;
|
||
kind: TargetNodeKind;
|
||
metadata?: Record<string, unknown>;
|
||
title: string;
|
||
},
|
||
options?: {
|
||
position?: Position;
|
||
connectFromSelection?: boolean;
|
||
patchTool?: CapturedTool;
|
||
index?: number;
|
||
}
|
||
) => {
|
||
const size =
|
||
payload.kind === "video"
|
||
? { width: 300, height: 220 }
|
||
: payload.kind === "image"
|
||
? { width: 260, height: 220 }
|
||
: payload.kind === "text"
|
||
? { width: 300, height: 170 }
|
||
: { width: payload.kind === "compose" ? 340 : 280, height: 170 };
|
||
const nodeConfig = buildTargetCanvasNode({
|
||
...payload,
|
||
position: options?.position || getPlacement(size, options?.index || 0),
|
||
});
|
||
const nodeId = addNode(nodeConfig);
|
||
const sourceNodeId =
|
||
options?.connectFromSelection !== false && selectedNode ? selectedNode.id : null;
|
||
if (sourceNodeId && sourceNodeId !== nodeId) {
|
||
addEdge({ source: sourceNodeId, target: nodeId, type: "default" });
|
||
}
|
||
if (options?.patchTool) {
|
||
const createdNode = useCanvasStore.getState().nodes.find((node) => node.id === nodeId);
|
||
if (createdNode) {
|
||
updateNode(nodeId, patchNodeForCapturedTool(createdNode, options.patchTool));
|
||
}
|
||
}
|
||
selectNodes([nodeId]);
|
||
return nodeId;
|
||
},
|
||
[addEdge, addNode, getPlacement, selectNodes, selectedNode, updateNode]
|
||
);
|
||
|
||
const createToolNode = useCallback(
|
||
(capturedTool: CapturedTool) => {
|
||
const kind = inferNodeKindForTool(capturedTool);
|
||
createTargetNode(
|
||
{
|
||
kind,
|
||
title: getToolLabel(capturedTool),
|
||
description: `${capturedTool.vendor || "LibLib"} / ${capturedTool.scene || capturedTool.modelKey || "目标模块"}`,
|
||
metadata: {
|
||
source: "liblib-tool-spec",
|
||
},
|
||
},
|
||
{ patchTool: capturedTool }
|
||
);
|
||
toast.success(`已添加 ${getToolLabel(capturedTool)}`);
|
||
},
|
||
[createTargetNode]
|
||
);
|
||
|
||
const createStarter = useCallback(
|
||
(starter: (typeof STARTER_CARDS)[number]) => {
|
||
const origin = getVisibleOrigin(viewport);
|
||
const nodeIds = starter.nodes.map((item, index) => {
|
||
const position = {
|
||
x: origin.x + index * 390,
|
||
y: origin.y + (index % 2) * 18,
|
||
};
|
||
return addNode(
|
||
buildTargetCanvasNode({
|
||
kind: item.kind,
|
||
title: item.label,
|
||
description: starter.title,
|
||
metadata: {
|
||
source: "liblib-starter-card",
|
||
starterTitle: starter.title,
|
||
},
|
||
position,
|
||
})
|
||
);
|
||
});
|
||
nodeIds.slice(1).forEach((nodeId, index) => {
|
||
const source = nodeIds[index];
|
||
if (source && nodeId) {
|
||
addEdge({ source, target: nodeId, type: "default" });
|
||
}
|
||
});
|
||
if (nodeIds.length > 0) {
|
||
selectNodes([nodeIds[nodeIds.length - 1]]);
|
||
}
|
||
toast.success(`已创建 ${starter.title}`);
|
||
},
|
||
[addEdge, addNode, selectNodes, viewport]
|
||
);
|
||
|
||
const createWorkflowPresetNode = useCallback(
|
||
(workflow: (typeof capturedWorkflows)[number]) => {
|
||
createTargetNode(
|
||
{
|
||
kind: "workflow",
|
||
title: String(workflow.name || workflow.title || "工作流预设"),
|
||
description: String(workflow.description || "目标前端预设,后端执行待接入。"),
|
||
metadata: {
|
||
source: "liblib-workflow-preset",
|
||
workflowId: workflow.id || "",
|
||
workflowUuid: (workflow as any).uuid || "",
|
||
cover: (workflow as any).cover || "",
|
||
},
|
||
},
|
||
{ connectFromSelection: true }
|
||
);
|
||
toast.success("已添加目标工作流预设");
|
||
},
|
||
[createTargetNode]
|
||
);
|
||
|
||
const updateSelectedDraftValue = useCallback(
|
||
(key: string, value: unknown) => {
|
||
if (!selectedNode) return;
|
||
const data = selectedNode.data as Record<string, any>;
|
||
const metadata = (data.metadata || {}) as Record<string, any>;
|
||
updateNode(selectedNode.id, {
|
||
data: {
|
||
...data,
|
||
metadata: {
|
||
...metadata,
|
||
draftValues: {
|
||
...((metadata.draftValues || {}) as Record<string, unknown>),
|
||
[key]: value,
|
||
},
|
||
},
|
||
} as unknown as CanvasNode["data"],
|
||
});
|
||
},
|
||
[selectedNode, updateNode]
|
||
);
|
||
|
||
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 openPanel = useCallback((panel: Exclude<ActivePanel, null>) => {
|
||
setActivePanel((current) => (current === panel ? null : panel));
|
||
}, []);
|
||
|
||
return (
|
||
<div className="relative h-dvh w-full overflow-hidden bg-[#050607] text-white">
|
||
<div className="absolute inset-0 z-0">
|
||
<CanvasWorkspace
|
||
emptyStateDescription=""
|
||
onBlankCanvasDoubleClick={(payload) => {
|
||
clearSelection();
|
||
setActivePanel("add");
|
||
setViewport({
|
||
x: viewport.x,
|
||
y: viewport.y,
|
||
zoom: viewport.zoom,
|
||
});
|
||
}}
|
||
showEmptyState={false}
|
||
showZoomControls={false}
|
||
workspaceLabel="视频画布"
|
||
workspaceVariant="video"
|
||
/>
|
||
</div>
|
||
|
||
{isBusy ? (
|
||
<div className="absolute inset-0 z-[80] flex items-center justify-center bg-[#050607]/72 backdrop-blur-sm">
|
||
<div className="flex items-center gap-3 rounded-[8px] border border-white/10 bg-[#1d1d1d] px-4 py-3 text-white/78">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在打开视频画布...
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<header className="pointer-events-none absolute top-0 right-0 left-0 z-30 flex h-[76px] 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 font-semibold text-white transition hover:text-white/78"
|
||
onClick={() => router.push("/")}
|
||
type="button"
|
||
>
|
||
<span className="relative h-4 w-6 rounded-[2px] border border-white/85">
|
||
<span className="absolute -right-1 top-1 h-2 w-3 rounded-[1px] bg-white" />
|
||
</span>
|
||
<span className="italic tracking-tight">Video Canvas</span>
|
||
</button>
|
||
|
||
<div className="h-5 w-px bg-white/14" />
|
||
|
||
{isTitleEditing ? (
|
||
<input
|
||
autoFocus
|
||
className="h-9 w-[220px] rounded-[8px] border border-white/12 bg-[#1b1b1b] px-3 text-sm text-white outline-none focus:border-white/28"
|
||
onBlur={() => void commitTitle()}
|
||
onChange={(event) => setDraftTitle(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") void commitTitle();
|
||
if (event.key === "Escape") setIsTitleEditing(false);
|
||
}}
|
||
value={draftTitle}
|
||
/>
|
||
) : (
|
||
<button
|
||
className="max-w-[260px] truncate text-left font-medium text-[15px] text-white/86 hover:text-white"
|
||
onClick={() => {
|
||
setDraftTitle(projectTitle || "未命名");
|
||
setIsTitleEditing(true);
|
||
}}
|
||
type="button"
|
||
>
|
||
{projectTitle || "未命名"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="pointer-events-auto flex items-center gap-2">
|
||
<GlassButton onClick={() => openPanel("skills")}>LibTV Skills</GlassButton>
|
||
<GlassButton disabled={isSaving} onClick={() => void saveCanvas()} title="保存">
|
||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||
</GlassButton>
|
||
<GlassButton onClick={() => openPanel("share")} title="分享">
|
||
<Share2 className="h-4 w-4" />
|
||
</GlassButton>
|
||
<GlassButton onClick={() => openPanel("notifications")} title="通知">
|
||
<Bell className="h-4 w-4" />
|
||
</GlassButton>
|
||
<GlassButton
|
||
className="gap-2 px-4 text-cyan-300"
|
||
onClick={() => openPanel("member")}
|
||
>
|
||
<Crown className="h-4 w-4" />
|
||
会员超市
|
||
</GlassButton>
|
||
<GlassButton className="gap-2 px-3 text-amber-200" onClick={() => openPanel("member")}>
|
||
<Zap className="h-4 w-4" />
|
||
会员特惠
|
||
</GlassButton>
|
||
<GlassButton onClick={() => openPanel("account")} title="账号">
|
||
<UserRound className="h-4 w-4" />
|
||
</GlassButton>
|
||
</div>
|
||
</header>
|
||
|
||
<aside className="-translate-y-1/2 absolute top-1/2 left-4 z-30 flex flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#1c1c1c] p-2 shadow-[0_22px_70px_rgba(0,0,0,0.45)]">
|
||
{TOOLBAR_ITEMS.map((item, index) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"relative flex h-11 w-11 items-center justify-center rounded-[8px] text-white/68 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={() => openPanel(item.id)}
|
||
title={item.label}
|
||
type="button"
|
||
>
|
||
<Icon className="h-5 w-5" />
|
||
</button>
|
||
);
|
||
})}
|
||
</aside>
|
||
|
||
{activePanel === "add" ? (
|
||
<section className="absolute top-[86px] left-[82px] z-40 flex h-[min(720px,calc(100vh-116px))] w-[390px] flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.46)]">
|
||
<div className="border-white/8 border-b p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-semibold text-[16px]">添加功能模块</div>
|
||
<div className="mt-1 text-[12px] text-white/46">
|
||
已迁移 {capturedCounts.tools || capturedTools.length} 个工具配置
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="rounded-[8px] p-2 text-white/52 hover:bg-white/8 hover:text-white"
|
||
onClick={() => setActivePanel(null)}
|
||
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/22 px-3 py-2">
|
||
<Search className="h-4 w-4 text-white/42" />
|
||
<input
|
||
className="min-w-0 flex-1 bg-transparent text-[13px] text-white outline-none placeholder:text-white/32"
|
||
onChange={(event) => setToolSearch(event.target.value)}
|
||
placeholder="搜索模型、厂商、模式"
|
||
value={toolSearch}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{PANEL_TABS.map((tab) => (
|
||
<button
|
||
className={cn(
|
||
"rounded-[8px] border border-white/8 px-2.5 py-1 text-[12px] text-white/58 transition hover:bg-white/8 hover:text-white",
|
||
toolTab === tab && "border-white/20 bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
key={tab}
|
||
onClick={() => setToolTab(tab)}
|
||
type="button"
|
||
>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||
<div className="grid gap-2">
|
||
{filteredTools.map((capturedTool) => {
|
||
const type = getToolType(capturedTool);
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
const Icon = meta.icon;
|
||
const modes = getToolModes(capturedTool);
|
||
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={`${capturedTool.toolKey || capturedTool.modelKey}-${capturedTool.modelName}`}
|
||
onClick={() => createToolNode(capturedTool)}
|
||
type="button"
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<span
|
||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] text-black"
|
||
style={{ backgroundColor: meta.color }}
|
||
>
|
||
<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(capturedTool)}
|
||
</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">
|
||
{capturedTool.vendor || meta.label}
|
||
</span>
|
||
{modes.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/30 transition group-hover:text-white/80" />
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{activePanel === "workflow" ? (
|
||
<section className="absolute top-[86px] left-[82px] z-40 h-[min(720px,calc(100vh-116px))] w-[410px] overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.46)]">
|
||
<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/46">
|
||
抓取到 {capturedWorkflows.length || 25} 个预设,后端执行先留空
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="rounded-[8px] p-2 text-white/52 hover:bg-white/8 hover:text-white"
|
||
onClick={() => setActivePanel(null)}
|
||
type="button"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="h-[calc(100%-73px)] overflow-y-auto p-4">
|
||
<div className="grid gap-3">
|
||
{capturedWorkflows.map((workflow) => {
|
||
const cover = String((workflow as any).cover || "").trim();
|
||
return (
|
||
<button
|
||
className="group overflow-hidden rounded-[8px] border border-white/8 bg-[#2a2a2a] text-left transition hover:border-white/18 hover:bg-[#323232]"
|
||
key={`${workflow.id || ""}-${(workflow as any).uuid || workflow.name}`}
|
||
onClick={() => createWorkflowPresetNode(workflow)}
|
||
type="button"
|
||
>
|
||
{cover ? (
|
||
<div
|
||
className="h-24 bg-cover bg-center"
|
||
style={{
|
||
backgroundImage: `linear-gradient(90deg, rgba(0,0,0,0.72), rgba(0,0,0,0.18)), url(${cover})`,
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="flex h-24 items-center justify-center bg-white/6 text-white/34">
|
||
<GitBranch className="h-6 w-6" />
|
||
</div>
|
||
)}
|
||
<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/48">
|
||
{workflow.description || "目标前端工作流预设,执行接口待补充。"}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{nodes.length === 0 && activePanel !== "add" && activePanel !== "workflow" ? (
|
||
<div className="-translate-x-1/2 -translate-y-1/2 absolute top-[52%] left-1/2 z-20 w-[min(900px,calc(100vw-180px))]">
|
||
<div className="mb-6 flex items-center justify-center gap-2 text-white/42 text-sm">
|
||
<MousePointer2 className="h-5 w-5" />
|
||
双击画布 自由生成节点
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{STARTER_CARDS.map((card) => (
|
||
<button
|
||
className={cn(
|
||
"group h-14 overflow-hidden rounded-[8px] border border-white/10 bg-gradient-to-r px-5 text-left shadow-[0_18px_42px_rgba(0,0,0,0.28)] transition hover:-translate-y-0.5 hover:border-white/20",
|
||
card.tone
|
||
)}
|
||
key={card.title}
|
||
onClick={() => createStarter(card)}
|
||
type="button"
|
||
>
|
||
<div className="font-medium text-[13px] text-white">{card.title}</div>
|
||
<div className="mt-1 text-[11px] text-white/44">{card.subtitle}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="absolute bottom-4 left-4 z-30 flex items-center gap-1 rounded-[8px] border border-white/10 bg-[#1d1d1d] p-1.5 text-white/70 shadow-[0_18px_46px_rgba(0,0,0,0.38)]">
|
||
<button
|
||
className={cn(
|
||
"flex h-8 w-8 items-center justify-center rounded-[8px] hover:bg-white/10 hover:text-white",
|
||
tool === "select" && "bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
onClick={() => setTool("select")}
|
||
title="选择"
|
||
type="button"
|
||
>
|
||
<MousePointer2 className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className={cn(
|
||
"flex h-8 w-8 items-center justify-center rounded-[8px] hover:bg-white/10 hover:text-white",
|
||
tool === "pan" && "bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
onClick={() => setTool("pan")}
|
||
title="平移"
|
||
type="button"
|
||
>
|
||
<Box className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className={cn(
|
||
"flex h-8 w-8 items-center justify-center rounded-[8px] hover:bg-white/10 hover:text-white",
|
||
tool === "draw" && "bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
onClick={() => setTool("draw")}
|
||
title="连线"
|
||
type="button"
|
||
>
|
||
<GitBranch className="h-4 w-4" />
|
||
</button>
|
||
<div className="mx-1 h-5 w-px bg-white/10" />
|
||
<button
|
||
className="flex h-8 w-8 items-center justify-center rounded-[8px] hover:bg-white/10 hover:text-white"
|
||
onClick={() => setViewport({ zoom: Math.max(0.1, viewport.zoom - 0.1) })}
|
||
title="缩小"
|
||
type="button"
|
||
>
|
||
<Minus className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className="h-8 min-w-[62px] rounded-[8px] px-2 text-[13px] hover:bg-white/10 hover:text-white"
|
||
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 })}
|
||
type="button"
|
||
>
|
||
{Math.round(viewport.zoom * 100)}%
|
||
</button>
|
||
<button
|
||
className="flex h-8 w-8 items-center justify-center rounded-[8px] hover:bg-white/10 hover:text-white"
|
||
onClick={() => setViewport({ zoom: Math.min(4, viewport.zoom + 0.1) })}
|
||
title="放大"
|
||
type="button"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{selectedNode ? (
|
||
<TargetNodeInspector
|
||
node={selectedNode}
|
||
onChangeProperty={updateSelectedDraftValue}
|
||
onClose={clearSelection}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "assets" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="素材库">
|
||
<EmptyPanel
|
||
icon={FolderOpen}
|
||
title="素材接口待接入"
|
||
description="前端入口已经迁移,后续可接 `/api/canvas/media-asset/tag-tree`、上传、图库与项目素材列表。"
|
||
/>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "history" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="历史记录">
|
||
<div className="space-y-2">
|
||
{nodes.length === 0 ? (
|
||
<EmptyPanel
|
||
icon={Clock3}
|
||
title="暂无节点历史"
|
||
description="创建或执行节点后,这里可以接入草稿版本、生成记录和回滚。"
|
||
/>
|
||
) : (
|
||
nodes.slice(-12).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={() => selectNodes([node.id])}
|
||
type="button"
|
||
>
|
||
<span>
|
||
<span className="block text-[13px] text-white/86">
|
||
{(node.data as any)?.title || (node.data as any)?.content?.slice?.(0, 18) || node.type}
|
||
</span>
|
||
<span className="text-[11px] text-white/38">{node.type}</span>
|
||
</span>
|
||
<Eye className="h-4 w-4 text-white/38" />
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "help" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="快捷键">
|
||
<div className="grid gap-2 text-[13px] text-white/70">
|
||
{[
|
||
["V", "选择工具"],
|
||
["H / 空格拖动", "平移画布"],
|
||
["L", "工作流连线"],
|
||
["⌘ / Ctrl + S", "保存"],
|
||
["双击空白", "打开添加模块"],
|
||
["双击连线", "删除连线"],
|
||
].map(([key, label]) => (
|
||
<div className="flex items-center justify-between rounded-[8px] bg-white/5 px-3 py-2" key={key}>
|
||
<span>{label}</span>
|
||
<span className="rounded-[6px] bg-white/10 px-2 py-1 font-mono text-white/58">{key}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "support" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="支持中心">
|
||
<EmptyPanel
|
||
icon={Headphones}
|
||
title="客服与反馈占位"
|
||
description="这里后续接入工单、飞书/钉钉客服或帮助文档。"
|
||
/>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "skills" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="LibTV Skills">
|
||
<div className="space-y-4">
|
||
<EmptyPanel
|
||
icon={KeyRound}
|
||
title="OpenAPI Access Key 占位"
|
||
description="抓取页会打开 access key 管理弹窗;当前先保留前端入口,后续接入账号体系。"
|
||
/>
|
||
<div className="rounded-[8px] border border-white/8 bg-black/20 p-3">
|
||
<div className="text-[12px] text-white/42">能力覆盖</div>
|
||
<div className="mt-2 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/5 p-2 text-center" key={type}>
|
||
<div className="text-[16px] text-white">{String(count)}</div>
|
||
<div className="mt-1 text-[11px] text-white/42">{meta.label}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "share" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="发布与分享">
|
||
<div className="space-y-4">
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-4">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<div className="font-medium">分享链接</div>
|
||
<div className="mt-1 text-[12px] text-white/42">
|
||
打开后可查看当前视频画布,权限控制后续接入。
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="rounded-[8px] bg-white px-3 py-2 font-medium text-black text-sm hover:bg-white/88"
|
||
onClick={() => void copyShareLink()}
|
||
type="button"
|
||
>
|
||
<Copy className="mr-1 inline h-4 w-4" />
|
||
复制
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-[8px] border border-white/8 bg-white/5 p-4">
|
||
<span className="text-sm">启用分享</span>
|
||
<button
|
||
className={cn(
|
||
"relative h-6 w-11 rounded-full transition",
|
||
shareEnabled ? "bg-blue-500" : "bg-white/18"
|
||
)}
|
||
onClick={() => setShareEnabled((value) => !value)}
|
||
type="button"
|
||
>
|
||
<span
|
||
className={cn(
|
||
"absolute top-1 h-4 w-4 rounded-full bg-white transition",
|
||
shareEnabled ? "left-6" : "left-1"
|
||
)}
|
||
/>
|
||
</button>
|
||
</div>
|
||
<button
|
||
className="w-full rounded-[8px] bg-blue-500 px-4 py-3 font-semibold text-white hover:bg-blue-400"
|
||
onClick={() => setActivePanel("publish")}
|
||
type="button"
|
||
>
|
||
在作品广场发布
|
||
</button>
|
||
</div>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "publish" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="发布作品到视频画布广场" width={560}>
|
||
<div className="space-y-4">
|
||
<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/20 px-3 text-sm outline-none focus:border-white/24"
|
||
defaultValue={projectTitle || "未命名"}
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">作品描述</span>
|
||
<textarea
|
||
className="min-h-[82px] w-full rounded-[8px] border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none focus:border-white/24"
|
||
placeholder="请输入作品描述"
|
||
/>
|
||
</label>
|
||
<div className="flex items-center justify-between rounded-[8px] border border-white/8 bg-white/5 p-3">
|
||
<span className="text-sm">公开画布</span>
|
||
<button
|
||
className={cn(
|
||
"relative h-6 w-11 rounded-full transition",
|
||
publishPublic ? "bg-blue-500" : "bg-white/18"
|
||
)}
|
||
onClick={() => setPublishPublic((value) => !value)}
|
||
type="button"
|
||
>
|
||
<span
|
||
className={cn(
|
||
"absolute top-1 h-4 w-4 rounded-full bg-white transition",
|
||
publishPublic ? "left-6" : "left-1"
|
||
)}
|
||
/>
|
||
</button>
|
||
</div>
|
||
<button
|
||
className="w-full rounded-[8px] bg-blue-500 px-4 py-3 font-semibold text-white hover:bg-blue-400"
|
||
onClick={() => toast.info("发布接口待接入,前端表单已迁移。")}
|
||
type="button"
|
||
>
|
||
发布并投稿
|
||
</button>
|
||
</div>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "member" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="会员中心">
|
||
<EmptyPanel
|
||
icon={Crown}
|
||
title="会员/积分接口待接入"
|
||
description="已保留会员超市、特惠倒计时和积分入口,后续接入支付和权益接口。"
|
||
/>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "notifications" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="通知">
|
||
<EmptyPanel
|
||
icon={Bell}
|
||
title="暂无通知"
|
||
description="后续可接入生成任务、发布审核、系统消息。"
|
||
/>
|
||
</FloatingModal>
|
||
) : null}
|
||
|
||
{activePanel === "account" ? (
|
||
<FloatingModal onClose={() => setActivePanel(null)} title="账户">
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-3 rounded-[8px] bg-white/5 p-3">
|
||
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-blue-500 text-white">
|
||
<UserRound className="h-5 w-5" />
|
||
</span>
|
||
<div>
|
||
<div className="font-medium">当前用户</div>
|
||
<div className="text-[12px] text-white/42">登录体系沿用现有项目</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="flex w-full items-center gap-2 rounded-[8px] border border-white/8 bg-white/5 px-3 py-2 text-left text-white/72 hover:bg-white/8"
|
||
onClick={() => toast.info("账户设置入口待接入。")}
|
||
type="button"
|
||
>
|
||
<Lock className="h-4 w-4" />
|
||
账户管理
|
||
</button>
|
||
</div>
|
||
</FloatingModal>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TargetNodeInspector({
|
||
node,
|
||
onChangeProperty,
|
||
onClose,
|
||
}: {
|
||
node: CanvasNode;
|
||
onChangeProperty: (key: string, value: unknown) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const tool = getNodeLiblibTool(node);
|
||
const draftValues = getNodeDraftValues(node);
|
||
const entries = getPropertyEntries(tool);
|
||
const nodeData = node.data as Record<string, any>;
|
||
const title =
|
||
nodeData.title ||
|
||
tool?.modelName ||
|
||
String(nodeData.content || node.type).split("\n")[0] ||
|
||
"目标模块";
|
||
const type = String(tool?.type || node.type || "module");
|
||
const modeLabels = (tool?.modes || []).map(String).filter(Boolean);
|
||
const propertyCount = entries.length;
|
||
|
||
return (
|
||
<aside
|
||
className="absolute top-[86px] right-4 z-30 flex max-h-[calc(100vh-110px)] w-[340px] flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] text-white shadow-[0_28px_90px_rgba(0,0,0,0.46)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="flex items-start justify-between gap-3 border-white/8 border-b p-4">
|
||
<div className="min-w-0">
|
||
<div className="truncate font-semibold text-[15px]">{title}</div>
|
||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||
<span className="rounded-[6px] bg-white/8 px-2 py-0.5 text-[11px] text-white/52">
|
||
{tool?.vendor || type}
|
||
</span>
|
||
<span className="rounded-[6px] bg-amber-300/12 px-2 py-0.5 text-[11px] text-amber-100/80">
|
||
后端待接入
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="rounded-[8px] p-2 text-white/52 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-4">
|
||
<div className="space-y-4">
|
||
{modeLabels.length > 0 ? (
|
||
<div>
|
||
<div className="mb-2 text-[12px] text-white/42">输入模式</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{modeLabels.map((mode) => (
|
||
<span className="rounded-[6px] bg-white/8 px-2 py-1 text-[11px] text-white/62" key={mode}>
|
||
{mode}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{rulesRequireMedia(tool) ? (
|
||
<div>
|
||
<div className="mb-2 text-[12px] text-white/42">素材输入</div>
|
||
<button
|
||
className="flex h-24 w-full flex-col items-center justify-center rounded-[8px] border border-dashed border-white/16 bg-black/20 text-white/52 transition hover:border-white/28 hover:text-white"
|
||
onClick={() => toast.info("素材上传和选择接口待接入。")}
|
||
type="button"
|
||
>
|
||
<Plus className="h-5 w-5" />
|
||
<span className="mt-2 text-[12px]">添加图片 / 视频素材</span>
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{propertyCount > 0 ? (
|
||
<div className="space-y-4">
|
||
{entries.map(([key, config]) => (
|
||
<TargetPropertyControl
|
||
config={config}
|
||
key={key}
|
||
propertyKey={key}
|
||
value={draftValues[key]}
|
||
onChange={(value) => onChangeProperty(key, value)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-4 text-[13px] text-white/52">
|
||
此模块未暴露可配置参数。
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
className="w-full rounded-[8px] bg-white px-4 py-3 font-semibold text-black hover:bg-white/88"
|
||
onClick={() => toast.info("生成执行接口待接入,当前仅保留目标前端交互。")}
|
||
type="button"
|
||
>
|
||
生成
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function TargetPropertyControl({
|
||
config,
|
||
onChange,
|
||
propertyKey,
|
||
value,
|
||
}: {
|
||
config: unknown;
|
||
onChange: (value: unknown) => void;
|
||
propertyKey: string;
|
||
value: unknown;
|
||
}) {
|
||
const label = getPropertyLabel(propertyKey, config);
|
||
const options = normalizeEnumOptions(config);
|
||
const current = String(value ?? getDefaultPropertyValue(propertyKey, config));
|
||
const component =
|
||
config && typeof config === "object" && "component" in config
|
||
? String((config as { component?: unknown }).component || "")
|
||
: "";
|
||
|
||
if (propertyKey === "prompt") {
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-[12px] text-white/62">{label}</span>
|
||
<textarea
|
||
className="min-h-[108px] w-full rounded-[8px] border border-white/10 bg-black/20 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 enabled = current === "true" || current === "1";
|
||
return (
|
||
<div className="flex items-center justify-between rounded-[8px] border border-white/8 bg-white/5 px-3 py-2">
|
||
<span className="text-[13px] text-white/72">{label}</span>
|
||
<button
|
||
className={cn("relative h-6 w-11 rounded-full transition", enabled ? "bg-blue-500" : "bg-white/18")}
|
||
onClick={() => onChange(enabled ? "0" : "1")}
|
||
type="button"
|
||
>
|
||
<span
|
||
className={cn(
|
||
"absolute top-1 h-4 w-4 rounded-full bg-white transition",
|
||
enabled ? "left-6" : "left-1"
|
||
)}
|
||
/>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (options.length > 0) {
|
||
return (
|
||
<div>
|
||
<div className="mb-2 text-[12px] text-white/62">{label}</div>
|
||
{options.length > 7 ? (
|
||
<select
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/20 px-3 text-[13px] text-white outline-none focus:border-white/24"
|
||
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>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{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 transition 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 &&
|
||
(propertyKey === "lora" || propertyKey === "template")
|
||
) {
|
||
return (
|
||
<div>
|
||
<div className="mb-2 text-[12px] text-white/62">{label}</div>
|
||
<button
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/20 px-3 text-left text-[13px] text-white/52 hover:border-white/22 hover:text-white"
|
||
onClick={() => toast.info(`${label}选择器待接入。`)}
|
||
type="button"
|
||
>
|
||
选择{label}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-[12px] text-white/62">{label}</span>
|
||
<input
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/20 px-3 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>
|
||
);
|
||
}
|
||
|
||
function EmptyPanel({
|
||
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>
|
||
);
|
||
}
|
||
|
||
function UploadBox({ hint, label }: { hint?: string; label: string }) {
|
||
return (
|
||
<button
|
||
className="flex h-32 flex-col items-center justify-center rounded-[8px] border border-white/10 bg-white/8 text-white/58 transition hover:bg-white/12 hover:text-white"
|
||
onClick={() => toast.info(`${label}接口待接入。`)}
|
||
type="button"
|
||
>
|
||
<Plus className="h-6 w-6" />
|
||
<span className="mt-2 text-sm">{label}</span>
|
||
{hint ? <span className="mt-1 text-[11px] text-white/34">{hint}</span> : null}
|
||
</button>
|
||
);
|
||
}
|