feat(canvas): 创作体验 - 合并 PR #282 并优化引用、风格与素材文件夹
This commit is contained in:
@@ -14,6 +14,7 @@ type CanvasProjectContextMenuProps = {
|
||||
screenToCanvas: (clientX: number, clientY: number) => Position;
|
||||
onClose: () => void;
|
||||
onAddNode: (type: CanvasNodeType, position: Position) => void;
|
||||
onAddFolder: (position: Position) => void;
|
||||
onChooseStyle: () => void;
|
||||
onOpenDirector: (position?: Position) => void;
|
||||
onUpload: (nodeId: string | undefined, position: Position) => void;
|
||||
@@ -53,6 +54,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
|
||||
onAddNode={(type) => {
|
||||
if (menu.type === "canvas") props.onAddNode(type, menu.position);
|
||||
}}
|
||||
onAddFolder={() => {
|
||||
if (menu.type === "canvas") props.onAddFolder(menu.position);
|
||||
}}
|
||||
onChooseStyle={props.onChooseStyle}
|
||||
onOpenDirector={props.onOpenDirector}
|
||||
onUpload={() => props.onUpload(menu.type === "node" ? menu.nodeId : undefined, menuPosition())}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
|
||||
import type { CanvasDisplayConnection, CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
|
||||
|
||||
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
|
||||
type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null;
|
||||
@@ -26,6 +26,7 @@ type CanvasProjectWorldLayersProps = {
|
||||
nodeById: Map<string, CanvasNodeData>;
|
||||
visibleNodes: CanvasNodeData[];
|
||||
frameChildrenById: Map<string, CanvasNodeData[]>;
|
||||
linkedFolderPreviewNodesById: Map<string, CanvasNodeData[]>;
|
||||
dragPreview: DragPreview;
|
||||
selectedNodeIds: Set<string>;
|
||||
frameDropTargetId: string | null;
|
||||
@@ -55,6 +56,8 @@ type CanvasProjectWorldLayersProps = {
|
||||
onConnectStart: (event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => void;
|
||||
onNodeResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
||||
onToggleFrame: (nodeId: string) => void;
|
||||
onFolderStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
|
||||
onFolderThemeChange: (nodeId: string, theme: CanvasFolderTheme) => void;
|
||||
onNodeTitleChange: (nodeId: string, title: string) => void;
|
||||
onNodeContextMenu: (event: ReactMouseEvent, nodeId: string) => void;
|
||||
onNodeContentChange: (nodeId: string, content: string) => void;
|
||||
@@ -77,6 +80,13 @@ const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
|
||||
|
||||
export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
|
||||
const { viewportScale } = props;
|
||||
const framePreviewNodes = (node: CanvasNodeData) => {
|
||||
const assetFolderId = node.metadata?.folder?.assetFolderId;
|
||||
if (assetFolderId) return props.linkedFolderPreviewNodesById.get(assetFolderId) || EMPTY_CANVAS_NODES;
|
||||
const localChildren = props.frameChildrenById.get(node.id) || EMPTY_CANVAS_NODES;
|
||||
if (localChildren.length) return localChildren;
|
||||
return EMPTY_CANVAS_NODES;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
@@ -106,13 +116,15 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
|
||||
key={node.id}
|
||||
data={node}
|
||||
dragOffset={props.dragPreview?.nodeIds.has(node.id) ? props.dragPreview : undefined}
|
||||
childNodes={props.frameChildrenById.get(node.id) || EMPTY_CANVAS_NODES}
|
||||
childNodes={framePreviewNodes(node)}
|
||||
scale={viewportScale}
|
||||
isSelected={props.selectedNodeIds.has(node.id)}
|
||||
isDropTarget={props.frameDropTargetId === node.id}
|
||||
onMouseDown={props.onNodeMouseDown}
|
||||
onResize={props.onNodeResize}
|
||||
onToggleCollapsed={props.onToggleFrame}
|
||||
onFolderStyleChange={props.onFolderStyleChange}
|
||||
onFolderThemeChange={props.onFolderThemeChange}
|
||||
onTitleChange={props.onNodeTitleChange}
|
||||
onContextMenu={props.onNodeContextMenu}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-charac
|
||||
import { shouldAutoConnectCanvasRuntime } from "@/lib/canvas/local-runtime-connection";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
|
||||
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { App } from "antd";
|
||||
@@ -112,6 +113,8 @@ import {
|
||||
CanvasNodeType,
|
||||
type CanvasAssistantSession,
|
||||
type CanvasConnection,
|
||||
type CanvasFolderStyle,
|
||||
type CanvasFolderTheme,
|
||||
type CanvasNodeData,
|
||||
type CanvasMediaPerformanceMode,
|
||||
type StoryboardColumn,
|
||||
@@ -205,6 +208,7 @@ function InfiniteCanvasPage() {
|
||||
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
||||
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
|
||||
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
|
||||
const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all");
|
||||
const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState<Position | undefined>();
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [subtitleNodeId, setSubtitleNodeId] = useState<string | null>(null);
|
||||
@@ -383,6 +387,23 @@ function InfiniteCanvasPage() {
|
||||
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
|
||||
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
|
||||
const refetchLinkedProject = linkedProjectQuery.refetch;
|
||||
const archiveNodesToLinkedFolder = useCallback((folder: CanvasNodeData, droppedNodes: CanvasNodeData[]) => {
|
||||
const folderId = folder.metadata?.folder?.assetFolderId;
|
||||
const domainProjectId = folder.metadata?.folder?.projectId || linkedProjectId;
|
||||
if (!folderId || !domainProjectId || !droppedNodes.length) return;
|
||||
void Promise.all(droppedNodes.map((node) => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, folderId, node, source: "canvas-manual" })))
|
||||
.then((results) => {
|
||||
const archivedByNodeId = new Map(droppedNodes.map((node, index) => [node.id, { assetId: results[index].assetId, content: node.metadata?.content, previousAssetId: node.metadata?.assetId }]));
|
||||
setNodes((current) => current.map((node) => {
|
||||
const archived = archivedByNodeId.get(node.id);
|
||||
if (!archived || node.metadata?.content !== archived.content || node.metadata?.assetId !== archived.previousAssetId) return node;
|
||||
return { ...node, metadata: { ...node.metadata, assetId: archived.assetId } };
|
||||
}));
|
||||
void refetchLinkedProject();
|
||||
message.success(`已归档到“${folder.title}”`);
|
||||
})
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "素材归档失败"));
|
||||
}, [linkedProjectId, message, projectId, refetchLinkedProject, setNodes]);
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || !linkedProjectQuery.data) return;
|
||||
setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets));
|
||||
@@ -671,9 +692,10 @@ function InfiniteCanvasPage() {
|
||||
);
|
||||
|
||||
const openProjectAssets = useCallback(
|
||||
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas") => {
|
||||
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas", initialFolderId = "all") => {
|
||||
setProjectAssetScope(scope);
|
||||
setProjectAssetInitialCategory(initialCategory);
|
||||
setProjectAssetInitialFolderId(initialFolderId);
|
||||
setProjectAssetInsertPosition(position);
|
||||
setProjectAssetOpen(true);
|
||||
// 资产与项目实时同步:打开弹窗前刷新关联短剧项目资产,避免缓存导致资产列表空白/过期。
|
||||
@@ -697,6 +719,7 @@ function InfiniteCanvasPage() {
|
||||
const closeProjectAssets = useCallback(() => {
|
||||
setProjectAssetOpen(false);
|
||||
setProjectAssetInsertPosition(undefined);
|
||||
setProjectAssetInitialFolderId("all");
|
||||
}, []);
|
||||
|
||||
const {
|
||||
@@ -799,6 +822,7 @@ function InfiniteCanvasPage() {
|
||||
arrangeSelectedNodes,
|
||||
copyNodesToClipboard,
|
||||
copySelectedNodes,
|
||||
createFolder,
|
||||
createNode,
|
||||
createReferenceGroup,
|
||||
createStoryboardGroup,
|
||||
@@ -913,6 +937,7 @@ function InfiniteCanvasPage() {
|
||||
onNodeInteractionStart: handleNodeInteractionStart,
|
||||
onNodeClick: handleSelectedNodeClick,
|
||||
onBatchConnectionTarget: handleBatchConnectionTargetClick,
|
||||
onLinkedFolderDrop: archiveNodesToLinkedFolder,
|
||||
onDeselect: handleCanvasDeselect,
|
||||
onSelectionBoxEnd: () => setCanvasTool((tool) => (tool === "box-select" ? "move" : tool)),
|
||||
});
|
||||
@@ -941,6 +966,8 @@ function InfiniteCanvasPage() {
|
||||
collapsingBatchIds,
|
||||
downloadNodeImage,
|
||||
handleConfigNodeChange,
|
||||
handleFolderStyleChange,
|
||||
handleFolderThemeChange,
|
||||
handleFontSizeChange,
|
||||
handleNodeContentChange,
|
||||
handleNodePromptChange,
|
||||
@@ -964,6 +991,62 @@ function InfiniteCanvasPage() {
|
||||
setHoveredNodeId,
|
||||
});
|
||||
|
||||
const handleProjectFolderInsert = useCallback((folderId: string) => {
|
||||
const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);
|
||||
if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试");
|
||||
const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
|
||||
const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora";
|
||||
createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, theme, createdAt: folder.createdAt });
|
||||
}, [createFolder, linkedProjectId, linkedProjectQuery.data?.assetFolders, projectAssetInsertPosition]);
|
||||
|
||||
const handleFrameToggle = useCallback((nodeId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
const linkedFolderId = node?.metadata?.folder?.assetFolderId;
|
||||
if (linkedFolderId) {
|
||||
openProjectAssets("all", node ? { x: node.position.x + node.width + 40, y: node.position.y } : undefined, "canvas", linkedFolderId);
|
||||
return;
|
||||
}
|
||||
toggleFrameCollapsed(nodeId);
|
||||
}, [nodesRef, openProjectAssets, toggleFrameCollapsed]);
|
||||
|
||||
const linkedFolderPreviewNodesById = useMemo(() => {
|
||||
const result = new Map<string, CanvasNodeData[]>();
|
||||
const localById = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
for (const asset of linkedProjectQuery.data?.assets || []) {
|
||||
if (!asset.folderId) continue;
|
||||
const local = localById.get(asset.id);
|
||||
const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
|
||||
const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
|
||||
const remoteResourceId = resourceIdFromStorageKey(asset.storageKey);
|
||||
const content = characterCover ? resourceFileUrl(characterCover.resourceId) : local?.kind === "image" ? local.data.dataUrl || local.coverUrl : local?.kind === "video" || local?.kind === "audio" ? local.data.url : local?.kind === "text" ? local.data.content : remoteResourceId ? resourceFileUrl(remoteResourceId) : asset.previewText || "";
|
||||
const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } };
|
||||
const current = result.get(asset.folderId) || [];
|
||||
current.push(preview);
|
||||
result.set(asset.folderId, current);
|
||||
}
|
||||
return result;
|
||||
}, [assets, linkedProjectQuery.data?.assets]);
|
||||
|
||||
useEffect(() => {
|
||||
const folders = linkedProjectQuery.data?.assetFolders;
|
||||
if (!folders?.length) return;
|
||||
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
setNodes((current) => {
|
||||
let changed = false;
|
||||
const next = current.map((node) => {
|
||||
const folderId = node.metadata?.folder?.assetFolderId;
|
||||
const folder = folderId ? byId.get(folderId) : undefined;
|
||||
if (!folder) return node;
|
||||
const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
|
||||
const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora";
|
||||
if (node.title === folder.name && node.metadata?.folder?.style === style && node.metadata?.folder?.theme === theme) return node;
|
||||
changed = true;
|
||||
return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style, theme, themeCover: undefined } } };
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [linkedProjectQuery.data?.assetFolders, setNodes]);
|
||||
|
||||
const {
|
||||
activeDirectorScene,
|
||||
activeNodeId,
|
||||
@@ -1796,6 +1879,7 @@ function InfiniteCanvasPage() {
|
||||
nodeById={nodeById}
|
||||
visibleNodes={visibleNodes}
|
||||
frameChildrenById={frameChildrenById}
|
||||
linkedFolderPreviewNodesById={linkedFolderPreviewNodesById}
|
||||
dragPreview={dragPreview}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
frameDropTargetId={frameDropTargetId}
|
||||
@@ -1833,7 +1917,9 @@ function InfiniteCanvasPage() {
|
||||
onNodeHoverEnd={handleCanvasNodeHoverEnd}
|
||||
onConnectStart={handleConnectStart}
|
||||
onNodeResize={handleNodeResize}
|
||||
onToggleFrame={toggleFrameCollapsed}
|
||||
onToggleFrame={handleFrameToggle}
|
||||
onFolderStyleChange={handleFolderStyleChange}
|
||||
onFolderThemeChange={handleFolderThemeChange}
|
||||
onNodeTitleChange={handleNodeTitleChange}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onNodeContentChange={handleNodeContentChange}
|
||||
@@ -1891,6 +1977,7 @@ function InfiniteCanvasPage() {
|
||||
onChooseStyle={() => setStylePickerOpen(true)}
|
||||
onAddScript={() => createNode(CanvasNodeType.Script)}
|
||||
onAddFrame={() => createNode(CanvasNodeType.Frame)}
|
||||
onAddFolder={createFolder}
|
||||
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
|
||||
onOpenDirector={() => createDirectorShot()}
|
||||
onUndo={undoCanvas}
|
||||
@@ -2101,6 +2188,7 @@ function InfiniteCanvasPage() {
|
||||
screenToCanvas={screenToCanvas}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onAddNode={(type, position) => createNode(type, position)}
|
||||
onAddFolder={createFolder}
|
||||
onChooseStyle={() => setStylePickerOpen(true)}
|
||||
onOpenDirector={createDirectorShot}
|
||||
onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
|
||||
@@ -2127,7 +2215,7 @@ function InfiniteCanvasPage() {
|
||||
void copyNodeMediaUrlToClipboard(node);
|
||||
}}
|
||||
onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
|
||||
onToggleFrame={(node) => toggleFrameCollapsed(node.id)}
|
||||
onToggleFrame={(node) => handleFrameToggle(node.id)}
|
||||
/>
|
||||
|
||||
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />
|
||||
@@ -2333,7 +2421,7 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
|
||||
<AssetPickerModal open={assetPickerOpen} onInsert={handleTimelineAssetInsert} onClose={closeAssetPicker} />
|
||||
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} />
|
||||
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} initialFolderId={projectAssetInitialFolderId} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined} />
|
||||
{codexCompactAgent && !assistantMounted ? (
|
||||
<CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} />
|
||||
) : null}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader"
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
|
||||
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
|
||||
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { getPublicCanvasShare } from "@/services/api/canvas-share";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -209,8 +209,8 @@ export default function SharedCanvasPage() {
|
||||
const frame = node.metadata?.frame;
|
||||
return {
|
||||
...node,
|
||||
width: collapsed ? 240 : frame?.expandedWidth || node.width,
|
||||
height: collapsed ? 144 : frame?.expandedHeight || node.height,
|
||||
width: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_WIDTH : 240) : frame?.expandedWidth || node.width,
|
||||
height: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_HEIGHT : 144) : frame?.expandedHeight || node.height,
|
||||
metadata: { ...node.metadata, frame: { collapsed, expandedWidth: collapsed ? node.width : frame?.expandedWidth || node.width, expandedHeight: collapsed ? node.height : frame?.expandedHeight || node.height } },
|
||||
};
|
||||
}));
|
||||
@@ -244,7 +244,7 @@ export default function SharedCanvasPage() {
|
||||
const dragged = [node, ...(frameChildrenById.get(nodeId) || [])];
|
||||
dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false };
|
||||
document.body.style.cursor = "grabbing";
|
||||
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
|
||||
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onFolderStyleChange={() => undefined} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} isConnecting={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0) return;
|
||||
const target = nodes.find((item) => item.id === nodeId);
|
||||
|
||||
@@ -4,12 +4,12 @@ import { App } from "antd";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isCanvasFolderNode, isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain";
|
||||
import { audioExtension, imageExtension, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
|
||||
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
|
||||
|
||||
type UseCanvasNodeEditorOptions = {
|
||||
canvasId: string;
|
||||
@@ -64,9 +64,10 @@ export function useCanvasNodeEditor({
|
||||
current.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
const frameState = node.metadata?.frame;
|
||||
const folder = isCanvasFolderNode(node);
|
||||
return collapsed
|
||||
? { ...node, width: frameState?.expandedWidth || NODE_DEFAULT_SIZE[CanvasNodeType.Frame].width, height: frameState?.expandedHeight || NODE_DEFAULT_SIZE[CanvasNodeType.Frame].height, metadata: { ...node.metadata, frame: { collapsed: false, expandedWidth: frameState?.expandedWidth || NODE_DEFAULT_SIZE[CanvasNodeType.Frame].width, expandedHeight: frameState?.expandedHeight || NODE_DEFAULT_SIZE[CanvasNodeType.Frame].height } } }
|
||||
: { ...node, width: FRAME_COLLAPSED_WIDTH, height: FRAME_COLLAPSED_HEIGHT, metadata: { ...node.metadata, frame: { collapsed: true, expandedWidth: node.width, expandedHeight: node.height } } };
|
||||
: { ...node, width: folder ? FOLDER_COLLAPSED_WIDTH : FRAME_COLLAPSED_WIDTH, height: folder ? FOLDER_COLLAPSED_HEIGHT : FRAME_COLLAPSED_HEIGHT, metadata: { ...node.metadata, frame: { collapsed: true, expandedWidth: node.width, expandedHeight: node.height } } };
|
||||
}),
|
||||
);
|
||||
setSelectedNodeIds(new Set([nodeId]));
|
||||
@@ -80,6 +81,22 @@ export function useCanvasNodeEditor({
|
||||
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title } : node)));
|
||||
}, [setNodes]);
|
||||
|
||||
const handleFolderStyleChange = useCallback((nodeId: string, style: CanvasFolderStyle) => {
|
||||
setNodes((current) => current.map((node) => {
|
||||
if (node.id !== nodeId || !isCanvasFolderNode(node)) return node;
|
||||
const folder = node.metadata!.folder!;
|
||||
return { ...node, metadata: { ...node.metadata, folder: { ...folder, style, createdAt: folder.createdAt || new Date().toISOString() } } };
|
||||
}));
|
||||
}, [setNodes]);
|
||||
|
||||
const handleFolderThemeChange = useCallback((nodeId: string, theme: CanvasFolderTheme) => {
|
||||
setNodes((current) => current.map((node) => {
|
||||
if (node.id !== nodeId || !isCanvasFolderNode(node)) return node;
|
||||
const folder = node.metadata!.folder!;
|
||||
return { ...node, metadata: { ...node.metadata, folder: { ...folder, theme, themeCover: undefined, createdAt: folder.createdAt || new Date().toISOString() } } };
|
||||
}));
|
||||
}, [setNodes]);
|
||||
|
||||
const toggleNodeFreeResize = useCallback((nodeId: string) => {
|
||||
setNodes((current) =>
|
||||
current.map((node) => {
|
||||
@@ -180,6 +197,8 @@ export function useCanvasNodeEditor({
|
||||
collapsingBatchIds,
|
||||
downloadNodeImage,
|
||||
handleConfigNodeChange,
|
||||
handleFolderStyleChange,
|
||||
handleFolderThemeChange,
|
||||
handleFontSizeChange,
|
||||
handleNodeContentChange,
|
||||
handleNodePromptChange,
|
||||
|
||||
@@ -3,11 +3,12 @@ import { App } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
|
||||
import { createCanvasNode, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
|
||||
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -132,6 +133,29 @@ export function useCanvasNodeOperations({
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
|
||||
}, [commitNodes, defaultDrawingEngine, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId, tldrawLicenseKey]);
|
||||
|
||||
const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => {
|
||||
const folder = createCanvasNode(CanvasNodeType.Frame, position || getCanvasCenter(), {
|
||||
frame: {
|
||||
collapsed: true,
|
||||
expandedWidth: NODE_DEFAULT_SIZE[CanvasNodeType.Frame].width,
|
||||
expandedHeight: NODE_DEFAULT_SIZE[CanvasNodeType.Frame].height,
|
||||
},
|
||||
folder: {
|
||||
style: linked?.style || "glass",
|
||||
theme: linked?.theme || "aurora",
|
||||
createdAt: linked?.createdAt || new Date().toISOString(),
|
||||
assetFolderId: linked?.id,
|
||||
projectId: linked?.projectId,
|
||||
},
|
||||
});
|
||||
folder.title = linked?.title || "我的文件";
|
||||
folder.width = FOLDER_COLLAPSED_WIDTH;
|
||||
folder.height = FOLDER_COLLAPSED_HEIGHT;
|
||||
commitNodes([...nodesRef.current, folder]);
|
||||
selectNodes(new Set([folder.id]));
|
||||
message.success(linked ? "素材文件夹已放到画布,打开可浏览其中内容" : "文件夹已创建,可拖入任意非容器节点");
|
||||
}, [commitNodes, getCanvasCenter, message, nodesRef, selectNodes]);
|
||||
|
||||
const arrangeSelectedNodes = useCallback((mode: "row" | "column" | "grid" | "flow") => {
|
||||
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));
|
||||
if (selected.length < 2) return;
|
||||
@@ -464,6 +488,7 @@ export function useCanvasNodeOperations({
|
||||
arrangeSelectedNodes,
|
||||
copyNodesToClipboard,
|
||||
copySelectedNodes,
|
||||
createFolder,
|
||||
createNode,
|
||||
createReferenceGroup,
|
||||
createStoryboardGroup,
|
||||
|
||||
@@ -20,6 +20,7 @@ type UseCanvasSelectionControllerOptions = {
|
||||
onNodeInteractionStart: (selectionModifier: boolean) => void;
|
||||
onNodeClick: (node: CanvasNodeData) => void;
|
||||
onBatchConnectionTarget?: (event: ReactMouseEvent, nodeId: string) => boolean;
|
||||
onLinkedFolderDrop?: (folder: CanvasNodeData, nodes: CanvasNodeData[]) => void;
|
||||
onDeselect: () => void;
|
||||
onSelectionBoxEnd?: () => void;
|
||||
};
|
||||
@@ -59,6 +60,7 @@ export function useCanvasSelectionController({
|
||||
onNodeInteractionStart,
|
||||
onNodeClick,
|
||||
onBatchConnectionTarget,
|
||||
onLinkedFolderDrop,
|
||||
onDeselect,
|
||||
onSelectionBoxEnd,
|
||||
}: UseCanvasSelectionControllerOptions) {
|
||||
@@ -205,13 +207,16 @@ export function useCanvasSelectionController({
|
||||
setAlignmentGuides({});
|
||||
if (dragRef.current.hasMoved) {
|
||||
const draggedNodeIds = new Set(dragRef.current.draggedNodeIds);
|
||||
setNodes((currentNodes) => {
|
||||
const positioned = clientX == null || clientY == null ? currentNodes : currentNodes.map((node) => {
|
||||
const initial = initialById.get(node.id);
|
||||
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
|
||||
});
|
||||
return applyFrameDrop(positioned, draggedNodeIds, findFrameDropTarget(positioned, draggedNodeIds));
|
||||
const positioned = clientX == null || clientY == null ? nodesRef.current : nodesRef.current.map((node) => {
|
||||
const initial = initialById.get(node.id);
|
||||
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
|
||||
});
|
||||
const targetId = findFrameDropTarget(positioned, draggedNodeIds);
|
||||
const target = targetId ? positioned.find((node) => node.id === targetId) : undefined;
|
||||
const linkedFolder = target?.metadata?.folder?.assetFolderId ? target : undefined;
|
||||
// 素材库文件夹只建立归档关系,不把画布节点变成其本地子节点。
|
||||
setNodes(linkedFolder ? positioned : applyFrameDrop(positioned, draggedNodeIds, targetId));
|
||||
if (linkedFolder) onLinkedFolderDrop?.(linkedFolder, positioned.filter((node) => draggedNodeIds.has(node.id)));
|
||||
}
|
||||
setFrameDropTargetId(null);
|
||||
alignmentContextRef.current = null;
|
||||
@@ -220,7 +225,7 @@ export function useCanvasSelectionController({
|
||||
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
|
||||
if (clickedNode) onNodeClick(clickedNode);
|
||||
}
|
||||
}, [historyPausedRef, nodesRef, onNodeClick, setNodes, viewportRef]);
|
||||
}, [historyPausedRef, nodesRef, onLinkedFolderDrop, onNodeClick, setNodes, viewportRef]);
|
||||
|
||||
const handleMouseMove = useCallback((event: MouseEvent) => {
|
||||
if (!dragRef.current.isDraggingNode) return;
|
||||
|
||||
@@ -220,6 +220,8 @@ export default function CreatePage() {
|
||||
setSeconds(normalized.seconds);
|
||||
setRatio(normalized.ratio);
|
||||
setVideoQuality(normalized.resolution.replace(/p$/i, ""));
|
||||
const maxReferences = videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0;
|
||||
if (attachments.length > maxReferences) setAttachments((current) => current.slice(0, maxReferences));
|
||||
}, [mode, selectedModel, videoProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1033,7 +1035,7 @@ function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; r
|
||||
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
|
||||
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
|
||||
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
|
||||
return <span key={reference.id} className="creation-user-message-reference">{reference.previewUrl && (reference.kind === "image" || reference.kind === "video") ? <img src={reference.previewUrl} alt="" /> : <Icon />}<span>{reference.label}</span></span>;
|
||||
return <span key={reference.id} className="creation-user-message-reference">{reference.previewUrl && reference.kind === "video" ? <video src={reference.previewUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : reference.previewUrl && reference.kind === "image" ? <img src={reference.previewUrl} alt="" /> : <Icon />}<span>{reference.label}</span></span>;
|
||||
})}</div>;
|
||||
}
|
||||
|
||||
|
||||
362
web/src/pages/dev/folder-preview-lab.css
Normal file
362
web/src/pages/dev/folder-preview-lab.css
Normal file
@@ -0,0 +1,362 @@
|
||||
.folder-preview-lab {
|
||||
min-height: 100dvh;
|
||||
overflow: auto;
|
||||
background-color: var(--workspace-canvas);
|
||||
background-image: radial-gradient(circle, var(--border-semantic) 1.15px, transparent 1.35px);
|
||||
background-position: center top;
|
||||
background-size: 24px 24px;
|
||||
color: var(--fg);
|
||||
padding: var(--space-10) var(--space-12) var(--space-20);
|
||||
}
|
||||
|
||||
.folder-preview-lab-header {
|
||||
display: flex;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto var(--space-10);
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.folder-preview-lab-kicker {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.folder-preview-lab-header h1 {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--fs-display);
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.folder-preview-lab-header p {
|
||||
max-width: 620px;
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-body-lg);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.folder-preview-lab-guide {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border-radius: var(--r-full);
|
||||
background: var(--library-surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.folder-preview-lab-controls {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.folder-preview-lab-theme {
|
||||
display: inline-flex;
|
||||
border-radius: var(--r-full);
|
||||
background: var(--library-surface);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.folder-preview-lab-theme button {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-full);
|
||||
background: transparent;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.folder-preview-lab-theme button[aria-pressed="true"] {
|
||||
background: var(--control-selected-bg);
|
||||
color: var(--control-selected-fg);
|
||||
}
|
||||
|
||||
.folder-preview-lab-theme svg {
|
||||
width: var(--space-4);
|
||||
height: var(--space-4);
|
||||
}
|
||||
|
||||
.folder-preview-lab-guide svg,
|
||||
.folder-preview-lab-open svg,
|
||||
.folder-preview-lab-drawer-header button svg {
|
||||
width: var(--space-4);
|
||||
height: var(--space-4);
|
||||
}
|
||||
|
||||
.folder-preview-lab-section {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.folder-preview-lab-section + .folder-preview-lab-section {
|
||||
margin-top: var(--space-16);
|
||||
}
|
||||
|
||||
.folder-preview-lab-theme-catalog {
|
||||
display: grid;
|
||||
max-width: 1280px;
|
||||
margin: var(--space-16) auto 0;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.folder-preview-lab-theme-catalog div {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 88px;
|
||||
border-radius: var(--r-2xl);
|
||||
background: var(--library-surface);
|
||||
}
|
||||
|
||||
.folder-preview-lab-theme-catalog img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 88px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.folder-preview-lab-theme-catalog span {
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
bottom: var(--space-3);
|
||||
border-radius: var(--r-full);
|
||||
background: color-mix(in srgb, var(--color-neutral-950) 72%, transparent);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--color-neutral-0);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.folder-preview-lab-section-heading {
|
||||
display: flex;
|
||||
margin-bottom: var(--space-6);
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.folder-preview-lab-section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.folder-preview-lab-section-heading p {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.folder-preview-lab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: var(--space-10) var(--space-8);
|
||||
}
|
||||
|
||||
.folder-preview-lab-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.folder-preview-lab-item-copy {
|
||||
display: flex;
|
||||
margin-bottom: var(--space-4);
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.folder-preview-lab-item-copy strong {
|
||||
font-size: var(--fs-heading);
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.folder-preview-lab-item-copy span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.folder-preview-lab-folder {
|
||||
width: 360px;
|
||||
height: 280px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-preview-lab-folder:focus-visible {
|
||||
border-radius: var(--r-5xl);
|
||||
outline: 2px solid var(--cn-active-stroke);
|
||||
outline-offset: var(--space-2);
|
||||
}
|
||||
|
||||
.folder-preview-lab-open,
|
||||
.folder-preview-lab-drawer-header button {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-xl);
|
||||
background: var(--library-surface);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-body);
|
||||
transition:
|
||||
transform var(--motion-state) var(--motion-ease-out),
|
||||
background-color var(--motion-state) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.folder-preview-lab-open {
|
||||
margin: var(--space-4) auto 0;
|
||||
}
|
||||
|
||||
.folder-preview-lab-open:hover,
|
||||
.folder-preview-lab-drawer-header button:hover {
|
||||
background: var(--library-surface-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.folder-preview-lab-drawer {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
inset: var(--space-6);
|
||||
overflow: auto;
|
||||
border-radius: var(--r-5xl);
|
||||
background: var(--bg);
|
||||
padding: var(--space-8);
|
||||
box-shadow: var(--elevation-overlay);
|
||||
}
|
||||
|
||||
.folder-preview-lab-drawer-header {
|
||||
display: grid;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto var(--space-8);
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.folder-preview-lab-drawer-header > div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.folder-preview-lab-drawer-header > div span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.folder-preview-lab-drawer-header h2 {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--fs-title);
|
||||
}
|
||||
|
||||
.folder-preview-lab-drawer-header button:first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.folder-preview-lab-drawer-header button:last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.folder-preview-lab-assets {
|
||||
display: grid;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.folder-preview-lab-asset {
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-3xl);
|
||||
background: var(--library-surface);
|
||||
transition:
|
||||
transform var(--motion-state) var(--motion-ease-out),
|
||||
background-color var(--motion-state) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.folder-preview-lab-asset:hover {
|
||||
background: var(--library-surface-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.folder-preview-lab-asset img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.folder-preview-lab-asset div {
|
||||
display: flex;
|
||||
padding: var(--space-4);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.folder-preview-lab-asset strong {
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
.folder-preview-lab-asset span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.folder-preview-lab {
|
||||
padding: var(--space-6) var(--space-4) var(--space-12);
|
||||
}
|
||||
.folder-preview-lab-header,
|
||||
.folder-preview-lab-controls,
|
||||
.folder-preview-lab-section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.folder-preview-lab-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.folder-preview-lab-theme-catalog {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.folder-preview-lab-folder {
|
||||
transform: scale(0.86);
|
||||
transform-origin: top center;
|
||||
margin-bottom: -40px;
|
||||
}
|
||||
.folder-preview-lab-drawer {
|
||||
inset: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.folder-preview-lab-drawer-header {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.folder-preview-lab-drawer-header > div {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.folder-preview-lab-folder *,
|
||||
.folder-preview-lab-open,
|
||||
.folder-preview-lab-asset,
|
||||
.folder-preview-lab-drawer-header button {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
230
web/src/pages/dev/folder-preview-lab.tsx
Normal file
230
web/src/pages/dev/folder-preview-lab.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronLeft, FolderOpen, ImagePlus, Moon, MousePointer2, Sun } from "lucide-react";
|
||||
|
||||
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
|
||||
import { CANVAS_FOLDER_THEME_OPTIONS } from "@/lib/canvas/canvas-folder-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
import "./folder-preview-lab.css";
|
||||
|
||||
const STYLE_PRESETS: Array<{ style: CanvasFolderStyle; name: string; hint: string }> = [
|
||||
{ style: "glass", name: "流光玻璃", hint: "复刻参考 · 加号入口" },
|
||||
{ style: "stacked", name: "内容陈列", hint: "复刻参考 · 文件预览" },
|
||||
{ style: "midnight", name: "午夜封面", hint: "复刻参考 · 深色标签" },
|
||||
{ style: "paper", name: "纸感收藏", hint: "衍生 · 轻量资料" },
|
||||
{ style: "cinema", name: "电影胶片", hint: "衍生 · 影像项目" },
|
||||
{ style: "compact", name: "紧凑资料", hint: "衍生 · 高频调用" },
|
||||
];
|
||||
|
||||
const PRESET_GROUPS = [
|
||||
{
|
||||
key: "reference",
|
||||
title: "原图复刻",
|
||||
description: "三种参考结构:玻璃加号、内容陈列、午夜封面。",
|
||||
presets: STYLE_PRESETS.slice(0, 3),
|
||||
},
|
||||
{
|
||||
key: "derived",
|
||||
title: "风格衍生",
|
||||
description: "外形与主题正交组合,六种结构都可切换四套皮肤。",
|
||||
presets: STYLE_PRESETS.slice(3),
|
||||
},
|
||||
];
|
||||
|
||||
const THEME_SEQUENCE: CanvasFolderTheme[] = ["aurora", "pearl", "ember", "obsidian", "aurora", "pearl"];
|
||||
|
||||
const SAMPLE_MEDIA = ["/short-drama-styles/fantasy-3d.jpg", "/short-drama-styles/suspense-noir.jpg", "/short-drama-styles/cyberpunk-neon.jpg", "/short-drama-styles/ink-narrative.jpg", "/short-drama-styles/space-opera.jpg"];
|
||||
|
||||
function makeMediaNode(id: string, title: string, content: string, index: number): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 320,
|
||||
height: 180,
|
||||
metadata: { content, mimeType: "image/jpeg", count: index + 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeFolderNode(style: CanvasFolderStyle, index: number): CanvasNodeData {
|
||||
return {
|
||||
id: `folder-${style}`,
|
||||
type: CanvasNodeType.Frame,
|
||||
title: ["My Files", "Archives", "My Files", "灵感收藏", "影像资产", "快速调用"][index],
|
||||
position: { x: 0, y: 0 },
|
||||
width: 360,
|
||||
height: 280,
|
||||
metadata: {
|
||||
folder: { style, theme: THEME_SEQUENCE[index], createdAt: new Date(2026, 7, 19 + index).toISOString() },
|
||||
frame: { collapsed: true, expandedWidth: 760, expandedHeight: 520 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const INITIAL_FOLDERS = STYLE_PRESETS.map((preset, index) => makeFolderNode(preset.style, index));
|
||||
|
||||
const INITIAL_CHILDREN = Object.fromEntries(
|
||||
INITIAL_FOLDERS.map((folder, folderIndex) => [
|
||||
folder.id,
|
||||
SAMPLE_MEDIA.slice(0, 3 + (folderIndex % 3)).map((source, childIndex) => makeMediaNode(`${folder.id}-asset-${childIndex}`, ["主视觉", "氛围参考", "场景草图", "色彩方案", "镜头灵感"][childIndex], source, childIndex)),
|
||||
]),
|
||||
) as Record<string, CanvasNodeData[]>;
|
||||
|
||||
export default function FolderPreviewLab() {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const [folders, setFolders] = useState(INITIAL_FOLDERS);
|
||||
const [childrenByFolder, setChildrenByFolder] = useState(INITIAL_CHILDREN);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [openedId, setOpenedId] = useState<string | null>(null);
|
||||
const openedFolder = useMemo(() => folders.find((folder) => folder.id === openedId), [folders, openedId]);
|
||||
const openedChildren = openedId ? childrenByFolder[openedId] || [] : [];
|
||||
|
||||
const updateFolder = (nodeId: string, patch: (folder: CanvasNodeData) => CanvasNodeData) => {
|
||||
setFolders((current) => current.map((folder) => (folder.id === nodeId ? patch(folder) : folder)));
|
||||
};
|
||||
|
||||
const addSampleContent = () => {
|
||||
if (!openedId) return;
|
||||
setChildrenByFolder((current) => {
|
||||
const previous = current[openedId] || [];
|
||||
const index = previous.length;
|
||||
const next = makeMediaNode(`${openedId}-asset-${Date.now()}`, `新素材 ${index + 1}`, SAMPLE_MEDIA[index % SAMPLE_MEDIA.length], index);
|
||||
return { ...current, [openedId]: [...previous, next] };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="folder-preview-lab">
|
||||
<header className="folder-preview-lab-header">
|
||||
<div>
|
||||
<span className="folder-preview-lab-kicker">DEV · INTERACTIVE FOLDER LAB</span>
|
||||
<h1>通用素材文件夹</h1>
|
||||
<p>文件夹外形与主题皮肤独立:四套主题都不是内部素材。打开后,真实内容以独立卡片展示;悬停、单击、双击与菜单均可交互。</p>
|
||||
</div>
|
||||
<div className="folder-preview-lab-controls">
|
||||
<div className="folder-preview-lab-theme" aria-label="预览主题">
|
||||
<button type="button" aria-pressed={theme === "light"} onClick={() => setTheme("light")}>
|
||||
<Sun aria-hidden /> 浅色
|
||||
</button>
|
||||
<button type="button" aria-pressed={theme === "dark"} onClick={() => setTheme("dark")}>
|
||||
<Moon aria-hidden /> 深色
|
||||
</button>
|
||||
</div>
|
||||
<div className="folder-preview-lab-guide">
|
||||
<MousePointer2 aria-hidden />
|
||||
<span>试试 hover、双击、样式与主题切换</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{PRESET_GROUPS.map((group) => (
|
||||
<section key={group.key} className="folder-preview-lab-section" aria-labelledby={`folder-preview-${group.key}`}>
|
||||
<div className="folder-preview-lab-section-heading">
|
||||
<h2 id={`folder-preview-${group.key}`}>{group.title}</h2>
|
||||
<p>{group.description}</p>
|
||||
</div>
|
||||
<div className="folder-preview-lab-grid">
|
||||
{group.presets.map((preset) => {
|
||||
const folder = folders.find((item) => item.id === `folder-${preset.style}`)!;
|
||||
const childNodes = childrenByFolder[folder.id] || [];
|
||||
return (
|
||||
<article key={folder.id} className="folder-preview-lab-item">
|
||||
<div className="folder-preview-lab-item-copy">
|
||||
<strong>{preset.name}</strong>
|
||||
<span>{preset.hint}</span>
|
||||
</div>
|
||||
<div
|
||||
className="folder-preview-lab-folder"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`打开 ${folder.title}`}
|
||||
onClick={() => setSelectedId(folder.id)}
|
||||
onDoubleClick={() => setOpenedId(folder.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setOpenedId(folder.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CanvasFolderPreview
|
||||
data={folder}
|
||||
childNodes={childNodes}
|
||||
active={selectedId === folder.id}
|
||||
isDropTarget={false}
|
||||
readOnly={false}
|
||||
onToggleCollapsed={setOpenedId}
|
||||
onTitleChange={(nodeId, title) => updateFolder(nodeId, (current) => ({ ...current, title }))}
|
||||
onStyleChange={(nodeId, style) =>
|
||||
updateFolder(nodeId, (current) => ({
|
||||
...current,
|
||||
metadata: {
|
||||
...current.metadata,
|
||||
folder: { ...current.metadata!.folder!, style },
|
||||
},
|
||||
}))
|
||||
}
|
||||
onThemeChange={(nodeId, nextTheme) =>
|
||||
updateFolder(nodeId, (current) => ({
|
||||
...current,
|
||||
metadata: {
|
||||
...current.metadata,
|
||||
folder: { ...current.metadata!.folder!, theme: nextTheme, themeCover: undefined },
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="folder-preview-lab-open" onClick={() => setOpenedId(folder.id)}>
|
||||
<FolderOpen aria-hidden /> 打开文件夹
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="folder-preview-lab-theme-catalog" aria-label="可用文件夹主题">
|
||||
{CANVAS_FOLDER_THEME_OPTIONS.map((item) => (
|
||||
<div key={item.key}>
|
||||
<img src={item.cover} alt="" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{openedFolder ? (
|
||||
<section className="folder-preview-lab-drawer" aria-label={`${openedFolder.title} 内容`}>
|
||||
<div className="folder-preview-lab-drawer-header">
|
||||
<button type="button" onClick={() => setOpenedId(null)}>
|
||||
<ChevronLeft aria-hidden /> 返回
|
||||
</button>
|
||||
<div>
|
||||
<span>已打开</span>
|
||||
<h2>{openedFolder.title}</h2>
|
||||
</div>
|
||||
<button type="button" onClick={addSampleContent}>
|
||||
<ImagePlus aria-hidden /> 添加示例内容
|
||||
</button>
|
||||
</div>
|
||||
<div className="folder-preview-lab-assets">
|
||||
{openedChildren.map((node) => (
|
||||
<article key={node.id} className="folder-preview-lab-asset">
|
||||
<img src={node.metadata?.content} alt="" draggable={false} />
|
||||
<div>
|
||||
<strong>{node.title}</strong>
|
||||
<span>图片素材</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,42 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd";
|
||||
import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
|
||||
import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
|
||||
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme } from "@/lib/canvas/canvas-folder-theme";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import {
|
||||
bindProjectCharacterVoice,
|
||||
confirmProjectAssetCandidate,
|
||||
createProjectAssetFolder,
|
||||
createProjectAssetVersion,
|
||||
createProjectCharacter,
|
||||
deleteProjectAssetFolder,
|
||||
getProjectCharacter,
|
||||
linkProjectAsset,
|
||||
listVoiceProfiles,
|
||||
moveProjectAsset,
|
||||
replaceProjectCharacterRepresentations,
|
||||
unbindProjectCharacterVoice,
|
||||
unlinkProjectAsset,
|
||||
updateProjectAssetCategory,
|
||||
updateProjectAssetFolder,
|
||||
updateProjectCharacter,
|
||||
type ProjectAsset,
|
||||
type ProjectAssetFolder,
|
||||
type ProjectDetail,
|
||||
} from "@/services/api/projects";
|
||||
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useAssetStore, type Asset, type AssetCategory, type AssetStatus, type EntityAsset, type ImageAsset } from "@/stores/use-asset-store";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData } from "@/types/canvas";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { ProjectCharacterCard } from "./project-character-card";
|
||||
@@ -36,6 +44,7 @@ import { generateCharacterTurnaround } from "./project-character-media";
|
||||
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared";
|
||||
|
||||
const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"];
|
||||
const ALL_FOLDERS = "__all_folders__";
|
||||
const pickerCategoryLabels = { all: "全部素材", ...categoryLabels };
|
||||
const characterFields = [
|
||||
["role", "剧情定位与人物关系"], ["aliases", "别名"], ["appearance", "稳定外貌"], ["physique", "身高、体型与体态"],
|
||||
@@ -47,13 +56,16 @@ const characterFields = [
|
||||
type CharacterForm = { name: string } & Record<(typeof characterFields)[number][0], string>;
|
||||
|
||||
export default function ProjectAssetsView({ detail, refreshProject }: ProjectDetailViewProps) {
|
||||
const { message } = App.useApp();
|
||||
const { message, modal } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const personalAssets = useAssetStore((state) => state.assets);
|
||||
const updatePersonalAsset = useAssetStore((state) => state.updateAsset);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const [category, setCategory] = useState("all");
|
||||
const [folderId, setFolderId] = useState("");
|
||||
const [folderEditor, setFolderEditor] = useState<{ folder?: ProjectAssetFolder; parentId: string } | null>(null);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editorAsset, setEditorAsset] = useState<ProjectAsset | "new" | null>(null);
|
||||
const [imageAsset, setImageAsset] = useState<ProjectAsset | null>(null);
|
||||
@@ -86,7 +98,18 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const pendingCandidates = detail.assetCandidates.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation");
|
||||
const characterAssets = detail.assets.filter((asset) => asset.category === "character" && asset.character);
|
||||
const mediaAssetCount = detail.assets.filter((asset) => asset.category !== "character").length;
|
||||
const visibleAssets = useMemo(() => category === "all" ? detail.assets : detail.assets.filter((asset) => asset.category === category), [category, detail.assets]);
|
||||
const assetFolders = detail.assetFolders || [];
|
||||
const currentFolder = assetFolders.find((folder) => folder.id === folderId);
|
||||
const childFolders = folderId === ALL_FOLDERS ? [] : assetFolders.filter((folder) => (folder.parentId || "") === folderId);
|
||||
const visibleAssets = useMemo(() => detail.assets.filter((asset) => {
|
||||
if (category !== "all" && asset.category !== category) return false;
|
||||
return folderId === ALL_FOLDERS || (asset.folderId || "") === folderId;
|
||||
}), [category, detail.assets, folderId]);
|
||||
const folderPath = useMemo(() => projectAssetFolderPath(assetFolders, folderId), [assetFolders, folderId]);
|
||||
const folderMoveItems = useMemo(() => [
|
||||
{ key: "", label: "素材库 / 根目录" },
|
||||
...assetFolders.map((folder) => ({ key: folder.id, label: projectAssetFolderLabel(assetFolders, folder) })),
|
||||
], [assetFolders]);
|
||||
const categoryCounts = categories.map((value) => ({
|
||||
value,
|
||||
count: value === "all"
|
||||
@@ -102,10 +125,41 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
|
||||
const done = (content: string) => { refreshProject(); message.success(content); };
|
||||
const failed = (fallback: string) => (error: unknown) => message.error(error instanceof Error ? error.message : fallback);
|
||||
const addMutation = useMutation({ mutationFn: ({ assetId, category: nextCategory }: { assetId: string; category: string }) => linkProjectAsset(detail.project.id, { assetId, category: nextCategory }), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }); setAddOpen(false); done("资产已加入项目"); }, onError: failed("资产加入失败") });
|
||||
const addMutation = useMutation({ mutationFn: ({ assetId, category: nextCategory, nextFolderId }: { assetId: string; category: string; nextFolderId?: string }) => linkProjectAsset(detail.project.id, { assetId, category: nextCategory, folderId: nextFolderId }), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory, status: asset.status as AssetStatus, primaryVersionId: asset.primaryVersionId }); setAddOpen(false); done("资产已加入项目"); }, onError: failed("资产加入失败") });
|
||||
const versionMutation = useMutation({ mutationFn: (id: string) => createProjectAssetVersion(detail.project.id, id, {}), onSuccess: () => done("已创建新版本"), onError: failed("版本创建失败") });
|
||||
const unlinkMutation = useMutation({ mutationFn: (id: string) => unlinkProjectAsset(detail.project.id, id), onSuccess: () => done("资产已移出项目"), onError: failed("资产移除失败") });
|
||||
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
|
||||
const moveMutation = useMutation({ mutationFn: ({ id, nextFolderId }: { id: string; nextFolderId: string }) => moveProjectAsset(detail.project.id, id, nextFolderId), onSuccess: () => done("资产已移动"), onError: failed("资产移动失败") });
|
||||
const createFolderMutation = useMutation({
|
||||
mutationFn: ({ name, parentId }: { name: string; parentId: string }) => createProjectAssetFolder(detail.project.id, { name, parentId: parentId || undefined }),
|
||||
onSuccess: ({ folder }) => { setFolderEditor(null); setFolderName(""); setFolderId(folder.parentId || ""); done("文件夹已创建"); },
|
||||
onError: failed("文件夹创建失败"),
|
||||
});
|
||||
const renameFolderMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => updateProjectAssetFolder(detail.project.id, id, { name }),
|
||||
onSuccess: () => { setFolderEditor(null); setFolderName(""); done("文件夹已重命名"); },
|
||||
onError: failed("文件夹重命名失败"),
|
||||
});
|
||||
const styleFolderMutation = useMutation({
|
||||
mutationFn: ({ id, style }: { id: string; style: CanvasFolderStyle }) => updateProjectAssetFolder(detail.project.id, id, { style }),
|
||||
onSuccess: () => done("文件夹样式已更新"),
|
||||
onError: failed("文件夹样式更新失败"),
|
||||
});
|
||||
const themeFolderMutation = useMutation({
|
||||
mutationFn: ({ id, theme }: { id: string; theme: CanvasFolderTheme }) => updateProjectAssetFolder(detail.project.id, id, { theme }),
|
||||
onSuccess: () => done("文件夹主题已更新"),
|
||||
onError: failed("文件夹主题更新失败"),
|
||||
});
|
||||
const moveFolderMutation = useMutation({
|
||||
mutationFn: ({ id, parentId }: { id: string; parentId: string }) => updateProjectAssetFolder(detail.project.id, id, { parentId }),
|
||||
onSuccess: () => done("文件夹已移动"),
|
||||
onError: failed("文件夹移动失败"),
|
||||
});
|
||||
const deleteFolderMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteProjectAssetFolder(detail.project.id, id),
|
||||
onSuccess: (_, deletedId) => { if (folderId === deletedId) setFolderId(""); done("文件夹已删除"); },
|
||||
onError: failed("文件夹删除失败"),
|
||||
});
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: ({ candidateId, targetAssetId }: { candidateId: string; targetAssetId?: string }) => confirmProjectAssetCandidate(detail.project.id, candidateId, targetAssetId),
|
||||
onSuccess: ({ asset }, variables) => {
|
||||
@@ -167,6 +221,19 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
};
|
||||
const openImages = (asset: ProjectAsset) => setImageAsset(asset);
|
||||
const openVoice = (asset: ProjectAsset) => { setVoiceAsset(asset); setVoiceProfileId(asset.character?.voice?.profile.id || ""); setVoiceInstructions(asset.character?.voice?.instructions || ""); };
|
||||
const openFolderEditor = (folder?: ProjectAssetFolder, parentId = folderId === ALL_FOLDERS ? "" : folderId) => {
|
||||
setFolderEditor({ folder, parentId: folder?.parentId || parentId });
|
||||
setFolderName(folder?.name || "");
|
||||
};
|
||||
const saveFolder = () => {
|
||||
const name = folderName.trim();
|
||||
if (!name) {
|
||||
message.warning("请输入文件夹名称");
|
||||
return;
|
||||
}
|
||||
if (folderEditor?.folder) renameFolderMutation.mutate({ id: folderEditor.folder.id, name });
|
||||
else if (folderEditor) createFolderMutation.mutate({ name, parentId: folderEditor.parentId });
|
||||
};
|
||||
const downloadPreviewAsset = (asset: ProjectAsset) => {
|
||||
const personal = personalAssets.find((item) => item.id === asset.id);
|
||||
if (personal && (personal.kind === "image" || personal.kind === "video" || personal.kind === "audio" || personal.kind === "model")) {
|
||||
@@ -177,7 +244,11 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
}
|
||||
const cover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
|
||||
if (cover) saveAs(resourceFileUrl(cover.resourceId), `${asset.title || "character"}.png`);
|
||||
else message.warning("当前资产没有可下载的媒体文件");
|
||||
else {
|
||||
const remoteUrl = projectAssetRemoteUrl(asset);
|
||||
if (remoteUrl) saveAs(remoteUrl, `${asset.title || "asset"}.${projectAssetFileExtension(asset.mediaType)}`);
|
||||
else message.warning("当前资产没有可下载的媒体文件");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
@@ -196,14 +267,30 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:pl-4">
|
||||
<Button type="text" className="!h-9 !px-3" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor()}>新建文件夹</Button>
|
||||
<Button type="text" className="!h-9 !px-3" icon={<Link2 className="size-3.5" />} onClick={() => setAddOpen(true)}>引用素材</Button>
|
||||
<Button type="primary" className="!h-9 !px-3.5" icon={<Plus className="size-3.5" />} onClick={() => openCharacterEditor("new")}>新建角色</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-[156px_minmax(0,1fr)]">
|
||||
<nav className="space-y-0.5 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<div className="project-assets-layout mt-3 grid gap-3">
|
||||
<nav className="space-y-0.5 pr-2" aria-label="素材目录与资产分类">
|
||||
<div className="mb-1 flex h-8 items-center justify-between px-2 text-[var(--fs-tiny)] font-medium text-foreground/42"><span>素材目录</span><button type="button" className="rounded p-1 hover:bg-surface-hover" aria-label="新建根目录文件夹" onClick={() => openFolderEditor(undefined, "")}><FolderPlus className="size-3.5" /></button></div>
|
||||
<button type="button" onClick={() => { setFolderId(""); setCategory("all"); }} className={`flex h-11 w-full items-center gap-2 rounded-md px-2 text-left text-xs ${folderId === "" ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><FolderOpen className="size-4 shrink-0" /><span className="min-w-0 flex-1 truncate">素材库</span><span className="text-[var(--fs-tiny)] tabular-nums text-foreground/42">{detail.assets.filter((asset) => !asset.folderId).length}</span></button>
|
||||
<ProjectAssetFolderTree folders={assetFolders} assets={detail.assets} selectedId={folderId} onSelect={(next) => { setFolderId(next); setCategory("all"); }} />
|
||||
<div className="my-2 h-px bg-foreground/[.08]" />
|
||||
<div className="mb-1 h-8 px-2 text-[var(--fs-tiny)] font-medium leading-8 text-foreground/42">分类筛选</div>
|
||||
{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => { setCategory(item.value); setFolderId(ALL_FOLDERS); }} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${folderId === ALL_FOLDERS && category === item.value ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
{(category === "all" || category === "character") && pendingCandidates.length ? (
|
||||
<div className="mb-3 flex min-h-9 flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1 text-xs text-foreground/48">
|
||||
<button type="button" className="truncate rounded px-1.5 py-1 hover:bg-surface-hover" onClick={() => { setFolderId(""); setCategory("all"); }}>素材库</button>
|
||||
{folderId === ALL_FOLDERS ? <><span>/</span><span className="font-medium text-foreground">全部资产</span></> : folderPath.map((folder) => <span key={folder.id} className="contents"><span>/</span><button type="button" className="truncate rounded px-1.5 py-1 font-medium text-foreground hover:bg-surface-hover" onClick={() => setFolderId(folder.id)}>{folder.name}</button></span>)}
|
||||
</div>
|
||||
{folderId !== ALL_FOLDERS ? <Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor(undefined, folderId)}>新建子文件夹</Button> : null}
|
||||
</div>
|
||||
{childFolders.length ? <div className="project-asset-folder-grid mb-5">{childFolders.map((folder) => <ProjectAssetFolderCard key={folder.id} folder={folder} folders={assetFolders} assets={detail.assets} personalAssets={personalAssets} onOpen={() => { setFolderId(folder.id); setCategory("all"); }} onRename={() => openFolderEditor(folder)} onMove={(parentId) => moveFolderMutation.mutate({ id: folder.id, parentId })} onStyle={(style) => styleFolderMutation.mutate({ id: folder.id, style })} onTheme={(theme) => themeFolderMutation.mutate({ id: folder.id, theme })} onDelete={() => modal.confirm({ title: `删除文件夹“${folder.name}”?`, content: "仅空文件夹可以删除,素材和子文件夹不会被级联删除。", okText: "删除", okButtonProps: { danger: true }, cancelText: "取消", onOk: () => deleteFolderMutation.mutateAsync(folder.id) })} deleting={(deleteFolderMutation.isPending && deleteFolderMutation.variables === folder.id) || (moveFolderMutation.isPending && moveFolderMutation.variables?.id === folder.id) || (styleFolderMutation.isPending && styleFolderMutation.variables?.id === folder.id) || (themeFolderMutation.isPending && themeFolderMutation.variables?.id === folder.id)} />)}</div> : null}
|
||||
{folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length ? (
|
||||
<section className="mb-4" aria-label="待确认角色">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-foreground/50" />剧情识别出的角色</div>
|
||||
@@ -229,8 +316,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{category === "all" ? "全部资产" : categoryLabel(category)}</span><span>{visibleAssets.length} 项已确认</span></div>
|
||||
{visibleAssets.length ? <div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onOpen={() => setPreviewAsset(asset)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : (category === "all" || category === "character") && pendingCandidates.length ? null : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{currentFolder?.name || (folderId === ALL_FOLDERS ? (category === "all" ? "全部资产" : categoryLabel(category)) : "根目录内容")}</span><span>{visibleAssets.length} 项已确认</span></div>
|
||||
{visibleAssets.length ? <div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} folderItems={folderMoveItems} generating={generatingAssetIds.has(asset.id)} removing={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} folderItems={folderMoveItems} onOpen={() => setPreviewAsset(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : childFolders.length || (folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length) ? null : <WorkspaceState icon="assets" compact title="这个文件夹还没有内容" description="可以新建子文件夹、引用个人素材,或把画布产物归档到这里。" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -247,11 +334,14 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
onConfirm={async (ids) => {
|
||||
const selected = personalAssets.find((asset) => asset.id === ids[0]);
|
||||
if (!selected) throw new Error("所选素材已不存在,请重新选择");
|
||||
await addMutation.mutateAsync({ assetId: selected.id, category: selected.kind === "entity" ? "character" : selected.category || "other" });
|
||||
await addMutation.mutateAsync({ assetId: selected.id, category: selected.kind === "entity" ? "character" : selected.category || "other", nextFolderId: folderId === ALL_FOLDERS ? undefined : folderId });
|
||||
}}
|
||||
/>
|
||||
<ProjectAssetPreviewModal asset={previewAsset} personalAsset={previewAsset ? personalAssets.find((item) => item.id === previewAsset.id) : undefined} onClose={() => setPreviewAsset(null)} onDownload={() => previewAsset && downloadPreviewAsset(previewAsset)} onReplaceImage={() => { if (!previewAsset || previewAsset.category !== "character") return; setPreviewAsset(null); openImages(previewAsset); }} />
|
||||
<CharacterEditorModal open={Boolean(editorAsset)} editing={editorAsset !== "new"} form={form} loading={saveCharacter.isPending} onClose={() => setEditorAsset(null)} onSave={() => form.validateFields().then((values) => saveCharacter.mutate(values))} />
|
||||
<Modal className="workspace-modal workspace-modal-compact" title={folderEditor?.folder ? "重命名文件夹" : "新建文件夹"} open={Boolean(folderEditor)} okText={folderEditor?.folder ? "保存" : "创建"} cancelText="取消" okButtonProps={{ loading: createFolderMutation.isPending || renameFolderMutation.isPending }} onCancel={() => { setFolderEditor(null); setFolderName(""); }} onOk={saveFolder}>
|
||||
<div className="grid gap-2"><span className="text-[var(--fs-label)] text-foreground/48">位置:{folderEditor ? projectAssetFolderParentLabel(assetFolders, folderEditor.parentId) : ""}</span><Input autoFocus maxLength={60} value={folderName} placeholder="输入文件夹名称" onChange={(event) => setFolderName(event.target.value)} onPressEnter={saveFolder} /></div>
|
||||
</Modal>
|
||||
<AssetLibraryPickerModal
|
||||
open={Boolean(imageAsset)}
|
||||
items={imagePickerItems}
|
||||
@@ -273,6 +363,129 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectAssetFolderTree({ folders, assets, selectedId, onSelect }: { folders: ProjectAssetFolder[]; assets: ProjectAsset[]; selectedId: string; onSelect: (folderId: string) => void }) {
|
||||
const renderLevel = (parentId: string, depth: number, visited: ReadonlySet<string>): ReactNode => depth >= 8 ? null : folders
|
||||
.filter((folder) => (folder.parentId || "") === parentId)
|
||||
.map((folder) => {
|
||||
if (visited.has(folder.id)) return null;
|
||||
const count = assets.filter((asset) => asset.folderId === folder.id).length;
|
||||
const nextVisited = new Set(visited).add(folder.id);
|
||||
return <div key={folder.id}><button type="button" onClick={() => onSelect(folder.id)} className={`flex h-9 w-full items-center gap-1.5 rounded-md pr-2 text-left text-[var(--fs-label)] ${selectedId === folder.id ? "bg-surface-active font-medium" : "text-foreground/52 hover:bg-surface-hover"}`} style={{ paddingLeft: `calc(var(--space-2) + ${depth} * var(--space-3))` }}><FolderOpen className="size-3.5 shrink-0" /><span className="min-w-0 flex-1 truncate">{folder.name}</span><span className="text-[var(--fs-micro)] tabular-nums text-foreground/36">{count}</span></button>{renderLevel(folder.id, depth + 1, nextVisited)}</div>;
|
||||
});
|
||||
return <div>{renderLevel("", 0, new Set())}</div>;
|
||||
}
|
||||
|
||||
function ProjectAssetFolderCard({ folder, folders, assets, personalAssets, onOpen, onRename, onMove, onStyle, onTheme, onDelete, deleting }: { folder: ProjectAssetFolder; folders: ProjectAssetFolder[]; assets: ProjectAsset[]; personalAssets: Asset[]; onOpen: () => void; onRename: () => void; onMove: (parentId: string) => void; onStyle: (style: CanvasFolderStyle) => void; onTheme: (theme: CanvasFolderTheme) => void; onDelete: () => void; deleting: boolean }) {
|
||||
const directAssets = assets.filter((asset) => (asset.folderId || "") === folder.id);
|
||||
const totalCount = projectAssetFolderDescendantAssetCount(folders, assets, folder.id);
|
||||
const childFolderCount = folders.filter((item) => item.parentId === folder.id).length;
|
||||
const moveItems = projectAssetFolderMoveItems(folders, folder);
|
||||
const data: CanvasNodeData = {
|
||||
id: folder.id,
|
||||
type: CanvasNodeType.Frame,
|
||||
title: folder.name,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 360,
|
||||
height: 280,
|
||||
metadata: {
|
||||
frame: { collapsed: true, expandedWidth: 760, expandedHeight: 520 },
|
||||
folder: { style: projectAssetFolderStyle(folder.style), theme: resolveCanvasFolderTheme(folder.theme), createdAt: folder.createdAt },
|
||||
},
|
||||
};
|
||||
const childNodes = directAssets.slice(0, 3).map((asset, index) => projectAssetCanvasPreviewNode(asset, personalAssets.find((item) => item.id === asset.id), index));
|
||||
return <article className="project-asset-folder-card" aria-label={`${folder.name} 文件夹,共 ${totalCount} 项`}>
|
||||
<button type="button" className="project-asset-folder-open" aria-label={`打开文件夹 ${folder.name}`} onClick={onOpen} />
|
||||
<div className="project-asset-folder-visual"><CanvasFolderPreview data={data} childNodes={childNodes} active={false} isDropTarget={false} readOnly onToggleCollapsed={onOpen} onTitleChange={() => undefined} onStyleChange={() => undefined} onThemeChange={() => undefined} /></div>
|
||||
<div className="project-asset-folder-card-footer"><span>{directAssets.length} 项内容{childFolderCount ? ` · ${childFolderCount} 个子文件夹` : ""}</span><Dropdown trigger={["click"]} menu={{ selectedKeys: [`style:${folder.style}`, `theme:${resolveCanvasFolderTheme(folder.theme)}`], items: [{ key: "rename", label: "重命名", icon: <Pencil className="size-3.5" /> }, { key: "move", label: "移动到", icon: <MoveRight className="size-3.5" />, children: moveItems }, { key: "style", label: "切换样式", children: [{ key: "style:glass", label: "流光玻璃" }, { key: "style:stacked", label: "内容陈列" }, { key: "style:midnight", label: "午夜封面" }, { key: "style:paper", label: "纸感收藏" }, { key: "style:cinema", label: "电影胶片" }, { key: "style:compact", label: "紧凑资料" }] }, { key: "theme", label: "切换主题", children: CANVAS_FOLDER_THEME_OPTIONS.map((item) => ({ key: `theme:${item.key}`, label: item.label })) }, { key: "delete", label: "删除空文件夹", icon: <Trash2 className="size-3.5" />, danger: true }], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); if (key === "rename") onRename(); else if (key.startsWith("move:")) onMove(key === "move:root" ? "" : key.slice(5)); else if (key.startsWith("style:")) onStyle(key.slice(6) as CanvasFolderStyle); else if (key.startsWith("theme:")) onTheme(key.slice(6) as CanvasFolderTheme); else if (key === "delete") onDelete(); } }}><button type="button" className="project-asset-folder-menu" disabled={deleting} aria-label={`${folder.name} 文件夹操作`} onClick={(event) => event.stopPropagation()}><MoreHorizontal className="size-4" /></button></Dropdown></div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function projectAssetCanvasPreviewNode(asset: ProjectAsset, personalAsset: Asset | undefined, index: number): CanvasNodeData {
|
||||
const type = asset.mediaType === "image" || asset.category === "character" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
|
||||
const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
|
||||
const content = characterCover
|
||||
? resourceFileUrl(characterCover.resourceId)
|
||||
: personalAsset?.kind === "image"
|
||||
? personalAsset.data.dataUrl || personalAsset.coverUrl
|
||||
: personalAsset?.kind === "video" || personalAsset?.kind === "audio"
|
||||
? personalAsset.data.url
|
||||
: personalAsset?.kind === "text"
|
||||
? personalAsset.data.content
|
||||
: projectAssetRemoteUrl(asset) || asset.previewText || "";
|
||||
return { id: asset.id, type, title: asset.title, position: { x: index * 24, y: index * 18 }, width: 240, height: 160, metadata: { content, assetId: asset.id } };
|
||||
}
|
||||
|
||||
function projectAssetFolderStyle(style?: string): CanvasFolderStyle {
|
||||
return style === "glass" || style === "stacked" || style === "midnight" || style === "paper" || style === "cinema" || style === "compact" ? style : "glass";
|
||||
}
|
||||
|
||||
function projectAssetRemoteUrl(asset: ProjectAsset) {
|
||||
const resourceId = resourceIdFromStorageKey(asset.storageKey);
|
||||
return resourceId ? resourceFileUrl(resourceId) : "";
|
||||
}
|
||||
|
||||
function projectAssetFileExtension(mediaType: string) {
|
||||
if (mediaType === "image") return "png";
|
||||
if (mediaType === "video") return "mp4";
|
||||
if (mediaType === "audio") return "mp3";
|
||||
if (mediaType === "model") return "glb";
|
||||
return "txt";
|
||||
}
|
||||
|
||||
function projectAssetFolderPath(folders: ProjectAssetFolder[], folderId: string) {
|
||||
if (!folderId || folderId === ALL_FOLDERS) return [];
|
||||
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const result: ProjectAssetFolder[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byId.get(folderId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
result.unshift(current);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function projectAssetFolderLabel(folders: ProjectAssetFolder[], folder: ProjectAssetFolder) {
|
||||
return projectAssetFolderPath(folders, folder.id).map((item) => item.name).join(" / ");
|
||||
}
|
||||
|
||||
function projectAssetFolderParentLabel(folders: ProjectAssetFolder[], parentId: string) {
|
||||
const path = parentId ? projectAssetFolderPath(folders, parentId).map((item) => item.name) : [];
|
||||
return ["素材库", ...path].join(" / ");
|
||||
}
|
||||
|
||||
function projectAssetFolderMoveItems(folders: ProjectAssetFolder[], movingFolder: ProjectAssetFolder) {
|
||||
const blocked = new Set([movingFolder.id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId || blocked.has(folder.id) || !blocked.has(folder.parentId)) continue;
|
||||
blocked.add(folder.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return [
|
||||
{ key: "move:root", label: "素材库 / 根目录", disabled: !movingFolder.parentId },
|
||||
...folders.filter((folder) => !blocked.has(folder.id)).map((folder) => ({ key: `move:${folder.id}`, label: projectAssetFolderLabel(folders, folder), disabled: movingFolder.parentId === folder.id })),
|
||||
];
|
||||
}
|
||||
|
||||
function projectAssetFolderDescendantAssetCount(folders: ProjectAssetFolder[], assets: ProjectAsset[], folderId: string) {
|
||||
const descendantIds = new Set([folderId]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId || descendantIds.has(folder.id) || !descendantIds.has(folder.parentId)) continue;
|
||||
descendantIds.add(folder.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
return assets.filter((asset) => asset.folderId && descendantIds.has(asset.folderId)).length;
|
||||
}
|
||||
|
||||
function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }: { open: boolean; editing: boolean; form: FormInstance<CharacterForm>; loading: boolean; onClose: () => void; onSave: () => void }) {
|
||||
const field = (key: CharacterFormKey) => characterFields.find(([name]) => name === key)!;
|
||||
const textArea = (key: CharacterFormKey, rows = 3) => <Form.Item name={key} label={field(key)[1]}><Input.TextArea rows={rows} placeholder={key === "appearance" ? "先描述用户能看到的稳定特征…" : undefined} /></Form.Item>;
|
||||
@@ -285,19 +498,30 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
|
||||
|
||||
type CharacterFormKey = (typeof characterFields)[number][0];
|
||||
|
||||
function MediaAssetCard({ asset, personalAsset, onOpen, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onOpen: () => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
|
||||
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
function MediaAssetCard({ asset, personalAsset, folderItems, onOpen, onMove, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; folderItems: Array<{ key: string; label: string }>; onOpen: () => void; onMove: (folderId: string) => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
|
||||
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><ProjectAssetMedia asset={asset} personalAsset={personalAsset} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" icon={<MoveRight className="size-3.5" />} loading={loading} aria-label={`移动 ${asset.title}`} /></Dropdown><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} aria-label={`为 ${asset.title} 创建版本`} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} aria-label={`移出 ${asset.title}`} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
}
|
||||
|
||||
function ProjectAssetMedia({ asset, personalAsset }: { asset: ProjectAsset; personalAsset?: Asset }) {
|
||||
if (personalAsset) return <AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} />;
|
||||
const remoteUrl = projectAssetRemoteUrl(asset);
|
||||
if (asset.mediaType === "image" && remoteUrl) return <img src={remoteUrl} alt={asset.title} className="h-full w-full bg-black object-cover" />;
|
||||
if (asset.mediaType === "video" && remoteUrl) return <video src={remoteUrl} muted preload="metadata" className="h-full w-full bg-black object-cover" />;
|
||||
if (asset.mediaType === "text" && asset.previewText) return <p className="line-clamp-6 h-full overflow-hidden p-4 text-left text-xs leading-5 text-foreground/62">{asset.previewText}</p>;
|
||||
return <div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>;
|
||||
}
|
||||
|
||||
function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, onReplaceImage }: { asset: ProjectAsset | null; personalAsset?: Asset; onClose: () => void; onDownload: () => void; onReplaceImage: () => void }) {
|
||||
const characterCover = asset?.character?.representations.find((item) => item.role === "turnaround_sheet") || asset?.character?.representations.find((item) => item.role === "primary") || asset?.character?.representations[0];
|
||||
const canDownload = Boolean(personalAsset && ["image", "video", "audio", "model"].includes(personalAsset.kind)) || Boolean(characterCover);
|
||||
const previewClass = asset?.category === "character" ? "is-character" : personalAsset?.kind === "video" ? "is-video" : personalAsset?.kind === "audio" ? "is-audio" : personalAsset?.kind === "text" ? "is-text" : "is-image";
|
||||
const remoteUrl = asset ? projectAssetRemoteUrl(asset) : "";
|
||||
const canDownload = Boolean(personalAsset && ["image", "video", "audio", "model"].includes(personalAsset.kind)) || Boolean(characterCover) || Boolean(remoteUrl);
|
||||
const previewKind = personalAsset?.kind || asset?.mediaType;
|
||||
const previewClass = asset?.category === "character" ? "is-character" : previewKind === "video" ? "is-video" : previewKind === "audio" ? "is-audio" : previewKind === "text" ? "is-text" : "is-image";
|
||||
return (
|
||||
<Modal className="workspace-modal workspace-modal-wide library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
|
||||
{asset ? <div className="project-asset-preview-layout">
|
||||
<div className={`project-asset-preview-stage ${previewClass}`}>
|
||||
{asset.category === "character" ? characterCover ? <CachedResourceImage storageKey={`resource:${characterCover.resourceId}`} src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" fallback={<div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div>} /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <AssetMediaPreview asset={personalAsset} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
|
||||
{asset.category === "character" ? characterCover ? <CachedResourceImage storageKey={`resource:${characterCover.resourceId}`} src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" fallback={<div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div>} /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <AssetMediaPreview asset={personalAsset} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : asset.mediaType === "video" && remoteUrl ? <video src={remoteUrl} controls className="project-asset-preview-media" /> : asset.mediaType === "audio" && remoteUrl ? <audio src={remoteUrl} controls className="project-asset-preview-audio" /> : asset.mediaType === "image" && remoteUrl ? <img src={remoteUrl} alt={asset.title} className="project-asset-preview-media" /> : asset.mediaType === "text" && asset.previewText ? <p className="project-asset-preview-text">{asset.previewText}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
|
||||
</div>
|
||||
<aside className="project-asset-preview-details">
|
||||
<div className="project-asset-preview-eyebrow">{asset.category === "character" ? "角色卡" : mediaLabel(asset.mediaType)}</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Popconfirm } from "antd";
|
||||
import { Image as ImageIcon, Pencil, Sparkles, Trash2, UserRound, Volume2 } from "lucide-react";
|
||||
import { Button, Dropdown, Popconfirm } from "antd";
|
||||
import { Image as ImageIcon, MoveRight, Pencil, Sparkles, Trash2, UserRound, Volume2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
@@ -9,8 +9,9 @@ import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/ass
|
||||
|
||||
import { textValue } from "./shared";
|
||||
|
||||
export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEdit, onGenerate, onBindImages, onBindVoice, onRemove }: {
|
||||
export function ProjectCharacterCard({ asset, folderItems, generating, removing, onOpen, onEdit, onGenerate, onBindImages, onBindVoice, onMove, onRemove }: {
|
||||
asset: ProjectAsset;
|
||||
folderItems: Array<{ key: string; label: string }>;
|
||||
generating: boolean;
|
||||
removing: boolean;
|
||||
onOpen: () => void;
|
||||
@@ -18,6 +19,7 @@ export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEd
|
||||
onGenerate: () => void;
|
||||
onBindImages: () => void;
|
||||
onBindVoice: () => void;
|
||||
onMove: (folderId: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const character = asset.character;
|
||||
@@ -46,7 +48,7 @@ export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEd
|
||||
<StatusLine icon={<Volume2 className="size-3.5" />} ready={character?.voiceStatus === "ready"} label={voiceStatus} action={character?.voiceStatus === "ready" ? "调整" : "选择"} onClick={onBindVoice} />
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 gap-2 border-t border-border/60 pt-2">
|
||||
<Button size="small" className="min-w-0 flex-1" icon={<Sparkles className="size-3.5" />} loading={generating} disabled={removing} onClick={onGenerate}>{character?.visualStatus === "missing" ? "初始化三视图" : "重新生成三视图"}</Button><Popconfirm title="移出项目角色?" description="已有画布或镜头引用时将无法移出。" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" loading={removing} disabled={generating} icon={<Trash2 className="size-3.5" />} aria-label={`移出 ${asset.title}`} /></Popconfirm>
|
||||
<Button size="small" className="min-w-0 flex-1" icon={<Sparkles className="size-3.5" />} loading={generating} disabled={removing} onClick={onGenerate}>{character?.visualStatus === "missing" ? "初始化三视图" : "重新生成三视图"}</Button><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" disabled={generating || removing} icon={<MoveRight className="size-3.5" />} aria-label={`移动 ${asset.title}`} /></Dropdown><Popconfirm title="移出项目角色?" description="已有画布或镜头引用时将无法移出。" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" loading={removing} disabled={generating} icon={<Trash2 className="size-3.5" />} aria-label={`移出 ${asset.title}`} /></Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</AssetLibraryCard>
|
||||
|
||||
Reference in New Issue
Block a user