From 8f96b05f6e5815b746b0fa56a57748b465bece92 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 18:05:15 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=9D=A1=E6=8E=A5=E5=9B=9E=E4=B8=8B=E8=BD=BD=E6=8C=89?= =?UTF-8?q?=E9=92=AE=EF=BC=9A=E8=B5=B0=E8=B5=84=E6=BA=90=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=9D=A1=E7=9C=9F=E9=93=BE=E8=B7=AF=E5=B9=B6?= =?UTF-8?q?=E6=94=B6=E7=B4=A7=E6=94=BE=E8=A1=8C=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.tsx 把资源面板的保存流程抽成共用 saveProjectResourcesToDisk(原生保存对话框 + save_local_project_asset_file),资源面板改为委派调用,不再有第二份保存实现 - index.tsx 工具条 onDownloadLayer 由空回调改为调用该共用 handler,回执写进 resourceWorkbenchNotice;不可导出的资源仍直接返回 - resourceCanvasToolbarModel.ts 把 download 加回 supportedActions,判据是 isResourceCanvasExportable(拿不到本地文件路径就不渲染),不按媒体类型放行 - resourceCanvasToolbarModel.test.ts 期望值随判据重算为 [quick-edit, download] 或 [download],并补 download 放行与拒绝两侧用例(空 path、虚拟版本条目) - project-development.suite.ts 音频工具条按钮集合断言更新为 [分类与标签, 重命名, 下载按钮],并新增两条真链路断言:工具条下载打到 save_local_project_asset_file、资源面板下载仍打到同一命令 - appSurface.test.ts 在入口文件 mock 原生对话框,只替代用户选择目标路径这一步;放别处注册会晚于真实模块实例化 --- .../resourceCanvasToolbarModel.ts | 13 ++- .../src/view/project-development/index.tsx | 51 +++++++++--- .../tests/appSurface.test.ts | 16 ++++ .../appSurface/project-development.suite.ts | 46 ++++++++++- .../tests/resourceCanvasToolbarModel.test.ts | 80 +++++++++++++++---- 5 files changed, 177 insertions(+), 29 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts index 9249f2922..ec8f42913 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts @@ -7,6 +7,7 @@ import type { import type { ImageCanvasSelectedToolbarAction } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; import { canonicalProjectedResourceMediaType } from '../../view/project-development/resourceEditModel'; import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; +import { isResourceCanvasExportable } from './resourceCanvasAssetTransferModel'; /** 快速编辑与改造都走 `derive_local_project_resource`,只接受这三种栅格图片格式。 */ const RESOURCE_RASTER_MEDIA_TYPES = new Set([ @@ -102,9 +103,10 @@ export function resolveResourceCanvasToolbarActions( resource: ProjectResource, ): ReadonlySet { // 判据只有一条:**放进来就必须真能跑通**。工具条按这个集合渲染按钮,未接通的动作 - // 一律不渲染,避免出现"点了没反应"的假按钮。当前真实接通链路的只有「快速编辑」 - // (normalize → derive(editKind='image-reference'));「改造」(redraw) 与 - // 「角色动画」(character-animation) 在宿主编排层仍是空回调,接上真实链路后再放回来。 + // 一律不渲染,避免出现"点了没反应"的假按钮。当前真实接通的只有「快速编辑」 + // (normalize → derive(editKind='image-reference'))与「下载」(复用资源面板同一条 + // 本地文件复制链路);「改造」(redraw) 与「角色动画」(character-animation) 在宿主 + // 编排层仍是空回调,接上真实链路后再放回来。 const supported = new Set(); const mediaType = resourceCanvasMediaType(resource); const canDeriveFromResource = @@ -115,6 +117,11 @@ export function resolveResourceCanvasToolbarActions( supported.add('quick-edit'); } } + // 「下载」的判据不是媒体类型,而是"拿得到可落盘的本地文件路径":虚拟版本条目没有 + // 文件,放行只会得到一个点了报错的按钮。 + if (isResourceCanvasExportable(resource)) { + supported.add('download'); + } return supported; } 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 6ccbb4fa4..c124d4f71 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 @@ -91,6 +91,7 @@ import { import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { defaultResourceExportFileName, + isResourceCanvasExportable, isUploadableResourceFile, resolveResourceCanvasPanelEntries, resolveSelectedPanelEntries, @@ -3449,16 +3450,25 @@ export default function ProjectDevelopmentView({ resourcePanelOpen, ]); - const downloadResourcePanelEntries = useCallback( - async (targets: typeof resourcePanelEntries) => { + /** + * 资源落盘的唯一实现:原生保存对话框选目标路径,再走 Rust 分块复制。 + * + * 资源面板的「下载」与画布工具条的「下载按钮」都复用这一条链路;新增下载入口时 + * 只传目标与提示通道,不要再写第二份保存实现。 + */ + const saveProjectResourcesToDisk = useCallback( + async ( + targets: ReadonlyArray<{ path: string; label: string }>, + onNotice: (message: string) => void, + ) => { const invoke = window.__TAURI__?.core?.invoke; if (!invoke || targets.length === 0) { if (!invoke) { - setResourcePanelNotice('保存素材需要在客户端内打开'); + onNotice('保存素材需要在客户端内打开'); } return; } - setResourcePanelNotice(''); + onNotice(''); let savedCount = 0; for (const [index, entry] of targets.entries()) { const destination = await saveNativeFileDialog({ @@ -3483,21 +3493,26 @@ export default function ProjectDevelopmentView({ }, }); savedCount += 1; - setResourcePanelNotice(`已保存 ${result.destinationPath}`); + onNotice(`已保存 ${result.destinationPath}`); } catch (error) { - setResourcePanelNotice( - error instanceof Error ? error.message : String(error), - ); + onNotice(error instanceof Error ? error.message : String(error)); break; } } if (savedCount > 1) { - setResourcePanelNotice(`已保存 ${savedCount} 个素材`); + onNotice(`已保存 ${savedCount} 个素材`); } }, [projectPath], ); + const downloadResourcePanelEntries = useCallback( + async (targets: typeof resourcePanelEntries) => { + await saveProjectResourcesToDisk(targets, setResourcePanelNotice); + }, + [saveProjectResourcesToDisk], + ); + const uploadResourcePanelFiles = useCallback( async (files: FileList) => { const invoke = window.__TAURI__?.core?.invoke; @@ -5522,7 +5537,23 @@ export default function ProjectDevelopmentView({ onSplitIconSpritesheet={() => {}} onExtractUiDesignAssets={() => {}} onOpenCharacterAnimationPanel={() => {}} - onDownloadLayer={() => {}} + onDownloadLayer={() => { + if ( + !selectedResource || + !isResourceCanvasExportable(selectedResource) + ) { + return; + } + void saveProjectResourcesToDisk( + [ + { + path: selectedResource.path, + label: selectedResource.label, + }, + ], + setResourceWorkbenchNotice, + ); + }} /> ) : null} {quickEditPanel && quickEditPanelStyle diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 5b9ab4807..bd12efdae 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1,4 +1,6 @@ /** @vitest-environment jsdom */ +import { vi } from 'vitest'; + import { registerAgentRuntimeCommandTests } from './appSurface/agent-runtime.suite'; import { registerAuthTests } from './appSurface/auth.suite'; import { @@ -33,6 +35,20 @@ import { } from './appSurface/runtime-settings.suite'; import { registerSupervisorRuntimeTests } from './appSurface/supervisor-runtime.suite'; +/** + * 原生文件对话框是宿主能力,不能在 jsdom 里真开窗。 + * + * 这里只替代"用户选了哪个目标路径"这一件事:`save` 按调用方给的建议文件名返回一个 + * 固定的导出路径,让「下载」用例能断言工具条真的走到了 `save_local_project_asset_file`。 + * 声明必须留在入口文件:`project-assets.suite` 等模块比 `project-development.suite` + * 更早 import 工作台视图,放在别处注册会晚于真实模块实例化。 + */ +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: async () => null, + save: async (options?: { defaultPath?: string }) => + options?.defaultPath ? `/tmp/native-export/${options.defaultPath}` : null, +})); + describe('AI 游戏创作 App 界面边界', () => { registerProjectWorkbenchFoundationTests(); registerAuthTests(); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index cd2835fe3..fc7539bb3 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2186,6 +2186,13 @@ export function registerProjectWorkbenchFoundationTests() { dataUrl: 'data:audio/mpeg;base64,SUQz', }; } + if (command === 'save_local_project_asset_file') { + const input = args?.input as { destinationPath?: string } | undefined; + return { + destinationPath: input?.destinationPath ?? '', + byteLen: 1024, + }; + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -2237,7 +2244,8 @@ export function registerProjectWorkbenchFoundationTests() { ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), // 并且只渲染宿主编排层真实接通的动作:「分类与标签」「重命名」已接面板, - // 「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。 + // 「下载按钮」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调, + // 不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { name: '素材工具栏', }); @@ -2248,7 +2256,41 @@ export function registerProjectWorkbenchFoundationTests() { within(audioToolbar) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual(['分类与标签', '重命名']); + ).toEqual(['分类与标签', '重命名', '下载按钮']); + + // 工具条的「下载按钮」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制, + // 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了 + // /tmp/native-export/<建议文件名>",所以这里能钉住完整入参。 + fireEvent.click( + within(audioToolbar).getByRole('button', { name: '下载按钮' }), + ); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('save_local_project_asset_file', { + input: { + projectPath: '/tmp/workbench-resource-media', + relativePath: 'assets/bgm.mp3', + destinationPath: '/tmp/native-export/bgm.mp3', + }, + }); + }); + + // 资源面板的「下载」必须仍在同一条链路上:抽出共用 handler 后两条入口都打到 + // save_local_project_asset_file,而不是各自另写一份保存实现。 + const saveCallCount = () => + invoke.mock.calls.filter( + ([command]) => command === 'save_local_project_asset_file', + ).length; + expect(saveCallCount()).toBe(1); + fireEvent.click(screen.getByRole('button', { name: '资源面板' })); + const resourcePanel = await screen.findByRole('dialog', { + name: '资源面板', + }); + fireEvent.click( + within(resourcePanel).getByRole('button', { name: '下载' }), + ); + await waitFor(() => { + expect(saveCallCount()).toBe(2); + }); await openResourceBookCategory('设计文档'); fireEvent.click(getResourceSelectButton('blocked.md')); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts index abc5efa09..b22925c00 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { resolveResourceCanvasToolbarActions } from '../src/features/resource-canvas/resourceCanvasToolbarModel'; -import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; +import type { + ProjectResource, + ProjectVersionResourceSummary, +} from '../src/view/project-development/resourceProjectionModel'; function createResource( overrides: Partial = {}, @@ -26,6 +29,20 @@ function createResource( }; } +/** 虚拟版本条目:只有版本摘要,没有对应文件。 */ +function createVersionSummary(): ProjectVersionResourceSummary { + return { + versionId: 'version-1', + parentVersionId: null, + projectRevision: 3, + resourceBindings: [], + createdReason: 'initial', + createdAt: 1, + label: '版本 1', + childVersionIds: [], + }; +} + function createManifest() { return createGameCreationAppManifest( 'project-toolbar-actions', @@ -60,15 +77,19 @@ function toolbarActions( * 逐条钉住当前真正接通的动作集合(见 * `docs/project-memory/shared-memory/pitfalls.md` 的「资源画布工具条的『改造』 * 『角色动画』按钮点了没反应」)。 + * + * 当前真实接通的是「快速编辑」(normalize → derive(editKind='image-reference')) + * 与「下载」(资源面板同一条原生保存对话框 + `save_local_project_asset_file` 链路)。 */ describe('resource canvas toolbar actions', () => { - it('已登记 manifest 资产的栅格图片只放行真实接通的快速编辑', () => { + it('已登记 manifest 资产的栅格图片放行快速编辑与下载', () => { expect(toolbarActions(createManifest(), createResource())).toEqual([ 'quick-edit', + 'download', ]); }); - it('音频与视频资源不再放行宿主编排层空回调的改造动作', () => { + it('音频与视频资源不再放行空回调的改造动作,但仍放行下载', () => { const manifest = createManifest(); expect( toolbarActions( @@ -83,7 +104,7 @@ describe('resource canvas toolbar actions', () => { manifestAssetId: 'bgm', }), ), - ).toEqual([]); + ).toEqual(['download']); expect( toolbarActions( manifest, @@ -96,28 +117,28 @@ describe('resource canvas toolbar actions', () => { manifestAssetId: 'intro', }), ), - ).toEqual([]); + ).toEqual(['download']); }); - it('角色资源只放行快速编辑,不再放行空回调的角色动画动作', () => { + it('角色资源只放行快速编辑与下载,不再放行空回调的角色动画动作', () => { expect( toolbarActions( createManifest(), createResource({ id: 'asset:hero-action', subtype: 'character' }), ), - ).toEqual(['quick-edit']); + ).toEqual(['quick-edit', 'download']); }); - it('未登记 manifest 资产且没有已完成任务产物背书的资源不放行任何动作', () => { + it('未登记 manifest 资产且没有已完成任务产物背书的资源只放行下载', () => { expect( toolbarActions( createManifest(), createResource({ manifestAssetId: null, producerTaskId: 'task-art' }), ), - ).toEqual([]); + ).toEqual(['download']); }); - it('只认已完成任务的栅格产物,未完成任务的产物不放行快速编辑', () => { + it('只认已完成任务的栅格产物,未完成任务的产物不放行快速编辑但可下载', () => { const artifactPath = 'assets/hero.png'; const completedManifest = createManifestWithProducingTask( 'completed', @@ -131,7 +152,7 @@ describe('resource canvas toolbar actions', () => { producerTaskId: completedManifest.tasks[0].id, }), ), - ).toEqual(['quick-edit']); + ).toEqual(['quick-edit', 'download']); const runningManifest = createManifestWithProducingTask( 'running', @@ -145,10 +166,10 @@ describe('resource canvas toolbar actions', () => { producerTaskId: runningManifest.tasks[0].id, }), ), - ).toEqual([]); + ).toEqual(['download']); }); - it('非栅格图片不进快速编辑的派生门禁', () => { + it('非栅格图片不进快速编辑的派生门禁,但仍可下载', () => { expect( toolbarActions( createManifest(), @@ -158,6 +179,37 @@ describe('resource canvas toolbar actions', () => { mediaType: 'image/svg+xml', }), ), - ).toEqual([]); + ).toEqual(['download']); + }); + + it('拿得到本地文件路径的资源才放行下载', () => { + expect(toolbarActions(createManifest(), createResource())).toContain( + 'download', + ); + }); + + it('拿不到本地文件路径的资源不放行下载,避免渲染出点了报错的按钮', () => { + const manifest = createManifest(); + expect( + toolbarActions( + manifest, + createResource({ path: '', manifestAssetId: 'virtual' }), + ), + ).not.toContain('download'); + expect( + toolbarActions( + manifest, + createResource({ + id: 'version:1', + category: 'version', + subtype: 'version', + label: '版本 1', + path: '', + mediaType: 'application/vnd.genarrative.project-version+json', + manifestAssetId: null, + version: createVersionSummary(), + }), + ), + ).not.toContain('download'); }); });