资源画布工具条接回下载按钮:走资源面板同一条真链路并收紧放行判据
- 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 原生对话框,只替代用户选择目标路径这一步;放别处注册会晚于真实模块实例化
This commit is contained in:
+10
-3
@@ -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<ImageCanvasSelectedToolbarAction> {
|
||||
// 判据只有一条:**放进来就必须真能跑通**。工具条按这个集合渲染按钮,未接通的动作
|
||||
// 一律不渲染,避免出现"点了没反应"的假按钮。当前真实接通链路的只有「快速编辑」
|
||||
// (normalize → derive(editKind='image-reference'));「改造」(redraw) 与
|
||||
// 「角色动画」(character-animation) 在宿主编排层仍是空回调,接上真实链路后再放回来。
|
||||
// 一律不渲染,避免出现"点了没反应"的假按钮。当前真实接通的只有「快速编辑」
|
||||
// (normalize → derive(editKind='image-reference'))与「下载」(复用资源面板同一条
|
||||
// 本地文件复制链路);「改造」(redraw) 与「角色动画」(character-animation) 在宿主
|
||||
// 编排层仍是空回调,接上真实链路后再放回来。
|
||||
const supported = new Set<ImageCanvasSelectedToolbarAction>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<ProjectResource> = {},
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user