接入素材删除入口并收敛资源命令后的 manifest 重载

- ResourceClassificationPanel 增加删除动作:首次点击进入确认态,二次点击才执行,避免误触不可逆操作
- 面板标题与按钮明确写出只移除登记、素材文件保留在磁盘
- 删除失败时把客户端的拒绝原因原样呈现(例如已被运行槽位绑定),并退出确认态
- index.tsx 抽出 reloadManifestAfterAssetCommand:资源分类更新与素材删除共用同一条重载路径,提交标识分别用 asset-classification / asset-delete 前缀
- 补 2 条面板测试:两次点击才删除且入参带 expectedProjectRevision、被运行槽位拒绝时不触发 onDeleted
This commit is contained in:
2026-09-10 10:53:13 +08:00
parent 16748821e3
commit e1044546ad
3 changed files with 202 additions and 9 deletions
@@ -19,6 +19,13 @@ type UpdateLocalProjectResourceClassificationResult = {
committedProjectRevision: number;
};
type DeleteLocalProjectAssetResult = {
assetId: string;
localPath: string;
committedProjectRevision: number;
fileRetained: boolean;
};
const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS =
GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({
id: category,
@@ -36,12 +43,19 @@ function resourceClassificationErrorMessage(error: unknown) {
return '保存资源分类与标签失败';
}
function resourceDeleteErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '删除资源失败';
}
type ResourceClassificationPanelProps = {
projectPath: string;
projectId: string;
asset: GameCreationAppAssetManifestEntry;
onClose: () => void;
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
onDeleted: (result: DeleteLocalProjectAssetResult) => void;
};
export function ResourceClassificationPanel({
@@ -50,6 +64,7 @@ export function ResourceClassificationPanel({
asset,
onClose,
onSaved,
onDeleted,
}: ResourceClassificationPanelProps) {
const [category, setCategory] = useState<GameCreationAppAssetCategory>(() =>
gameCreationAppAssetCategory(asset),
@@ -58,6 +73,8 @@ export function ResourceClassificationPanel({
gameCreationAppAssetTags(asset).join('、'),
);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteArmed, setDeleteArmed] = useState(false);
const [error, setError] = useState<string | null>(null);
async function saveResourceClassification() {
@@ -98,6 +115,51 @@ export function ResourceClassificationPanel({
}
}
/**
* 删除素材登记。素材不可变:只摘掉 manifest 登记,磁盘文件保留。
* 第一次点击只进入确认态,避免误触这个不可逆操作。
*/
async function deleteResourceClassificationAsset() {
if (!deleteArmed) {
setDeleteArmed(true);
setError(null);
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('删除资源需要在客户端内执行');
return;
}
setDeleting(true);
setError(null);
try {
const status = await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath },
);
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
const result = await invoke<DeleteLocalProjectAssetResult>(
'delete_local_project_asset',
{
input: {
projectPath,
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
},
},
);
onDeleted(result);
} catch (deleteError) {
setDeleteArmed(false);
setError(resourceDeleteErrorMessage(deleteError));
} finally {
setDeleting(false);
}
}
return (
<ThemedModal
open
@@ -136,16 +198,27 @@ export function ResourceClassificationPanel({
) : null}
</div>
<footer>
<PlatformActionButton
tone="danger"
onClick={() => void deleteResourceClassificationAsset()}
disabled={saving || deleting}
aria-label={
deleteArmed ? '确认删除资源登记' : '删除资源登记(保留素材文件)'
}
title="只移除项目里的资源登记,素材文件保留在磁盘上"
>
{deleteArmed ? '确认删除(文件保留)' : '删除'}
</PlatformActionButton>
<PlatformActionButton
tone="secondary"
onClick={onClose}
disabled={saving}
disabled={saving || deleting}
>
取消
</PlatformActionButton>
<PlatformActionButton
onClick={() => void saveResourceClassification()}
disabled={saving}
disabled={saving || deleting}
>
保存
</PlatformActionButton>
@@ -2193,11 +2193,12 @@ export default function ProjectDevelopmentView({
: null,
[manifest.assets, resourceClassificationAssetId],
);
const handleResourceClassificationSaved = useCallback(
async (result: {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
}) => {
/**
* 资源分类更新与素材删除都只改 manifest,成功后重读一次并按新 revision 投影;
* 两者共用同一条重载路径,避免出现两份略有差异的刷新逻辑。
*/
const reloadManifestAfterAssetCommand = useCallback(
async (committedProjectRevision: number, commitId: string) => {
setResourceClassificationAssetId(null);
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !onManifestChange) return;
@@ -2209,9 +2210,9 @@ export default function ProjectDevelopmentView({
if (next.projectId !== manifest.projectId) return;
onManifestChange(projectPath, next, {
projectId: next.projectId,
revision: result.committedProjectRevision,
revision: committedProjectRevision,
source: 'asset-command',
commitId: `asset-classification:${result.asset.id}`,
commitId,
});
} catch (error) {
setResourceWorkbenchNotice(
@@ -2221,6 +2222,27 @@ export default function ProjectDevelopmentView({
},
[manifest.projectId, onManifestChange, projectPath],
);
const handleResourceClassificationSaved = useCallback(
async (result: {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
}) => {
await reloadManifestAfterAssetCommand(
result.committedProjectRevision,
`asset-classification:${result.asset.id}`,
);
},
[reloadManifestAfterAssetCommand],
);
const handleResourceClassificationDeleted = useCallback(
async (result: { assetId: string; committedProjectRevision: number }) => {
await reloadManifestAfterAssetCommand(
result.committedProjectRevision,
`asset-delete:${result.assetId}`,
);
},
[reloadManifestAfterAssetCommand],
);
const focusedResourceDependencyDetails = useMemo(() => {
if (!focusedResource) {
return {
@@ -5799,6 +5821,9 @@ export default function ProjectDevelopmentView({
asset={resourceClassificationAsset}
onClose={() => setResourceClassificationAssetId(null)}
onSaved={(result) => void handleResourceClassificationSaved(result)}
onDeleted={(result) =>
void handleResourceClassificationDeleted(result)
}
/>
) : null}
{resourceRecoveryPanelOpen ? (
@@ -65,6 +65,7 @@ describe('ResourceClassificationPanel', () => {
asset={asset}
onClose={vi.fn()}
onSaved={onSaved}
onDeleted={vi.fn()}
/>,
);
@@ -119,6 +120,7 @@ describe('ResourceClassificationPanel', () => {
asset={asset}
onClose={vi.fn()}
onSaved={onSaved}
onDeleted={vi.fn()}
/>,
);
@@ -139,6 +141,7 @@ describe('ResourceClassificationPanel', () => {
asset={asset}
onClose={vi.fn()}
onSaved={onSaved}
onDeleted={vi.fn()}
/>,
);
@@ -148,4 +151,96 @@ describe('ResourceClassificationPanel', () => {
expect(screen.getByRole('alert').textContent).toContain('客户端');
expect(onSaved).not.toHaveBeenCalled();
});
test('deletes the asset registration only after an explicit confirmation', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
const invoke = installInvoke(async (command) => {
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(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
);
// 第一次点击只进入确认态,不得直接发起删除。
await user.click(
screen.getByRole('button', { name: '删除资源登记(保留素材文件)' }),
);
expect(onDeleted).not.toHaveBeenCalled();
expect(
invoke.mock.calls.some(
([command]) => command === 'delete_local_project_asset',
),
).toBe(false);
await user.click(screen.getByRole('button', { name: '确认删除资源登记' }));
await waitFor(() => {
expect(onDeleted).toHaveBeenCalledTimes(1);
});
expect(invoke).toHaveBeenCalledWith('delete_local_project_asset', {
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 9,
assetId: 'asset-hero',
},
});
expect(onDeleted.mock.calls[0]?.[0]).toEqual({
assetId: 'asset-hero',
localPath: 'assets/hero.png',
committedProjectRevision: 10,
fileRetained: true,
});
});
test('surfaces the runtime rejection when the asset is still bound to a slot', async () => {
const user = userEvent.setup();
const onDeleted = vi.fn();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 9 };
}
throw '素材 asset-hero 已被版本 initial-1 的运行槽位 hero 绑定,不能删除';
});
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={vi.fn()}
onDeleted={onDeleted}
/>,
);
await user.click(
screen.getByRole('button', { name: '删除资源登记(保留素材文件)' }),
);
await user.click(screen.getByRole('button', { name: '确认删除资源登记' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('运行槽位');
expect(onDeleted).not.toHaveBeenCalled();
});
});