From 33ed85ba5430dd1ec47a8337d7324b7a9583c6a6 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 14:22:38 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E7=B4=A0=E6=9D=90=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=8B=AC=E7=AB=8B=E7=A1=AE=E8=AE=A4=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8B=BE=E9=80=89=E8=BF=9E?= =?UTF-8?q?=E5=B8=A6=E5=88=A0=E9=99=A4=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ResourceAssetDeleteDialog:列出使用该素材的游戏版本(标识 + 创建时间),给出「把相关游戏版本一并删除」勾选,默认不勾 - ResourceClassificationPanel 删除入口改为先读 read_local_project_asset_references,再打开独立确认弹窗,不再用面板内二次确认态 - 确认删除时按勾选状态传 deleteReferencedVersions;未被任何版本引用时不显示连带删除勾选 - styles.css 补删除弹窗、版本列表与勾选行样式 - 重写并扩充 resourceClassificationPanel.test.tsx 的删除用例:确认弹窗才发起删除、未引用时不出现勾选、列出被引用版本且默认不勾、勾选后传 true、客户端拒绝时不报告删除 --- apps/ai-game-creator-shell/src/styles.css | 56 ++++++ .../ResourceAssetDeleteDialog.tsx | 111 ++++++++++++ .../ResourceClassificationPanel.tsx | 72 ++++++-- .../resourceClassificationPanel.test.tsx | 169 ++++++++++++++++-- 4 files changed, 380 insertions(+), 28 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 9660713d2..e0b6de6f2 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6135,6 +6135,62 @@ iframe.preview-frame { font-size: 11px; } +.game-resource-delete-dialog { + width: min(460px, 100%); +} + +.game-resource-delete-dialog > footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.game-resource-delete-body { + display: grid; + gap: 10px; +} + +.game-resource-delete-body .game-resource-delete-summary { + margin: 0; + color: #6f5247; + font-size: 12px; +} + +.game-resource-delete-versions { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + max-height: 180px; + overflow: auto; + list-style: none; +} + +.game-resource-delete-versions li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border: 1px solid #f0ddd4; + border-radius: 10px; + background: #fff; + font-size: 11px; +} + +.game-resource-delete-versions small { + color: #9a7d70; + font-size: 11px; +} + +.game-resource-delete-option { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + .game-resource-live-notice { display: flex; align-items: center; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx new file mode 100644 index 000000000..0c86df9a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx @@ -0,0 +1,111 @@ +import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; +import { ThemedModal } from '../../components/modal/ThemedModal'; + +export type ResourceAssetReferenceVersion = { + versionId: string; + projectRevision: number; + createdAt: number; +}; + +function referenceVersionTimeLabel(createdAt: number) { + if (!Number.isSafeInteger(createdAt) || createdAt < 0) return '时间未知'; + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(createdAt * 1000)); +} + +type ResourceAssetDeleteDialogProps = { + open: boolean; + localPath: string; + referencedVersions: readonly ResourceAssetReferenceVersion[]; + deleteReferencedVersions: boolean; + deleting: boolean; + onChangeDeleteReferencedVersions: (value: boolean) => void; + onClose: () => void; + onConfirm: () => void; +}; + +/** + * 素材删除确认面板。被版本引用时列出引用它的版本,并给出「把相关游戏版本一并删除」勾选, + * 默认不勾:只删素材,版本保留悬空绑定。 + */ +export function ResourceAssetDeleteDialog({ + open, + localPath, + referencedVersions, + deleteReferencedVersions, + deleting, + onChangeDeleteReferencedVersions, + onClose, + onConfirm, +}: ResourceAssetDeleteDialogProps) { + return ( + +
+
+

删除资源

+

{localPath}

+
+ +
+ {referencedVersions.length > 0 ? ( +
+

+ {`被 ${referencedVersions.length} 个游戏版本使用`} +

+
    + {referencedVersions.map((version) => ( +
  • + {version.versionId} + {referenceVersionTimeLabel(version.createdAt)} +
  • + ))} +
+ +
+ ) : null} +
+ + 取消 + + + 删除 + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx index ebb1a1111..6dc83a914 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx @@ -13,6 +13,10 @@ import { } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { ThemedModal } from '../../components/modal/ThemedModal'; import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences'; +import { + ResourceAssetDeleteDialog, + type ResourceAssetReferenceVersion, +} from './ResourceAssetDeleteDialog'; type UpdateLocalProjectResourceClassificationResult = { asset: GameCreationAppAssetManifestEntry; @@ -26,6 +30,11 @@ type DeleteLocalProjectAssetResult = { fileRetained: boolean; }; +type ReadLocalProjectAssetReferencesResult = { + assetId: string; + versions: ResourceAssetReferenceVersion[]; +}; + const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS = GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({ id: category, @@ -73,8 +82,14 @@ export function ResourceClassificationPanel({ gameCreationAppAssetTags(asset).join('、'), ); const [saving, setSaving] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteDialogPreparing, setDeleteDialogPreparing] = useState(false); + const [deleteReferencedVersions, setDeleteReferencedVersions] = + useState(false); + const [referencedVersions, setReferencedVersions] = useState< + readonly ResourceAssetReferenceVersion[] + >([]); const [deleting, setDeleting] = useState(false); - const [deleteArmed, setDeleteArmed] = useState(false); const [error, setError] = useState(null); async function saveResourceClassification() { @@ -116,15 +131,32 @@ export function ResourceClassificationPanel({ } /** - * 删除素材登记。素材不可变:只摘掉 manifest 登记,磁盘文件保留。 - * 第一次点击只进入确认态,避免误触这个不可逆操作。 + * 点删除先读引用信息,再打开独立确认面板。素材不可变:只摘掉 manifest 登记,磁盘文件保留。 */ - async function deleteResourceClassificationAsset() { - if (!deleteArmed) { - setDeleteArmed(true); - setError(null); + async function openDeleteResourceDialog() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setError('删除资源需要在客户端内执行'); return; } + setDeleteDialogPreparing(true); + setError(null); + try { + const references = await invoke( + 'read_local_project_asset_references', + { input: { projectPath, assetId: asset.id } }, + ); + setReferencedVersions(references.versions); + setDeleteReferencedVersions(false); + setDeleteDialogOpen(true); + } catch (referenceError) { + setError(resourceDeleteErrorMessage(referenceError)); + } finally { + setDeleteDialogPreparing(false); + } + } + + async function confirmDeleteResource() { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { setError('删除资源需要在客户端内执行'); @@ -148,12 +180,13 @@ export function ResourceClassificationPanel({ expectedProjectId: projectId, expectedProjectRevision: status.revision, assetId: asset.id, + deleteReferencedVersions, }, }, ); + setDeleteDialogOpen(false); onDeleted(result); } catch (deleteError) { - setDeleteArmed(false); setError(resourceDeleteErrorMessage(deleteError)); } finally { setDeleting(false); @@ -200,14 +233,11 @@ export function ResourceClassificationPanel({
void deleteResourceClassificationAsset()} - disabled={saving || deleting} - aria-label={ - deleteArmed ? '确认删除资源登记' : '删除资源登记(保留素材文件)' - } - title="只移除项目里的资源登记,素材文件保留在磁盘上" + onClick={() => void openDeleteResourceDialog()} + disabled={saving || deleting || deleteDialogPreparing} + aria-label="删除资源" > - {deleteArmed ? '确认删除(文件保留)' : '删除'} + 删除
+ {deleteDialogOpen ? ( + setDeleteDialogOpen(false)} + onConfirm={() => void confirmDeleteResource()} + /> + ) : null} ); } diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index ffa60ca67..d4cfad5a9 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -152,10 +152,13 @@ describe('ResourceClassificationPanel', () => { expect(onSaved).not.toHaveBeenCalled(); }); - test('deletes the asset registration only after an explicit confirmation', async () => { + test('deletes the asset registration only after the confirmation panel is submitted', async () => { const user = userEvent.setup(); const onDeleted = vi.fn(); const invoke = installInvoke(async (command) => { + if (command === 'read_local_project_asset_references') { + return { assetId: 'asset-hero', versions: [] }; + } if (command === 'get_local_game_project_revision') { return { revision: 9 }; } @@ -181,18 +184,24 @@ describe('ResourceClassificationPanel', () => { />, ); - // 第一次点击只进入确认态,不得直接发起删除。 - await user.click( - screen.getByRole('button', { name: '删除资源登记(保留素材文件)' }), - ); + // 删除入口只打开确认面板,不得直接发起删除。 + await user.click(screen.getByRole('button', { name: '删除资源' })); + await screen.findByRole('dialog', { name: '确认删除资源' }); expect(onDeleted).not.toHaveBeenCalled(); + expect(invoke).toHaveBeenCalledWith('read_local_project_asset_references', { + input: { projectPath: 'C:/project', assetId: 'asset-hero' }, + }); expect( invoke.mock.calls.some( ([command]) => command === 'delete_local_project_asset', ), ).toBe(false); + // 未被任何版本引用时不出现连带删除勾选。 + expect( + screen.queryByRole('checkbox', { name: '把相关游戏版本一并删除' }), + ).toBeNull(); - await user.click(screen.getByRole('button', { name: '确认删除资源登记' })); + await user.click(screen.getByRole('button', { name: '确认删除资源' })); await waitFor(() => { expect(onDeleted).toHaveBeenCalledTimes(1); @@ -203,6 +212,7 @@ describe('ResourceClassificationPanel', () => { expectedProjectId: 'project-1', expectedProjectRevision: 9, assetId: 'asset-hero', + deleteReferencedVersions: false, }, }); expect(onDeleted.mock.calls[0]?.[0]).toEqual({ @@ -213,14 +223,39 @@ describe('ResourceClassificationPanel', () => { }); }); - test('surfaces the runtime rejection when the asset is still bound to a slot', async () => { + test('lists the versions using the asset and keeps the cascade option unchecked', async () => { const user = userEvent.setup(); const onDeleted = vi.fn(); - installInvoke(async (command) => { + const invoke = installInvoke(async (command) => { + if (command === 'read_local_project_asset_references') { + return { + assetId: 'asset-hero', + versions: [ + { + versionId: 'initial-1', + projectRevision: 1, + createdAt: 1_760_000_000, + }, + { + versionId: 'agent-4', + projectRevision: 4, + createdAt: 1_760_003_600, + }, + ], + }; + } if (command === 'get_local_game_project_revision') { return { revision: 9 }; } - throw '素材 asset-hero 已被版本 initial-1 的运行槽位 hero 绑定,不能删除'; + if (command === 'delete_local_project_asset') { + return { + assetId: 'asset-hero', + localPath: 'assets/hero.png', + committedProjectRevision: 10, + fileRetained: true, + }; + } + throw new Error(`unexpected command: ${command}`); }); render( @@ -234,13 +269,121 @@ describe('ResourceClassificationPanel', () => { />, ); - await user.click( - screen.getByRole('button', { name: '删除资源登记(保留素材文件)' }), + await user.click(screen.getByRole('button', { name: '删除资源' })); + await screen.findByRole('dialog', { name: '确认删除资源' }); + expect(screen.getByText('被 2 个游戏版本使用')).toBeTruthy(); + expect(screen.getByText('initial-1')).toBeTruthy(); + expect(screen.getByText('agent-4')).toBeTruthy(); + + const cascade = screen.getByRole('checkbox', { + name: '把相关游戏版本一并删除', + }); + expect((cascade as HTMLInputElement).checked).toBe(false); + + await user.click(screen.getByRole('button', { name: '确认删除资源' })); + + await waitFor(() => { + expect(onDeleted).toHaveBeenCalledTimes(1); + }); + expect(invoke).toHaveBeenCalledWith( + 'delete_local_project_asset', + expect.objectContaining({ + input: expect.objectContaining({ deleteReferencedVersions: false }), + }), ); - await user.click(screen.getByRole('button', { name: '确认删除资源登记' })); + }); + + test('requests the cascade delete only when the user checks the option', async () => { + const user = userEvent.setup(); + const onDeleted = vi.fn(); + const invoke = installInvoke(async (command) => { + if (command === 'read_local_project_asset_references') { + return { + assetId: 'asset-hero', + versions: [ + { + versionId: 'initial-1', + projectRevision: 1, + createdAt: 1_760_000_000, + }, + ], + }; + } + if (command === 'get_local_game_project_revision') { + return { revision: 9 }; + } + if (command === 'delete_local_project_asset') { + return { + assetId: 'asset-hero', + localPath: 'assets/hero.png', + committedProjectRevision: 10, + fileRetained: true, + }; + } + throw new Error(`unexpected command: ${command}`); + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: '删除资源' })); + await screen.findByRole('dialog', { name: '确认删除资源' }); + await user.click( + screen.getByRole('checkbox', { name: '把相关游戏版本一并删除' }), + ); + await user.click(screen.getByRole('button', { name: '确认删除资源' })); + + await waitFor(() => { + expect(onDeleted).toHaveBeenCalledTimes(1); + }); + expect(invoke).toHaveBeenCalledWith( + 'delete_local_project_asset', + expect.objectContaining({ + input: expect.objectContaining({ deleteReferencedVersions: true }), + }), + ); + }); + + test('surfaces the native rejection without reporting a delete', async () => { + const user = userEvent.setup(); + const onDeleted = vi.fn(); + installInvoke(async (command) => { + if (command === 'read_local_project_asset_references') { + return { assetId: 'asset-hero', versions: [] }; + } + if (command === 'get_local_game_project_revision') { + return { revision: 9 }; + } + throw 'project-revision-conflict'; + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: '删除资源' })); + await screen.findByRole('dialog', { name: '确认删除资源' }); + await user.click(screen.getByRole('button', { name: '确认删除资源' })); await screen.findByRole('alert'); - expect(screen.getByRole('alert').textContent).toContain('运行槽位'); + expect(screen.getByRole('alert').textContent).toContain( + 'project-revision-conflict', + ); expect(onDeleted).not.toHaveBeenCalled(); }); });