From 9121e8979892e4d3dc511e7e89eb9e66cbfd8748 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 15:43:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BB=84=E7=BB=87=E6=93=8D=E4=BD=9C=E6=92=A4?= =?UTF-8?q?=E9=94=80=E9=87=8D=E5=81=9A=EF=BC=88WP4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 features/resource-canvas/resourceCanvasHistoryModel:纯函数有界快照栈(capture/push/undo/redo/clear + 只回写差异位置),快照只含资源卡布局坐标,不回滚素材内容 新增 tests/resourceCanvasHistoryModel.test.ts 覆盖排序稳定、相同快照不入栈、栈上限、push 清空重做分支、撤销重做互逆、空栈返回 null、差异回写与已删除资源不重建 资源卡拖动持久化成功前先落快照,撤销/重做复用现役 commitPosition 手动 CAS 写链 画布缩放集群新增撤销/重做按钮(不可用时禁用),并支持 Ctrl/Cmd+Z 与 Ctrl/Cmd+Shift+Z、Ctrl+Y(输入框内不拦截) 项目切换时清空布局历史,避免跨项目回滚 --- .../resourceCanvasHistoryModel.ts | 208 ++++++++++++++++++ .../src/view/project-development/index.tsx | 133 +++++++++++ .../tests/resourceCanvasHistoryModel.test.ts | 166 ++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasHistoryModel.test.ts diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts new file mode 100644 index 000000000..bd5d4202a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts @@ -0,0 +1,208 @@ +/** + * 资源画布的组织操作历史:只记录资源卡的布局坐标,**不记录、也不回滚素材内容**。 + * + * 素材不可变是硬约束,所以撤销/重做只作用于「画布上卡片怎么排」,绝不反向修改 + * manifest 条目或素材文件。持久化仍走现役的手动 CAS 写链(`commitPosition`), + * 本模块只做纯函数的快照栈。 + */ + +import type { ProjectResourceCanvasSection } from '../../../../../packages/shared/src/contracts/gameCreationApp'; + +export const MAX_RESOURCE_CANVAS_HISTORY_STEPS = 40; + +export type ResourceCanvasLayoutSnapshotEntry = { + resourceId: string; + section: ProjectResourceCanvasSection; + x: number; + y: number; + manuallyPlaced: boolean; +}; + +export type ResourceCanvasLayoutSnapshot = { + entries: ResourceCanvasLayoutSnapshotEntry[]; +}; + +export type ResourceCanvasHistoryEntry = { + id: number; + label: string; + snapshot: ResourceCanvasLayoutSnapshot; + createdAt: number; +}; + +export type ResourceCanvasHistory = { + undoStack: ResourceCanvasHistoryEntry[]; + redoStack: ResourceCanvasHistoryEntry[]; + nextEntryId: number; +}; + +export function createResourceCanvasHistory(): ResourceCanvasHistory { + return { undoStack: [], redoStack: [], nextEntryId: 1 }; +} + +/** + * 按 resourceId 排序后落快照,保证同一组坐标无论 map 顺序如何都比较得出相等。 + */ +export function captureResourceCanvasSnapshot( + positions: readonly ResourceCanvasLayoutSnapshotEntry[], +): ResourceCanvasLayoutSnapshot { + return { + entries: positions + .map((position) => ({ + resourceId: position.resourceId, + section: position.section, + x: position.x, + y: position.y, + manuallyPlaced: position.manuallyPlaced, + })) + .sort((left, right) => left.resourceId.localeCompare(right.resourceId)), + }; +} + +export function resourceCanvasSnapshotsEqual( + left: ResourceCanvasLayoutSnapshot, + right: ResourceCanvasLayoutSnapshot, +) { + if (left.entries.length !== right.entries.length) { + return false; + } + return left.entries.every((entry, index) => { + const other = right.entries[index]; + return ( + other !== undefined && + entry.resourceId === other.resourceId && + entry.section === other.section && + entry.x === other.x && + entry.y === other.y && + entry.manuallyPlaced === other.manuallyPlaced + ); + }); +} + +export function pushResourceCanvasHistory( + history: ResourceCanvasHistory, + { + label, + snapshot, + createdAt = 0, + }: { + label: string; + snapshot: ResourceCanvasLayoutSnapshot; + createdAt?: number; + }, +): ResourceCanvasHistory { + const previous = history.undoStack.at(-1); + if (previous && resourceCanvasSnapshotsEqual(previous.snapshot, snapshot)) { + return history; + } + const entry: ResourceCanvasHistoryEntry = { + id: history.nextEntryId, + label, + snapshot, + createdAt, + }; + return { + undoStack: [ + ...history.undoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)), + entry, + ], + // 新的操作会让原来的重做分支失效。 + redoStack: [], + nextEntryId: history.nextEntryId + 1, + }; +} + +export type ResourceCanvasHistoryStep = { + history: ResourceCanvasHistory; + label: string; + snapshot: ResourceCanvasLayoutSnapshot; +}; + +export function undoResourceCanvasHistory( + history: ResourceCanvasHistory, + currentSnapshot: ResourceCanvasLayoutSnapshot, +): ResourceCanvasHistoryStep | null { + const entry = history.undoStack.at(-1); + if (!entry) { + return null; + } + return { + history: { + undoStack: history.undoStack.slice(0, -1), + redoStack: [ + ...history.redoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)), + { + ...entry, + snapshot: currentSnapshot, + }, + ], + nextEntryId: history.nextEntryId, + }, + label: entry.label, + snapshot: entry.snapshot, + }; +} + +export function redoResourceCanvasHistory( + history: ResourceCanvasHistory, + currentSnapshot: ResourceCanvasLayoutSnapshot, +): ResourceCanvasHistoryStep | null { + const entry = history.redoStack.at(-1); + if (!entry) { + return null; + } + return { + history: { + undoStack: [ + ...history.undoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)), + { + ...entry, + snapshot: currentSnapshot, + }, + ], + redoStack: history.redoStack.slice(0, -1), + nextEntryId: history.nextEntryId, + }, + label: entry.label, + snapshot: entry.snapshot, + }; +} + +export function clearResourceCanvasHistory( + history: ResourceCanvasHistory, +): ResourceCanvasHistory { + if (history.undoStack.length === 0 && history.redoStack.length === 0) { + return history; + } + return { ...history, undoStack: [], redoStack: [] }; +} + +export function canUndoResourceCanvasHistory(history: ResourceCanvasHistory) { + return history.undoStack.length > 0; +} + +export function canRedoResourceCanvasHistory(history: ResourceCanvasHistory) { + return history.redoStack.length > 0; +} + +/** + * 只回写和快照不一致的位置,避免撤销时把没动过的卡片也当成一次手动摆放写回后端。 + */ +export function resolveResourceCanvasRestoreEntries( + snapshot: ResourceCanvasLayoutSnapshot, + current: readonly ResourceCanvasLayoutSnapshotEntry[], +): ResourceCanvasLayoutSnapshotEntry[] { + const currentById = new Map( + current.map((entry) => [entry.resourceId, entry] as const), + ); + return snapshot.entries.filter((entry) => { + const existing = currentById.get(entry.resourceId); + if (!existing) { + return false; + } + return ( + existing.section !== entry.section || + existing.x !== entry.x || + existing.y !== entry.y + ); + }); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ed5e575aa..0b53f3800 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -29,11 +29,13 @@ import { Pause, Play, Plus, + Redo2, RotateCcw, Search, Settings2, SlidersHorizontal, Sparkles, + Undo2, X, ZoomOut, } from 'lucide-react'; @@ -80,6 +82,18 @@ import { RESOURCE_REFERENCE_FILTERS, type ResourceReferenceFilter, } from '../../features/project-workspace/resourceReferences'; +import { + canRedoResourceCanvasHistory, + canUndoResourceCanvasHistory, + captureResourceCanvasSnapshot, + clearResourceCanvasHistory, + createResourceCanvasHistory, + pushResourceCanvasHistory, + redoResourceCanvasHistory, + resolveResourceCanvasRestoreEntries, + type ResourceCanvasLayoutSnapshot, + undoResourceCanvasHistory, +} from '../../features/resource-canvas/resourceCanvasHistoryModel'; import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel'; import { canNormalizeResourceIntoManifestAsset, @@ -1195,6 +1209,10 @@ export default function ProjectDevelopmentView({ useState(null); const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); + /** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */ + const [resourceCanvasHistory, setResourceCanvasHistory] = useState( + createResourceCanvasHistory, + ); const [uiEditorRoute, setUiEditorRoute] = useState( null, ); @@ -2966,6 +2984,7 @@ export default function ProjectDevelopmentView({ setSelectedResourceIds([]); setQuickEditPanel(null); setQuickEditSourceLayer(null); + setResourceCanvasHistory(clearResourceCanvasHistory); setActiveResourceCategory(null); const defaultViewports = defaultResourceCanvasViewports(); resourceCanvasViewportTargetsRef.current = defaultViewports; @@ -3246,6 +3265,91 @@ export default function ProjectDevelopmentView({ } }, [resourceCardPreviews.previews, stopActiveCardMedia]); + const applyResourceCanvasLayoutSnapshot = useCallback( + (snapshot: ResourceCanvasLayoutSnapshot) => { + resolveResourceCanvasRestoreEntries( + snapshot, + activeResourceLayout.layout.positions, + ).forEach((entry) => { + activeResourceLayout.commitPosition( + entry.resourceId, + entry.section, + entry.x, + entry.y, + ); + }); + }, + [activeResourceLayout], + ); + + const undoSaveResourceCanvasLayout = useCallback(() => { + const step = undoResourceCanvasHistory( + resourceCanvasHistory, + captureResourceCanvasSnapshot(activeResourceLayout.layout.positions), + ); + if (!step) { + return; + } + setResourceCanvasHistory(step.history); + applyResourceCanvasLayoutSnapshot(step.snapshot); + }, [ + activeResourceLayout.layout.positions, + applyResourceCanvasLayoutSnapshot, + resourceCanvasHistory, + ]); + + const redoResourceCanvasLayout = useCallback(() => { + const step = redoResourceCanvasHistory( + resourceCanvasHistory, + captureResourceCanvasSnapshot(activeResourceLayout.layout.positions), + ); + if (!step) { + return; + } + setResourceCanvasHistory(step.history); + applyResourceCanvasLayoutSnapshot(step.snapshot); + }, [ + activeResourceLayout.layout.positions, + applyResourceCanvasLayoutSnapshot, + resourceCanvasHistory, + ]); + + const canUndoResourceCanvas = canUndoResourceCanvasHistory( + resourceCanvasHistory, + ); + const canRedoResourceCanvas = canRedoResourceCanvasHistory( + resourceCanvasHistory, + ); + + useEffect(() => { + const handleHistoryShortcut = (event: KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey) || event.altKey) { + return; + } + const key = event.key.toLowerCase(); + const isUndo = key === 'z' && !event.shiftKey; + const isRedo = (key === 'z' && event.shiftKey) || key === 'y'; + if (!isUndo && !isRedo) { + return; + } + const target = event.target; + if ( + target instanceof HTMLElement && + target.closest('input, textarea, [contenteditable="true"]') + ) { + return; + } + event.preventDefault(); + if (isUndo) { + undoSaveResourceCanvasLayout(); + } else { + redoResourceCanvasLayout(); + } + }; + window.addEventListener('keydown', handleHistoryShortcut); + return () => window.removeEventListener('keydown', handleHistoryShortcut); + }, [redoResourceCanvasLayout, undoSaveResourceCanvasLayout]); + const handleResourceSelect = useCallback( (resourceId: string, options: { append?: boolean } = {}) => { if (skipNextResourceCardClickRef.current === resourceId) { @@ -3591,6 +3695,15 @@ export default function ProjectDevelopmentView({ : null; setResourceCardDragPreview(null); if (commit && drag.changed && preview?.resourceId === drag.resourceId) { + // 先落"拖动前"的快照,撤销才有回退点;它只含布局坐标。 + setResourceCanvasHistory((history) => + pushResourceCanvasHistory(history, { + label: '移动资源卡', + snapshot: captureResourceCanvasSnapshot( + activeResourceLayout.layout.positions, + ), + }), + ); activeResourceLayout.commitPosition( drag.resourceId, drag.section, @@ -5010,6 +5123,26 @@ export default function ProjectDevelopmentView({ onWheel={handleResourceBookWheel} />
+ + + + +
+ + + + +
+ + {notice ? ( +

+ {notice} +

+ ) : null} + + {entries.length === 0 ? ( +

当前筛选下没有资源

+ ) : ( +
    + {entries.map((entry) => { + const isSelected = selected.has(entry.resourceId); + return ( +
  • + +
    + {entry.label} + {`${entry.categoryLabel} · ${entry.typeLabel}`} +
    +
  • + ); + })} +
+ )} + +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetTransferModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetTransferModel.ts new file mode 100644 index 000000000..96e1e6216 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetTransferModel.ts @@ -0,0 +1,96 @@ +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; + +/** 上传只接受浏览器/客户端都能直读字节的图片与音频,其它类型走 Agent 侧登记。 */ +const UPLOADABLE_MEDIA_TYPE_PREFIXES = ['image/', 'audio/', 'video/']; + +export type ResourceCanvasPanelEntry = { + resourceId: string; + label: string; + categoryLabel: string; + typeLabel: string; + path: string; + mediaType: string; + selectable: boolean; + downloadable: boolean; + previewIdentity: string | null; + previewSourceUrl: string | null; + previewStatus: 'idle' | 'loading' | 'loaded' | 'failed'; + previewError: string | null; +}; + +export type ResourceCanvasPanelEntryInput = { + resource: ProjectResource; + categoryLabel: string; + typeLabel: string; + previewIdentity: string | null; + previewStatus: 'idle' | 'loading' | 'loaded' | 'failed'; + previewSourceUrl: string | null; + previewError: string | null; +}; + +export function resolveResourceCanvasPanelEntries( + inputs: readonly ResourceCanvasPanelEntryInput[], +): ResourceCanvasPanelEntry[] { + return inputs.map((input) => ({ + resourceId: input.resource.id, + label: input.resource.label, + categoryLabel: input.categoryLabel, + typeLabel: input.typeLabel, + path: input.resource.path, + mediaType: input.resource.mediaType, + selectable: true, + // 保存走本地文件复制,因此只要求有可读路径;虚拟版本条目没有文件,不能保存。 + downloadable: + input.resource.version === undefined && input.resource.path !== '', + previewIdentity: input.previewIdentity, + previewSourceUrl: input.previewSourceUrl, + previewStatus: input.previewStatus, + previewError: input.previewError, + })); +} + +export function resolveSelectedPanelEntries( + entries: readonly ResourceCanvasPanelEntry[], + selectedResourceIds: readonly string[], +) { + const selected = new Set(selectedResourceIds); + return entries.filter((entry) => selected.has(entry.resourceId)); +} + +export function resolveDownloadablePanelEntries( + entries: readonly ResourceCanvasPanelEntry[], +) { + return entries.filter((entry) => entry.downloadable); +} + +export function resourceFileNameFromPath(path: string) { + const segments = path.split(/[\\/]/u).filter(Boolean); + return segments.at(-1) ?? path; +} + +export function defaultResourceExportFileName( + entry: Pick, +) { + const fromPath = resourceFileNameFromPath(entry.path); + if (fromPath && fromPath !== entry.path) { + return fromPath; + } + return entry.label; +} + +export function isUploadableResourceFile(file: { + type?: string; + name: string; +}) { + const mediaType = (file.type ?? '').toLowerCase(); + if ( + UPLOADABLE_MEDIA_TYPE_PREFIXES.some((prefix) => + mediaType.startsWith(prefix), + ) + ) { + return true; + } + return /\.(png|jpe?g|webp|gif|bmp|avif|mp3|wav|ogg|m4a|mp4|webm|mov)$/iu.test( + file.name, + ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 9490e5615..de56e491d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -239,3 +239,138 @@ width: 94vw; } } + +/* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */ +.game-resource-panel { + width: min(880px, calc(100% - 32px)); + max-height: calc(100% - 32px); + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.game-resource-panel-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.game-resource-panel-actions > button, +.game-resource-panel-upload { + display: inline-flex; + align-items: center; + gap: 0.35rem; + height: 32px; + padding: 0 0.7rem; + border: 1px solid #ecdcd4; + border-radius: 8px; + background: #fffdfa; + color: #6b4a3c; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.game-resource-panel-actions > button:disabled, +.game-resource-panel-upload:has(input:disabled) { + opacity: 0.55; + cursor: not-allowed; +} + +.game-resource-panel-upload { + position: relative; + overflow: hidden; +} + +.game-resource-panel-upload input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.game-resource-panel-download { + margin-left: auto; +} + +.game-resource-panel-notice { + margin: 0; + color: #8c6252; + font-size: 0.78rem; +} + +.game-resource-panel-empty { + margin: 0; + color: #8c6252; + font-size: 0.82rem; +} + +.game-resource-panel-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.6rem; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.game-resource-panel-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.3rem; + padding: 0.4rem; + border: 1px solid transparent; + border-radius: 10px; + background: #fffaf6; +} + +.game-resource-panel-card.is-selected { + border-color: #d57b51; + box-shadow: 0 0 0 2px rgb(216 115 66 / 16%); +} + +.game-resource-panel-preview { + display: grid; + width: 100%; + aspect-ratio: 4 / 3; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 8px; + background: linear-gradient(145deg, #fffaf6, #f6ece6); + color: #c46a40; + cursor: pointer; + place-items: center; +} + +.game-resource-panel-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.game-resource-panel-preview-placeholder { + font-size: 0.72rem; + color: #a97f6c; +} + +.game-resource-panel-meta { + display: flex; + min-width: 0; + flex-direction: column; +} + +.game-resource-panel-meta strong { + overflow: hidden; + font-size: 0.76rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-panel-meta small { + color: #8c6252; + font-size: 0.68rem; +} \ No newline at end of file diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 0b53f3800..1d9ca7f20 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -13,6 +13,7 @@ import { CanvasChromeButton, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, Code2, @@ -82,6 +83,12 @@ import { RESOURCE_REFERENCE_FILTERS, type ResourceReferenceFilter, } from '../../features/project-workspace/resourceReferences'; +import { + defaultResourceExportFileName, + isUploadableResourceFile, + resolveResourceCanvasPanelEntries, + resolveSelectedPanelEntries, +} from '../../features/resource-canvas/resourceCanvasAssetTransferModel'; import { canRedoResourceCanvasHistory, canUndoResourceCanvasHistory, @@ -94,6 +101,7 @@ import { type ResourceCanvasLayoutSnapshot, undoResourceCanvasHistory, } from '../../features/resource-canvas/resourceCanvasHistoryModel'; +import { ResourceCanvasPanelView } from '../../features/resource-canvas/ResourceCanvasPanelView'; import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel'; import { canNormalizeResourceIntoManifestAsset, @@ -1213,6 +1221,9 @@ export default function ProjectDevelopmentView({ const [resourceCanvasHistory, setResourceCanvasHistory] = useState( createResourceCanvasHistory, ); + const [resourcePanelOpen, setResourcePanelOpen] = useState(false); + const [resourcePanelNotice, setResourcePanelNotice] = useState(''); + const [resourcePanelUploading, setResourcePanelUploading] = useState(false); const [uiEditorRoute, setUiEditorRoute] = useState( null, ); @@ -3265,6 +3276,161 @@ export default function ProjectDevelopmentView({ } }, [resourceCardPreviews.previews, stopActiveCardMedia]); + const resourcePanelEntries = useMemo( + () => + resolveResourceCanvasPanelEntries( + visibleResources.map((resource) => { + const previewIdentity = + resourceCardPreviews.identityByResourceId.get(resource.id) ?? null; + const preview = previewIdentity + ? resourceCardPreviews.previews.get(previewIdentity) + : undefined; + return { + resource, + categoryLabel: categoryLabels[resource.category], + typeLabel: projectResourceTypeLabel(resource), + previewIdentity, + previewStatus: preview?.status ?? 'idle', + previewSourceUrl: + preview?.status === 'loaded' + ? (preview.preview.sourceUrl ?? null) + : null, + previewError: + preview?.status === 'failed' ? (preview.error ?? null) : null, + }; + }), + ), + [resourceCardPreviews, visibleResources], + ); + const selectedResourcePanelEntries = resolveSelectedPanelEntries( + resourcePanelEntries, + selectedResourceIds, + ); + + useEffect(() => { + if (!resourcePanelOpen) { + return; + } + // 面板打开时补一次预览请求;卡片自身只按可见性请求。 + resourcePanelEntries.slice(0, 24).forEach((entry) => { + const resource = canvasResources.find( + (item) => item.id === entry.resourceId, + ); + if (resource && entry.previewIdentity) { + resourceCardPreviews.requestPreview( + resource, + entry.previewIdentity, + 'detail', + ); + } + }); + }, [ + canvasResources, + resourceCardPreviews, + resourcePanelEntries, + resourcePanelOpen, + ]); + + const downloadResourcePanelEntries = useCallback( + async (targets: typeof resourcePanelEntries) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || targets.length === 0) { + if (!invoke) { + setResourcePanelNotice('保存素材需要在客户端内打开'); + } + return; + } + setResourcePanelNotice(''); + let savedCount = 0; + for (const [index, entry] of targets.entries()) { + const destination = await saveNativeFileDialog({ + defaultPath: defaultResourceExportFileName(entry), + title: + targets.length > 1 + ? `保存素材(${index + 1}/${targets.length})` + : '保存素材', + }); + if (!destination) { + break; + } + try { + const result = await invoke<{ + destinationPath: string; + byteLen: number; + }>('save_local_project_asset_file', { + input: { + projectPath, + relativePath: entry.path, + destinationPath: destination, + }, + }); + savedCount += 1; + setResourcePanelNotice(`已保存 ${result.destinationPath}`); + } catch (error) { + setResourcePanelNotice( + error instanceof Error ? error.message : String(error), + ); + break; + } + } + if (savedCount > 1) { + setResourcePanelNotice(`已保存 ${savedCount} 个素材`); + } + }, + [projectPath], + ); + + const uploadResourcePanelFiles = useCallback( + async (files: FileList) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setResourcePanelNotice('上传素材需要在客户端内打开'); + return; + } + const uploadable = Array.from(files).filter(isUploadableResourceFile); + if (uploadable.length === 0) { + setResourcePanelNotice('只能上传图片、音频或视频素材'); + return; + } + setResourcePanelUploading(true); + setResourcePanelNotice(''); + try { + let expectedProjectRevision: number | null = null; + for (const file of uploadable) { + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + expectedProjectRevision = status.revision; + const bytes = Array.from(new Uint8Array(await file.arrayBuffer())); + await invoke('upload_local_asset', { + projectPath, + fileName: file.name, + mediaType: file.type || 'application/octet-stream', + bytes, + }); + } + setResourcePanelNotice(`已上传 ${uploadable.length} 个素材`); + if (expectedProjectRevision !== null) { + await reloadManifestAfterAssetCommand( + expectedProjectRevision, + `asset-upload:${Date.now()}`, + ); + } + } catch (error) { + setResourcePanelNotice( + error instanceof Error ? error.message : String(error), + ); + } finally { + setResourcePanelUploading(false); + } + }, + [projectPath, reloadManifestAfterAssetCommand], + ); + const applyResourceCanvasLayoutSnapshot = useCallback( (snapshot: ResourceCanvasLayoutSnapshot) => { resolveResourceCanvasRestoreEntries( @@ -4926,6 +5092,19 @@ export default function ProjectDevelopmentView({ ) : null} {mode === 'resources' && !uiEditorRoute ? ( <> + {pendingResourceEdits.length > 0 || pendingResourceEditsLoadState === 'failed' ? (