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)} + + ))} + + + + onChangeDeleteReferencedVersions(event.currentTarget.checked) + } + /> + 把相关游戏版本一并删除 + + + ) : 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({ + {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(); }); });
{localPath}
+ {`被 ${referencedVersions.length} 个游戏版本使用`} +