Add collapsible video workflow sidebar
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-05 03:32:41 +08:00
parent d0c26e9fab
commit 3876a0c403

View File

@ -7,6 +7,9 @@ import {
AlertCircle,
ArrowLeft,
Bot,
ChevronDown,
ChevronLeft,
ChevronRight,
Save,
Undo2,
Redo2,
@ -56,6 +59,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
@ -243,6 +251,14 @@ interface VideoCanvasInsertMenuItem {
accent?: "highlight";
}
interface VideoCanvasSidebarGroup {
id: string;
label: string;
description: string;
icon: LucideIcon;
nodeTypes: VideoWorkflowTemplateType[];
}
const IMPORTABLE_CANVAS_NODE_TYPES = new Set<CanvasNode["type"]>([
"message",
"image",
@ -355,6 +371,83 @@ function getVideoWorkflowQuickConnectIcon(type: VideoWorkflowTemplateType) {
return VIDEO_WORKFLOW_QUICK_CONNECT_ICON_MAP[type] || Plus;
}
const VIDEO_CANVAS_SIDEBAR_GROUPS: VideoCanvasSidebarGroup[] = [
{
id: "input",
label: "输入层",
description: "文本、图片、视频、小说",
icon: FileImage,
nodeTypes: ["text-node", "input-image", "video-input", "novel-input"],
},
{
id: "analysis",
label: "分析层",
description: "视频拆解、角色场景抽取",
icon: Scan,
nodeTypes: [
"video-analyze",
"extract-characters-scenes",
"character-description",
"scene-description",
"create-character",
"create-scene",
],
},
{
id: "storyboard",
label: "分镜层",
description: "分镜节点、分镜图表、表格",
icon: GitBranch,
nodeTypes: [
"storyboard-node",
"storyboard-chart-node",
"table-editor-node",
"camera-movement",
"professional-camera",
],
},
{
id: "generation",
label: "生成层",
description: "生图、生视频、音乐、超清",
icon: Sparkles,
nodeTypes: [
"gen-image",
"gen-video",
"gen-music",
"jimeng-super-resolution",
"generate-character-image",
"generate-character-video",
"generate-scene-image",
"generate-scene-video",
"motion-control",
],
},
{
id: "tool",
label: "工具层",
description: "局部重绘、Comfy、Agent",
icon: Wand2,
nodeTypes: [
"inpaint-crop",
"inpaint-stitch",
"rh-comfy",
"rh-app",
"custom-agent",
"doodle-canvas",
"canvas-node",
"image-compare",
],
},
{
id: "output",
label: "输出层",
description: "预览、保存",
icon: Save,
nodeTypes: ["preview", "local-save"],
},
];
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@ -862,6 +955,15 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [editTitle, setEditTitle] = useState("");
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] = useState(false);
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<Record<string, boolean>>({
input: true,
analysis: true,
storyboard: true,
generation: true,
tool: true,
output: true,
});
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
useState<VideoCanvasInsertMenuState | null>(null);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
@ -890,6 +992,12 @@ export default function CanvasEditorPage({ params }: PageProps) {
setVideoCanvasInsertMenu(null);
}
}, [isVideoMode]);
useEffect(() => {
if (!isVideoMode) {
setIsVideoModuleSidebarCollapsed(false);
}
}, [isVideoMode]);
const [appearanceRecognitionEnabled, setAppearanceRecognitionEnabled] = useState(true);
const [selectedBrandModel, setSelectedBrandModel] =
useState<AgentWorkflowSelectedBrandModel | null>(null);
@ -1073,6 +1181,12 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedWorkflowQuickConnectAnchor && selectedVideoWorkflowNode
? selectedVideoWorkflowNode
: null;
const videoModuleSidebarWidth = isVideoMode
? isVideoModuleSidebarCollapsed
? 64
: 296
: 0;
const selectedWorkflowPanelLeft = isVideoMode ? videoModuleSidebarWidth + 28 : 16;
const workflowGraph = useMemo(
() => createWorkflowGraphContext(nodes, edges),
[edges, nodes]
@ -7411,6 +7525,149 @@ export default function CanvasEditorPage({ params }: PageProps) {
</DropdownMenu>
) : null}
{isVideoMode ? (
<div
className="pointer-events-auto absolute left-4 top-16 z-20"
style={{ width: `${videoModuleSidebarWidth}px` }}
>
<div className={cn(
"overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-[0_24px_60px_-30px_rgba(15,23,42,0.18)] dark:border-zinc-800 dark:bg-zinc-950",
isVideoModuleSidebarCollapsed ? "w-16" : "w-[296px]"
)}>
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-3 dark:border-zinc-800">
{isVideoModuleSidebarCollapsed ? (
<div className="flex w-full justify-center">
<Layers className="h-5 w-5 text-slate-500 dark:text-zinc-400" />
</div>
) : (
<div>
<div className="text-sm font-semibold text-foreground">视频模块栏</div>
<div className="mt-1 text-xs text-muted-foreground">
按工作流层级添加模块
</div>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setIsVideoModuleSidebarCollapsed((prev) => !prev)}
className="h-8 w-8 rounded-xl text-muted-foreground hover:bg-slate-50 hover:text-foreground dark:hover:bg-zinc-900"
>
{isVideoModuleSidebarCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
{!isVideoModuleSidebarCollapsed ? (
<ScrollArea className="max-h-[calc(100vh-164px)]">
<div className="space-y-3 p-3">
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => {
const GroupIcon = group.icon;
return (
<Collapsible
key={group.id}
open={videoModuleSectionOpen[group.id] !== false}
onOpenChange={(open) =>
setVideoModuleSectionOpen((prev) => ({
...prev,
[group.id]: open,
}))
}
>
<div className="rounded-2xl border border-slate-200 bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300">
<GroupIcon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground">
{group.label}
</span>
<span className="mt-1 block text-xs text-muted-foreground">
{group.description}
</span>
</span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
videoModuleSectionOpen[group.id] !== false ? "rotate-0" : "-rotate-90"
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-slate-200 px-3 py-3 dark:border-zinc-800">
<div className="grid gap-2">
{group.nodeTypes.map((type) => {
const definition = getVideoWorkflowNodeDefinition(type);
if (!definition) return null;
const Icon = getVideoWorkflowQuickConnectIcon(type);
return (
<button
key={type}
type="button"
onClick={() => handleAddWorkflowNode(type)}
className="flex items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-left transition-colors hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"
>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300"
style={{ color: definition.accentColor }}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{definition.label}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{definition.description}
</span>
</span>
</button>
);
})}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
})}
</div>
</ScrollArea>
) : (
<div className="flex flex-col items-center gap-3 px-2 py-3">
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => {
const GroupIcon = group.icon;
return (
<button
key={group.id}
type="button"
onClick={() => {
setIsVideoModuleSidebarCollapsed(false);
setVideoModuleSectionOpen((prev) => ({
...prev,
[group.id]: true,
}));
}}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 bg-white text-xs font-semibold text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-800 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
title={group.label}
>
<GroupIcon className="h-4 w-4" />
</button>
);
})}
</div>
)}
</div>
</div>
) : null}
{isVideoMode &&
selectedWorkflowQuickConnectAnchor &&
selectedWorkflowQuickConnectSourceNode &&
@ -7502,7 +7759,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
{isVideoMode &&
selectedVideoWorkflowDefinition &&
selectedWorkflowIsActionable ? (
<div className="pointer-events-none absolute left-4 top-16 z-20 flex w-[min(480px,calc(100vw-32px))] flex-col gap-3">
<div
className="pointer-events-none absolute top-16 z-20 flex w-[min(480px,calc(100vw-32px))] flex-col gap-3"
style={{ left: `${selectedWorkflowPanelLeft}px` }}
>
{selectedWorkflowIsActionable ? (
<Card className={VIDEO_CANVAS_PANEL_CLASS}>
<CardHeader className="pb-3">