接入素材删除入口并收敛资源命令后的 manifest 重载
- ResourceClassificationPanel 增加删除动作:首次点击进入确认态,二次点击才执行,避免误触不可逆操作 - 面板标题与按钮明确写出只移除登记、素材文件保留在磁盘 - 删除失败时把客户端的拒绝原因原样呈现(例如已被运行槽位绑定),并退出确认态 - index.tsx 抽出 reloadManifestAfterAssetCommand:资源分类更新与素材删除共用同一条重载路径,提交标识分别用 asset-classification / asset-delete 前缀 - 补 2 条面板测试:两次点击才删除且入参带 expectedProjectRevision、被运行槽位拒绝时不触发 onDeleted
This commit is contained in:
+75
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user