import { memo, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { App, Empty, Input, Popconfirm, Select, Spin, Tag } from "antd"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Check, ChevronRight, Download, Eye, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react"; import { motion } from "motion/react"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { exportCanvasNodes } from "@/lib/canvas/canvas-export"; import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { cn } from "@/lib/utils"; import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog"; import { fetchSourcePrompts, type Prompt } from "@/services/api/prompts"; import { uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store"; import { usePromptSourceStore } from "@/stores/use-prompt-source-store"; import { CANVAS_SIDE_PANEL_MAX_WIDTH, CANVAS_SIDE_PANEL_MIN_WIDTH, CANVAS_SIDE_PANEL_MOTION_MS, useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import type { InsertAssetPayload } from "./asset-picker-modal"; const PANEL_MOTION_SECONDS = CANVAS_SIDE_PANEL_MOTION_MS / 1000; const PANEL_EASE = [0.22, 1, 0.36, 1] as const; type PanelTab = "canvas" | "assets" | "prompts"; type Props = { nodes: CanvasNodeData[]; selectedNodeIds: Set; onFocusNode: (nodeId: string) => void; onInsertAsset: (payload: InsertAssetPayload) => void; }; const NODE_TYPE_ICON: Record = { [CanvasNodeType.Image]: ImageIcon, [CanvasNodeType.Video]: Video, [CanvasNodeType.Audio]: Music2, [CanvasNodeType.Text]: Type, [CanvasNodeType.Config]: Settings2, [CanvasNodeType.Group]: Square, }; const STATUS_COLOR: Record = { success: "#22c55e", loading: "#f59e0b", error: "#ef4444", idle: "transparent", }; export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [tab, setTab] = useState("canvas"); const width = useCanvasSidePanelStore((state) => state.width); const panelOpen = useCanvasSidePanelStore((state) => state.panelOpen); const panelMounted = useCanvasSidePanelStore((state) => state.panelMounted); const panelClosing = useCanvasSidePanelStore((state) => state.panelClosing); const setWidth = useCanvasSidePanelStore((state) => state.setWidth); const [resizing, setResizing] = useState(false); const startResize = (event: ReactPointerEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = width; let nextWidth = startWidth; const onMove = (moveEvent: PointerEvent) => { nextWidth = Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, startWidth + moveEvent.clientX - startX)); setWidth(nextWidth); }; const onUp = () => { localStorage.setItem("canvas-side-panel-width", String(nextWidth)); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); setResizing(false); }; setResizing(true); window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); }; if (!panelMounted) return null; return (
setTab("canvas")} /> setTab("assets")} /> setTab("prompts")} />
{tab === "canvas" ? ( ) : tab === "assets" ? ( ) : ( )}
); } // --------------------------------------------------------------------------- // 画布 Tab —— 列出节点,点击居中放大并选中 // --------------------------------------------------------------------------- const NODE_FILTER_OPTIONS = [ { label: "全部", value: "all" }, { label: "图片", value: CanvasNodeType.Image }, { label: "视频", value: CanvasNodeType.Video }, { label: "文本", value: CanvasNodeType.Text }, { label: "音频", value: CanvasNodeType.Audio }, { label: "配置", value: CanvasNodeType.Config }, { label: "分组", value: CanvasNodeType.Group }, ]; function nodePreviewText(node: CanvasNodeData) { if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || ""; return getNodeDefinition(node.type)?.title || node.type; } function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) { const { message } = App.useApp(); const [keyword, setKeyword] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); const [selectMode, setSelectMode] = useState(false); const [checked, setChecked] = useState>(new Set()); const [exporting, setExporting] = useState(false); const filtered = useMemo(() => { const query = keyword.trim().toLowerCase(); return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query))); }, [nodes, keyword, typeFilter]); const exitSelect = () => { setSelectMode(false); setChecked(new Set()); }; const toggleChecked = (id: string) => setChecked((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); const allChecked = filtered.length > 0 && filtered.every((node) => checked.has(node.id)); const toggleAll = () => setChecked(allChecked ? new Set() : new Set(filtered.map((node) => node.id))); const handleExport = async () => { const targets = nodes.filter((node) => checked.has(node.id)); if (!targets.length) return; setExporting(true); const hide = message.loading("正在导出选中元素…", 0); try { await exportCanvasNodes(targets, `画布元素-${targets.length}个`); message.success(`已导出 ${targets.length} 个元素`); exitSelect(); } catch (error) { console.error(error); message.error("导出失败,请重试"); } finally { hide(); setExporting(false); } }; return (
画布元素 {filtered.length ? {filtered.length} : null} {selectMode ? null : } placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{filtered.length ? (
{filtered.map((node) => { const Icon = NODE_TYPE_ICON[node.type] || FileText; const isImage = node.type === CanvasNodeType.Image && node.metadata?.content; const isChecked = checked.has(node.id); const active = selectMode ? isChecked : selectedNodeIds.has(node.id); return ( ); })}
) : (
画布暂无节点
)}
{selectMode ? (
已选 {checked.size}
) : null}
); } function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme }) { return ( {checked ? : null} ); } // --------------------------------------------------------------------------- // 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布 // --------------------------------------------------------------------------- const ASSET_GROUPS: { kind: AssetKind; label: string; icon: typeof Square }[] = [ { kind: "image", label: "图片", icon: ImageIcon }, { kind: "video", label: "视频", icon: Video }, { kind: "text", label: "文本", icon: FileText }, ]; function buildInsertPayload(asset: Asset): InsertAssetPayload { if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title }; if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height }; return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title }; } const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) { const { message } = App.useApp(); const assets = useAssetStore((state) => state.assets); const addAsset = useAssetStore((state) => state.addAsset); const removeAsset = useAssetStore((state) => state.removeAsset); const [keyword, setKeyword] = useState(""); const [tagFilter, setTagFilter] = useState("all"); const [collapsed, setCollapsed] = useState>({}); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]); const filtered = useMemo(() => { const query = keyword.trim().toLowerCase(); return assets.filter((asset) => (tagFilter === "all" || (asset.tags || []).includes(tagFilter)) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query))); }, [assets, keyword, tagFilter]); const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]); const handleFiles = async (fileList: FileList | null) => { const files = Array.from(fileList || []); if (!files.length) return; setUploading(true); const hide = message.loading("正在添加资产…", 0); let added = 0; try { for (const file of files) { if (file.type.startsWith("image/")) { const image = await uploadImage(file); addAsset({ kind: "image", title: file.name || "图片", coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } }); added += 1; } else if (file.type.startsWith("video/")) { const media = await uploadMediaFile(file, "video"); addAsset({ kind: "video", title: file.name || "视频", coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } }); added += 1; } } if (added) message.success(`已添加 ${added} 个资产`); else message.warning("仅支持图片或视频文件"); } catch (error) { console.error(error); message.error("添加失败,请重试"); } finally { hide(); setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } }; return (
} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} /> void handleFiles(e.target.files)} />
{allTags.length ? (
setTagFilter("all")}> 全部 {allTags.map((tag) => ( setTagFilter((prev) => (prev === tag ? "all" : tag))}> {tag} ))}
) : null}
{groups.length ? (
{groups.map((group) => { const isCollapsed = collapsed[group.kind]; return (
{isCollapsed ? null : (
{group.items.map((asset) => ( onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success("资产已移除"))} /> ))}
)}
); })}
) : ( )}
); }); function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) { return (
); } function AssetCover({ asset }: { asset: Asset }) { if (asset.kind === "text") return
{asset.data.content}
; if (asset.kind === "video") { if (asset.coverUrl) return ; return