ai-chatbot/components/canvas/LiblibVideoCanvasReplica.tsx
Your Name ce98b117b7
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Refine LibTV video canvas clone
2026-04-18 12:13:24 +08:00

1679 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
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>
);
}