ai-chatbot/components/canvas/LiblibVideoCanvasReplica.tsx
Your Name 799dd3007b
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Replicate target video canvas frontend
2026-04-18 11:50:04 +08:00

1736 lines
61 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,
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>
);
}