feat(assets): 素材库与画布 - 新增可交互通用文件夹

This commit is contained in:
echoD886
2026-08-20 15:16:45 +08:00
parent 0e251c3c8f
commit 5d775071e4
35 changed files with 2010 additions and 155 deletions

View File

@@ -17,6 +17,13 @@ export type AssetLibraryPickerItem = {
description?: string;
searchText?: string;
disabledReason?: string;
folderId?: string;
};
export type AssetLibraryPickerFolder = {
id: string;
parentId?: string;
name: string;
};
type Props = {
@@ -24,6 +31,8 @@ type Props = {
items: AssetLibraryPickerItem[];
categoryLabels: Record<string, string>;
initialCategory?: string;
initialFolderId?: string;
folders?: AssetLibraryPickerFolder[];
initialSelectedIds?: Iterable<string>;
multiple?: boolean;
title?: string;
@@ -32,6 +41,7 @@ type Props = {
emptyTitle?: string;
emptyDescription?: string;
footerNote?: string;
folderActionLabel?: string;
upload?: {
accept: string;
description: string;
@@ -39,6 +49,7 @@ type Props = {
};
onClose: () => void;
onConfirm: (ids: string[]) => Promise<void> | void;
onFolderAction?: (folderId: string) => Promise<void> | void;
};
export function AssetLibraryPickerModal({
@@ -46,6 +57,8 @@ export function AssetLibraryPickerModal({
items,
categoryLabels,
initialCategory = "all",
initialFolderId = "all",
folders = [],
initialSelectedIds,
multiple = true,
title = "素材库",
@@ -54,11 +67,14 @@ export function AssetLibraryPickerModal({
emptyTitle = "这个分类还没有素材",
emptyDescription = "换个分类后再试。",
footerNote,
folderActionLabel = "将文件夹放到画布",
upload,
onClose,
onConfirm,
onFolderAction,
}: Props) {
const [category, setCategory] = useState(initialCategory);
const [folderId, setFolderId] = useState(initialFolderId);
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [working, setWorking] = useState(false);
@@ -74,9 +90,10 @@ export function AssetLibraryPickerModal({
const query = keyword.trim().toLowerCase();
return items.filter((item) => {
if (category !== "all" && item.category !== category) return false;
if (folderId !== "all" && (item.folderId || "") !== folderId) return false;
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
});
}, [category, items, keyword]);
}, [category, folderId, items, keyword]);
const selectedIds = useMemo(
() => items.filter((item) => !item.disabledReason && selected.has(item.id)).map((item) => item.id),
[items, selected],
@@ -85,13 +102,14 @@ export function AssetLibraryPickerModal({
useEffect(() => {
if (!open) return;
setCategory(initialCategory);
setFolderId(initialFolderId);
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]);
}, [initialCategory, initialFolderId, open]);
useEffect(() => {
if (category === "all" || categories.includes(category)) return;
@@ -140,6 +158,19 @@ export function AssetLibraryPickerModal({
}
};
const runFolderAction = async () => {
if (!onFolderAction || folderId === "all" || working) return;
setWorking(true);
setError("");
try {
await onFolderAction(folderId);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "文件夹操作失败,请重试");
} finally {
setWorking(false);
}
};
const countFor = (value: string) => value === "all" ? items.length : items.filter((item) => item.category === value).length;
const uploading = uploadingCount > 0;
@@ -166,6 +197,7 @@ export function AssetLibraryPickerModal({
</header>
<div className="asset-picker-body">
<nav className="asset-picker-categories" aria-label="素材分类">
{folders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={folderId === "all" ? "is-active" : ""} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span>全部文件夹</span><em>{items.length}</em></button>{renderPickerFolders(folders, items, folderId, setFolderId)}<span className="asset-picker-nav-label">分类</span></> : null}
{categories.map((value) => (
<button key={value} type="button" className={category === value ? "is-active" : ""} aria-pressed={category === value} onClick={() => setCategory(value)}>
<span>{categoryLabels[value] || "其他"}</span><em>{countFor(value)}</em>
@@ -194,6 +226,7 @@ export function AssetLibraryPickerModal({
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
<div className="asset-picker-actions">
{onFolderAction && folderId !== "all" ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
<Button type="text" onClick={onClose} disabled={working}>取消</Button>
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
{confirmLabel(selectedIds.length)}
@@ -220,6 +253,21 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
);
}
function renderPickerFolders(folders: AssetLibraryPickerFolder[], items: AssetLibraryPickerItem[], selectedId: string, onSelect: (folderId: string) => void, parentId = "", depth = 0, visited: ReadonlySet<string> = new Set()): ReactNode {
if (depth >= 8) return null;
return folders.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id)).map((folder) => {
const nextVisited = new Set(visited).add(folder.id);
return (
<span key={folder.id} className="contents">
<button type="button" className={selectedId === folder.id ? "is-active" : ""} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
<span>{folder.name}</span><em>{items.filter((item) => item.folderId === folder.id).length}</em>
</button>
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
</span>
);
});
}
function kindIcon(label: string): ReactNode {
if (label.includes("角色")) return <UserRound />;
if (label.includes("视频")) return <Video />;

View File

@@ -7,6 +7,7 @@ import { aceternityMotion } from "@/lib/aceternity-motion";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import { isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas";
@@ -33,6 +34,7 @@ type CanvasNodeContextMenuProps = {
canPaste: boolean;
onClose: () => void;
onAddNode: (type: CanvasNodeType) => void;
onAddFolder: () => void;
onChooseStyle: () => void;
onOpenDirector: (position: Position) => void;
onUpload: () => void;
@@ -65,6 +67,7 @@ export function CanvasNodeContextMenu({
canPaste,
onClose,
onAddNode,
onAddFolder,
onChooseStyle,
onOpenDirector,
onUpload,
@@ -128,6 +131,7 @@ export function CanvasNodeContextMenu({
const isMedia = isImage || isVideo;
const isAudio = node?.type === CanvasNodeType.Audio;
const isFrame = node?.type === CanvasNodeType.Frame;
const isFolder = isCanvasFolderNode(node);
const hasNodeContent = isText ? Boolean(nodeContent.trim()) : Boolean(nodeContent);
const canSaveAsset = Boolean(node && !isCharacterReference && (isText ? hasNodeContent : hasNodeContent && (isImage || isVideo || isAudio)));
const canOpenPreview = Boolean(isMedia && hasNodeContent);
@@ -207,17 +211,17 @@ export function CanvasNodeContextMenu({
<>
<MenuHeader title={node?.title || nodeTypeLabel(node)} />
<MenuSection label="节点操作" />
{isFrame ? <MenuButton icon={<PanelTop />} label={node?.metadata?.frame?.collapsed ? "展开背板" : "折叠背板"} onClick={() => runAction(onToggleFrame)} /> : <MenuButton icon={<FolderPlus />} label="保存到我的素材" disabled={!canSaveAsset} onClick={() => runAction(onSaveAsset)} />}
{isFrame ? <MenuButton icon={isFolder ? <FolderOpen /> : <PanelTop />} label={node?.metadata?.frame?.collapsed ? `展开${isFolder ? "文件夹" : "背板"}` : `折叠${isFolder ? "文件夹" : "背板"}`} onClick={() => runAction(onToggleFrame)} /> : <MenuButton icon={<FolderPlus />} label="保存到我的素材" disabled={!canSaveAsset} onClick={() => runAction(onSaveAsset)} />}
{isText ? <MenuButton icon={<Maximize2 />} label="放大编辑" onClick={() => runAction(onEditText)} /> : null}
{isDrawing ? <MenuButton icon={<Pencil />} label="打开绘图" onClick={() => runAction(onOpenDrawing)} /> : null}
{isText ? <MenuButton icon={<ImageIcon />} label="用文本生图" disabled={!canGenerateFromText} onClick={() => runAction(onGenerateImage)} /> : null}
<MenuDivider />
<MenuSection label="副本与内容" />
<MenuButton icon={<Copy />} label={isFrame ? "复制背板及内容" : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
<MenuButton icon={<Copy />} label={isFrame ? `复制${isFolder ? "文件夹" : "背板"}及内容` : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
{isText ? <MenuButton icon={<Clipboard />} label="复制文本" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
<MenuButton icon={<Copy />} label={isFrame ? "创建背板副本" : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
<MenuButton icon={<Copy />} label={isFrame ? `创建${isFolder ? "文件夹" : "背板"}副本` : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
<MenuButton icon={<Clipboard />} label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} />
<MenuButton icon={<Trash2 />} label={isFrame ? "删除背板" : "删除节点"} danger onClick={() => runAction(onDelete)} />
<MenuButton icon={<Trash2 />} label={isFrame ? `删除${isFolder ? "文件夹" : "背板"}` : "删除节点"} danger onClick={() => runAction(onDelete)} />
</>
)}
</>
@@ -238,6 +242,7 @@ export function CanvasNodeContextMenu({
isProjectLinked={isProjectLinked}
reducedMotion={Boolean(reducedMotion)}
onAddNode={(type) => runAction(() => onAddNode(type))}
onAddFolder={() => runAction(onAddFolder)}
onChooseStyle={() => runAction(onChooseStyle)}
onOpenDirector={() => runAction(() => onOpenDirector(menu.position))}
onUpload={() => runAction(onUpload)}
@@ -250,7 +255,7 @@ export function CanvasNodeContextMenu({
);
}
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeType) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const left = getSubmenuLeft(parentPosition.left);
const createContext: AddNodeMenuContext = {
@@ -263,6 +268,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
onAddAudio: () => onAddNode(CanvasNodeType.Audio),
onAddScript: () => onAddNode(CanvasNodeType.Script),
onAddFrame: () => onAddNode(CanvasNodeType.Frame),
onAddFolder,
onAddDrawing: () => onAddNode(CanvasNodeType.Drawing),
onChooseStyle,
onOpenDirector,
@@ -364,6 +370,6 @@ function nodeTypeLabel(node?: CanvasNodeData | null) {
if (node.type === CanvasNodeType.Video) return "视频节点";
if (node.type === CanvasNodeType.Audio) return "音频节点";
if (node.type === CanvasNodeType.Drawing) return "绘图节点";
if (node.type === CanvasNodeType.Frame) return "背板";
if (node.type === CanvasNodeType.Frame) return isCanvasFolderNode(node) ? "文件夹" : "背板";
return "生成配置节点";
}

View File

@@ -0,0 +1,208 @@
import React, { useEffect, useMemo, useState } from "react";
import { Dropdown } from "antd";
import { FileAudio, FileText, MoreHorizontal, Pencil, Plus, SlidersHorizontal, Sparkles, Video } from "lucide-react";
import type { CanvasFolderStyle, CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType } from "@/types/canvas";
const DEFAULT_FOLDER_COVER = "/images/canvas/folder-default-cover.png";
const FOLDER_STYLE_OPTIONS: Array<{ key: CanvasFolderStyle; label: string }> = [
{ key: "glass", label: "流光玻璃" },
{ key: "stacked", label: "内容陈列" },
{ key: "midnight", label: "午夜封面" },
{ key: "paper", label: "纸感收藏" },
{ key: "cinema", label: "电影胶片" },
{ key: "compact", label: "紧凑资料" },
];
export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
data,
childNodes,
active,
isDropTarget,
readOnly,
onToggleCollapsed,
onTitleChange,
onStyleChange,
}: {
data: CanvasNodeData;
childNodes: CanvasNodeData[];
active: boolean;
isDropTarget: boolean;
readOnly: boolean;
onToggleCollapsed: (nodeId: string) => void;
onTitleChange: (nodeId: string, title: string) => void;
onStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
}) {
const style = data.metadata?.folder?.style || "glass";
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(data.title);
const previewNodes = useMemo(() => childNodes.filter(canPreviewFolderNode).slice(0, 3), [childNodes]);
const primaryNode = previewNodes[0];
const showAdd = style === "glass" || childNodes.length === 0;
useEffect(() => setTitle(data.title), [data.title]);
const commitTitle = () => {
const next = title.trim() || "未命名文件夹";
setTitle(next);
setEditing(false);
onTitleChange(data.id, next);
};
return (
<div className={`canvas-folder-preview canvas-folder-preview-${style}${active ? " is-active" : ""}${isDropTarget ? " is-drop-target" : ""}`}>
<div className="canvas-folder-back" aria-hidden>
<FolderNodeMedia node={primaryNode} fallback />
</div>
<div className="canvas-folder-preview-stack" aria-hidden>
{(previewNodes.length ? previewNodes : [undefined, undefined]).map((node, index) => (
<div key={node?.id || `folder-placeholder-${index}`} className={`canvas-folder-preview-card canvas-folder-preview-card-${index + 1}`}>
<FolderNodeMedia node={node} fallback={index === 0} />
</div>
))}
</div>
<div className="canvas-folder-front">
<div className="canvas-folder-front-media" aria-hidden>
<FolderNodeMedia node={primaryNode} fallback />
</div>
<div className="canvas-folder-front-tint" aria-hidden />
<div className="canvas-folder-copy">
{editing ? (
<input
autoFocus
className="canvas-folder-title-input"
value={title}
onChange={(event) => setTitle(event.target.value)}
onBlur={commitTitle}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") commitTitle();
if (event.key === "Escape") {
setTitle(data.title);
setEditing(false);
}
}}
/>
) : (
<button
type="button"
className="canvas-folder-title"
title={readOnly ? data.title : `${data.title} · 点击重命名`}
onClick={(event) => {
event.stopPropagation();
if (!readOnly) setEditing(true);
}}
onDoubleClick={(event) => {
event.stopPropagation();
if (!readOnly) setEditing(true);
}}
>
<span>{data.title}</span>{!readOnly ? <Pencil aria-hidden /> : null}
</button>
)}
<span className="canvas-folder-meta">
{style === "midnight" ? formatFolderDate(data.metadata?.folder?.createdAt) : `${childNodes.length} 项内容`}
</span>
</div>
{!readOnly ? (
showAdd ? (
<button
type="button"
className="canvas-folder-action canvas-folder-add"
aria-label="展开文件夹并添加内容"
title="展开文件夹并添加内容"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleCollapsed(data.id);
}}
>
<Plus />
</button>
) : (
<Dropdown
trigger={["click"]}
menu={{
selectedKeys: [style],
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
}}
>
<button
type="button"
className="canvas-folder-action canvas-folder-options"
aria-label={`文件夹选项,当前 ${childNodes.length} 项`}
title="切换文件夹样式"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal />
</button>
</Dropdown>
)
) : null}
{!readOnly && showAdd ? (
<Dropdown
trigger={["click"]}
menu={{
selectedKeys: [style],
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
}}
>
<button
type="button"
className="canvas-folder-style-trigger"
aria-label="切换文件夹样式"
title="切换文件夹样式"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal />
</button>
</Dropdown>
) : null}
{readOnly && childNodes.length > 0 ? <span className="canvas-folder-count">{childNodes.length}</span> : null}
</div>
</div>
);
});
function canPreviewFolderNode(node: CanvasNodeData) {
return node.type !== CanvasNodeType.Frame;
}
function FolderNodeMedia({ node, fallback = false }: { node?: CanvasNodeData; fallback?: boolean }) {
if (node?.type === CanvasNodeType.Image && node.metadata?.content) {
return <img src={node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
}
if (node?.type === CanvasNodeType.Video && node.metadata?.content) {
return <video src={node.metadata.content} muted playsInline preload="metadata" />;
}
if (node?.type === CanvasNodeType.Drawing && (node.metadata?.drawingPreviewUrl || node.metadata?.content)) {
return <img src={node.metadata.drawingPreviewUrl || node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
}
if (node?.type === CanvasNodeType.Video) return <Video className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Audio) return <FileAudio className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Skill) return <Sparkles className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Config) return <SlidersHorizontal className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Text || node?.type === CanvasNodeType.Script) {
return <span className="canvas-folder-text-preview">{node.metadata?.content || node.title}</span>;
}
if (node) return <FileText className="canvas-folder-file-icon" />;
return fallback ? <img src={DEFAULT_FOLDER_COVER} alt="" loading="lazy" decoding="async" draggable={false} /> : <span className="canvas-folder-file-sheet" />;
}
function formatFolderDate(value?: string) {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) return "刚刚创建";
return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric" }).format(date);
}

View File

@@ -3,10 +3,11 @@ import type { MouseEvent as ReactMouseEvent } from "react";
import { ChevronDown, ChevronRight, Video } from "lucide-react";
import { CometCard } from "@/components/ui/aceternity/comet-card";
import { FRAME_HEADER_HEIGHT, FRAME_PADDING } from "@/lib/canvas/canvas-frame";
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
import { FRAME_HEADER_HEIGHT, FRAME_PADDING, isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasNodeData, type Position } from "@/types/canvas";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -20,6 +21,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
onMouseDown,
onResize,
onToggleCollapsed,
onFolderStyleChange,
onTitleChange,
onContextMenu,
readOnly = false,
@@ -35,6 +37,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
onMouseDown: (event: ReactMouseEvent, nodeId: string) => void;
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onToggleCollapsed: (nodeId: string) => void;
onFolderStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
onTitleChange: (nodeId: string, title: string) => void;
onContextMenu: (event: ReactMouseEvent, nodeId: string) => void;
readOnly?: boolean;
@@ -43,6 +46,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const collapsed = Boolean(data.metadata?.frame?.collapsed);
const folder = isCanvasFolderNode(data);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(data.title);
const resizeRef = useRef({
@@ -63,7 +67,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
useEffect(() => setTitle(data.title), [data.title]);
const commitTitle = () => {
const next = title.trim() || "未命名背板";
const next = title.trim() || (folder ? "未命名文件夹" : "未命名背板");
setTitle(next);
setEditing(false);
onTitleChange(data.id, next);
@@ -140,11 +144,15 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
};
const active = isSelected || isDropTarget;
const linkedFolder = Boolean(data.metadata?.folder?.assetFolderId);
return (
<div
data-node-id={data.id}
className={`absolute z-0 select-none ${dragOffset ? "cursor-grabbing" : "cursor-default"}`}
role={folder && collapsed ? "group" : undefined}
tabIndex={folder && collapsed ? 0 : undefined}
aria-label={folder && collapsed ? `${data.title},文件夹,${childNodes.length} 项内容。按回车打开` : undefined}
className={`absolute z-0 select-none${folder && collapsed ? " canvas-folder-node" : ""} ${dragOffset ? "cursor-grabbing" : "cursor-default"}`}
style={{ transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`, width: data.width, height: data.height, contain: "layout style" }}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
@@ -153,22 +161,42 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
onToggleCollapsed(data.id);
}}
onContextMenu={(event) => onContextMenu(event, data.id)}
onKeyDown={(event) => {
if (!folder || !collapsed || (event.target instanceof Element && event.target.closest("button,input"))) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onToggleCollapsed(data.id);
}}
onMouseEnter={() => onHoverStart?.(data.id)}
onMouseLeave={() => onHoverEnd?.(data.id)}
>
{/* 帧节点同样禁用指针跟随 3D 位移,hover 使用 CSS 静态抬升 */}
<CometCard
containerClassName="h-full w-full"
className="canvas-frame-shell overflow-hidden rounded-[var(--dock-radius)] border"
className={folder && collapsed ? "canvas-folder-shell overflow-visible" : `canvas-frame-shell overflow-hidden rounded-[var(--dock-radius)] border${folder ? " canvas-folder-expanded" : ""}`}
disabled
data-canvas-frame-hover-locked={!collapsed || editing || Boolean(dragOffset) || scale < 0.32 ? "true" : "false"}
style={{
background: active ? theme.frame.activeFill : theme.frame.fill,
borderColor: active ? theme.frame.activeStroke : theme.frame.stroke,
borderWidth: 1 / Math.max(scale, 0.05),
boxShadow: isSelected ? `0 0 0 ${1 / Math.max(scale, 0.05)}px ${theme.frame.activeStroke}33, 0 24px 72px ${theme.spatial.shadow}` : `0 18px 54px ${theme.spatial.shadow}`,
background: folder && collapsed ? "transparent" : active ? theme.frame.activeFill : theme.frame.fill,
borderColor: folder && collapsed ? "transparent" : active ? theme.frame.activeStroke : theme.frame.stroke,
borderWidth: folder && collapsed ? 0 : 1 / Math.max(scale, 0.05),
boxShadow: folder && collapsed ? "none" : isSelected ? `0 0 0 ${1 / Math.max(scale, 0.05)}px ${theme.frame.activeStroke}33, 0 24px 72px ${theme.spatial.shadow}` : `0 18px 54px ${theme.spatial.shadow}`,
}}
>
{folder && collapsed ? (
// 素材库目录是单一事实源;画布节点只负责浏览和调用,避免本地改名后被同步结果覆盖。
<CanvasFolderPreview
data={data}
childNodes={childNodes}
active={active}
isDropTarget={isDropTarget}
readOnly={readOnly || linkedFolder}
onToggleCollapsed={onToggleCollapsed}
onTitleChange={onTitleChange}
onStyleChange={onFolderStyleChange}
/>
) : (
<>
<div className="pointer-events-auto absolute inset-x-0 top-0 z-10 flex items-center gap-1.5 px-1.5" style={{ height: FRAME_HEADER_HEIGHT, color: theme.node.text }}>
<button
type="button"
@@ -220,6 +248,8 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
</div>
{collapsed ? <FramePreview nodes={childNodes} frame={data} theme={theme} /> : null}
</>
)}
</CometCard>
{!readOnly && !collapsed && isSelected && !data.metadata?.locked ? (
<>

View File

@@ -3,42 +3,46 @@ import { useMemo } from "react";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
import { resourceFileUrl } from "@/services/api/resources";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
import { getRemoteAsset } from "@/services/api/user-data";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
const categoryLabels: Record<string, string> = { all: "全部资产", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
type ProjectPickerItem = { id: string; category: string; character?: ProjectAsset; media?: Asset };
type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset };
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", onClose, onInsert }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void }) {
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
const mediaAssets = useAssetStore((state) => state.assets);
const items = useMemo<ProjectPickerItem[]>(() => {
const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset]));
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", character: asset }];
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", folderId: asset.folderId, project: asset, character: asset }];
const media = mediaById.get(asset.id);
return media && media.kind !== "model" && media.kind !== "entity" ? [{ id: asset.id, category: asset.category || media.category || "other", media }] : [];
return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: asset.category || media?.category || "other", folderId: asset.folderId, project: asset, media }];
});
if (projectItems.length) return projectItems;
// 自由画布(未关联短剧项目)或项目资产为空/后端未返回时,回退到本地素材库,避免弹窗空白
if (detail) return projectItems;
// 自由画布未关联项目时回退到个人素材库。
return mediaAssets
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
.map((media): ProjectPickerItem => ({ id: media.id, category: media.category || "other", media }));
}, [detail?.assets, mediaAssets]);
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
const character = item.character;
const project = item.project;
const media = item.media;
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
return {
id: item.id,
title: character?.title || media?.title || "未命名资产",
title: character?.title || project?.title || media?.title || "未命名资产",
category: item.category,
kindLabel: character ? "角色卡" : media?.kind === "video" ? "视频" : media?.kind === "audio" ? "音频" : media?.kind === "text" ? "文本" : "图片",
folderId: item.folderId,
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : undefined,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : undefined,
searchText: media?.tags.join(" "),
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags.join(" ") || "", project?.previewText || ""].join(" "),
};
}), [items]);
@@ -48,14 +52,21 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
items={pickerItems}
categoryLabels={categoryLabels}
initialCategory={initialCategory}
initialFolderId={initialFolderId}
folders={detail?.assetFolders || []}
title="项目资产"
confirmLabel={(count) => `引入已选资产${count ? `(${count})` : ""}`}
emptyTitle="此分类没有可引用资产"
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
footerNote="角色引用会在生成时解析当前角色版本"
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
onClose={onClose}
onConfirm={async (ids) => {
const payloads = items.filter((item) => ids.includes(item.id)).map(toInsertPayload);
const payloads = await Promise.all(items.filter((item) => ids.includes(item.id)).map(async (item) => {
if (item.media || item.character || !item.project) return toInsertPayload(item);
const { asset } = await getRemoteAsset(item.project.id);
return toInsertPayload({ ...item, media: asset });
}));
if (!payloads.length) return;
await onInsert(payloads);
onClose();
@@ -69,14 +80,29 @@ function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
return projectCharacterToInsertPayload(item.character);
}
const asset = item.media;
const project = item.project;
if (!asset && project) {
const resourceId = resourceIdFromStorageKey(project.storageKey);
const remoteUrl = resourceId ? resourceFileUrl(resourceId) : "";
if (project.mediaType === "text" && project.previewText) return { kind: "text", content: project.previewText, title: project.title, assetId: project.id };
if (project.mediaType === "video" && remoteUrl) return { kind: "video", url: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id };
if (project.mediaType === "audio" && remoteUrl) return { kind: "audio", url: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id };
if (project.mediaType === "image" && remoteUrl) return { kind: "image", dataUrl: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id };
throw new Error(`“${project.title}”缺少可读取的内容`);
}
if (!asset) throw new Error("项目资产不可用");
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
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, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "image") return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "image") return { kind: "image", dataUrl: projectAssetMediaUrl(asset.data.storageKey, asset.data.dataUrl), storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id };
throw new Error("当前项目资产不能直接插入画布");
}
function projectAssetMediaUrl(storageKey?: string, fallback = "") {
const resourceId = resourceIdFromStorageKey(storageKey);
return resourceId ? resourceFileUrl(resourceId) : fallback;
}
export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAssetPayload {
if (!asset.character) throw new Error("项目角色信息不完整");
const card = asset.character;

View File

@@ -32,6 +32,7 @@ export function CanvasToolbar({
onChooseStyle,
onAddScript,
onAddFrame,
onAddFolder,
onAddDrawing,
onOpenDirector,
onUndo,
@@ -61,6 +62,7 @@ export function CanvasToolbar({
onChooseStyle: () => void;
onAddScript: () => void;
onAddFrame: () => void;
onAddFolder: () => void;
onAddDrawing: () => void;
onOpenDirector: () => void;
onUndo: () => void;
@@ -122,6 +124,7 @@ export function CanvasToolbar({
onAddAudio,
onAddScript,
onAddFrame,
onAddFolder,
onAddDrawing,
onChooseStyle,
onOpenDirector,