From acbbbe1078947505db335e49ce825e9d7a96cd88 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 14 Aug 2026 18:35:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E5=88=9B=E4=BD=9C=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=20-=20=E7=BB=9F=E4=B8=80=E7=B4=A0=E6=9D=90=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E4=B8=8E=E6=9A=97=E8=89=B2=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assets/asset-library-picker-modal.tsx | 230 +++++++++++++ .../components/canvas/asset-picker-modal.tsx | 151 ++------- .../components/canvas/canvas-asset-tray.tsx | 2 +- .../canvas/canvas-emotion-workspace.tsx | 4 +- .../canvas-image-toolbar-settings-modal.tsx | 2 +- .../components/canvas/canvas-node-toolbar.tsx | 114 ++++--- web/src/components/canvas/canvas-node.tsx | 4 +- .../canvas/canvas-project-asset-modal.tsx | 82 +++-- .../canvas/canvas-short-drama-entry.tsx | 2 +- .../canvas/canvas-timeline-dialog.tsx | 2 +- .../layout/workspace-sidebar-footer.tsx | 2 +- web/src/lib/app-theme.ts | 4 +- web/src/lib/canvas-theme.ts | 44 +-- .../pages/canvas/canvas-project-top-bar.tsx | 14 +- web/src/pages/create/index.tsx | 92 ++---- web/src/pages/projects/detail/assets.tsx | 65 +++- web/src/pages/test-voice-recording.tsx | 4 +- web/src/styles/globals.css | 303 ++++++++++++------ 18 files changed, 673 insertions(+), 448 deletions(-) create mode 100644 web/src/components/assets/asset-library-picker-modal.tsx diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx new file mode 100644 index 000000000..d4ab0eb3d --- /dev/null +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -0,0 +1,230 @@ +import { Modal } from "antd"; +import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, Search, Upload, UserRound, Video } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +import { AssetMediaPreview } from "@/components/asset-media-preview"; +import { cn } from "@/lib/utils"; +import type { Asset } from "@/stores/use-asset-store"; + +export type AssetLibraryPickerItem = { + id: string; + title: string; + category: string; + kindLabel: string; + asset?: Asset; + imageUrl?: string; + imageFit?: "cover" | "contain"; + description?: string; + searchText?: string; + disabledReason?: string; +}; + +type Props = { + open: boolean; + items: AssetLibraryPickerItem[]; + categoryLabels: Record; + initialCategory?: string; + initialSelectedIds?: Iterable; + multiple?: boolean; + title?: string; + eyebrow?: string; + confirmLabel?: (count: number) => string; + emptyTitle?: string; + emptyDescription?: string; + footerNote?: string; + upload?: { + accept: string; + description: string; + onUpload: (files: FileList) => Promise; + }; + onClose: () => void; + onConfirm: (ids: string[]) => Promise | void; +}; + +export function AssetLibraryPickerModal({ + open, + items, + categoryLabels, + initialCategory = "all", + initialSelectedIds, + multiple = true, + title = "素材库", + eyebrow = "参考内容", + confirmLabel = (count) => `使用已选素材${count ? `(${count})` : ""}`, + emptyTitle = "这个分类还没有素材", + emptyDescription = "换个分类后再试。", + footerNote, + upload, + onClose, + onConfirm, +}: Props) { + const [category, setCategory] = useState(initialCategory); + const [keyword, setKeyword] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [working, setWorking] = useState(false); + const [uploadingCount, setUploadingCount] = useState(0); + const [error, setError] = useState(""); + const uploadInputRef = useRef(null); + const initialSelectedIdsRef = useRef(initialSelectedIds); + const itemsRef = useRef(items); + initialSelectedIdsRef.current = initialSelectedIds; + itemsRef.current = items; + const categories = useMemo(() => ["all", ...Array.from(new Set(items.map((item) => item.category || "other")))], [items]); + const visibleItems = useMemo(() => { + const query = keyword.trim().toLowerCase(); + return items.filter((item) => { + if (category !== "all" && item.category !== category) return false; + return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query); + }); + }, [category, items, keyword]); + const selectedIds = useMemo( + () => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id), + [items, selected], + ); + + useEffect(() => { + if (!open) return; + setCategory(initialCategory); + setKeyword(""); + const selectableIds = new Set(itemsRef.current.filter((item) => !item.disabledReason).map((item) => item.id)); + setSelected(new Set(Array.from(initialSelectedIdsRef.current || []).filter((id) => selectableIds.has(id)))); + setWorking(false); + setUploadingCount(0); + setError(""); + }, [initialCategory, open]); + + useEffect(() => { + if (category === "all" || categories.includes(category)) return; + setCategory("all"); + }, [categories, category]); + + const toggle = (item: AssetLibraryPickerItem) => { + if (item.disabledReason || working) return; + setError(""); + setSelected((current) => { + if (!multiple) return current.has(item.id) ? new Set() : new Set([item.id]); + const next = new Set(current); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); + }; + + const confirm = async () => { + if (!selectedIds.length || working) return; + setWorking(true); + setError(""); + try { + await onConfirm(selectedIds); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "素材操作失败,请重试"); + } finally { + setWorking(false); + } + }; + + const handleUpload = async (files: FileList | null) => { + if (!upload || !files?.length || working) return; + setWorking(true); + setError(""); + setUploadingCount(files.length); + try { + const ids = await upload.onUpload(files); + if (ids.length) setSelected((current) => new Set(multiple ? [...current, ...ids] : ids.slice(-1))); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "素材上传失败,请重试"); + } finally { + if (uploadInputRef.current) uploadInputRef.current.value = ""; + setWorking(false); + setUploadingCount(0); + } + }; + + const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length; + const uploading = uploadingCount > 0; + + return ( + { + if (!working) onClose(); + }} + width="min(1040px, calc(100vw - 24px))" + className="asset-library-picker-modal" + styles={{ container: { padding: 0 }, body: { padding: 0 } }} + > +
+
+
{eyebrow}{title}
+ + 已选 {selectedIds.length} · {visibleItems.length} 个素材 +
+
+ +
+
+ {visibleItems.length ? visibleItems.map((item) => ( + toggle(item)} /> + )) : ( +
{emptyTitle}{upload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}
+ )} +
+
+
+
+ {upload ? ( + <> + void handleUpload(event.target.files)} /> + + + ) : footerNote ? {footerNote} : } + {error ? {error} : null} +
+ + +
+
+
+
+ ); +} + +function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) { + const disabled = Boolean(item.disabledReason); + return ( + + ); +} + +function kindIcon(label: string): ReactNode { + if (label.includes("角色")) return ; + if (label.includes("视频")) return