Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx
T
suzmii 33ed85ba54 AGC 素材删除改为独立确认弹窗并支持勾选连带删除版本
- 新增 ResourceAssetDeleteDialog:列出使用该素材的游戏版本(标识 + 创建时间),给出「把相关游戏版本一并删除」勾选,默认不勾
- ResourceClassificationPanel 删除入口改为先读 read_local_project_asset_references,再打开独立确认弹窗,不再用面板内二次确认态
- 确认删除时按勾选状态传 deleteReferencedVersions;未被任何版本引用时不显示连带删除勾选
- styles.css 补删除弹窗、版本列表与勾选行样式
- 重写并扩充 resourceClassificationPanel.test.tsx 的删除用例:确认弹窗才发起删除、未引用时不出现勾选、列出被引用版本且默认不勾、勾选后传 true、客户端拒绝时不报告删除
2026-09-10 14:22:38 +08:00

271 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import {
GAME_CREATION_APP_ASSET_CATEGORIES,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
normalizeGameCreationAppAssetTags,
} 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;
committedProjectRevision: number;
};
type DeleteLocalProjectAssetResult = {
assetId: string;
localPath: string;
committedProjectRevision: number;
fileRetained: boolean;
};
type ReadLocalProjectAssetReferencesResult = {
assetId: string;
versions: ResourceAssetReferenceVersion[];
};
const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS =
GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({
id: category,
label: resourceReferenceCategoryLabel(category),
}));
/** 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。 */
function splitResourceClassificationTagsDraft(value: string) {
return normalizeGameCreationAppAssetTags(value.split(/[,,、\n]/u));
}
function resourceClassificationErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
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({
projectPath,
projectId,
asset,
onClose,
onSaved,
onDeleted,
}: ResourceClassificationPanelProps) {
const [category, setCategory] = useState<GameCreationAppAssetCategory>(() =>
gameCreationAppAssetCategory(asset),
);
const [tagsDraft, setTagsDraft] = useState(() =>
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 [error, setError] = useState<string | null>(null);
async function saveResourceClassification() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('资源分类与标签需要在客户端内保存');
return;
}
setSaving(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<UpdateLocalProjectResourceClassificationResult>(
'update_local_project_resource_classification',
{
input: {
projectPath,
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
category,
tags: splitResourceClassificationTagsDraft(tagsDraft),
},
},
);
onSaved(result);
} catch (saveError) {
setError(resourceClassificationErrorMessage(saveError));
} finally {
setSaving(false);
}
}
/**
* 点删除先读引用信息,再打开独立确认面板。素材不可变:只摘掉 manifest 登记,磁盘文件保留。
*/
async function openDeleteResourceDialog() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('删除资源需要在客户端内执行');
return;
}
setDeleteDialogPreparing(true);
setError(null);
try {
const references = await invoke<ReadLocalProjectAssetReferencesResult>(
'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('删除资源需要在客户端内执行');
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,
deleteReferencedVersions,
},
},
);
setDeleteDialogOpen(false);
onDeleted(result);
} catch (deleteError) {
setError(resourceDeleteErrorMessage(deleteError));
} finally {
setDeleting(false);
}
}
return (
<ThemedModal
open
ariaLabel="资源分类与标签"
onClose={onClose}
panelClassName="game-approval-dialog game-resource-classification-dialog"
>
<header>
<div>
<h2>资源分类与标签</h2>
<p>{asset.localPath}</p>
</div>
<button type="button" aria-label="关闭资源分类与标签" onClick={onClose}>
×
</button>
</header>
<div className="game-resource-classification-body">
<PlatformSegmentedTabs
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
activeId={category}
onChange={setCategory}
columns="threeToSix"
gap="sm"
size="compact"
/>
<PlatformTextField
aria-label="资源标签"
placeholder="用、或逗号分隔标签"
value={tagsDraft}
onChange={(event) => setTagsDraft(event.currentTarget.value)}
/>
{error ? (
<p className="game-resource-classification-error" role="alert">
{error}
</p>
) : null}
</div>
<footer>
<PlatformActionButton
tone="danger"
onClick={() => void openDeleteResourceDialog()}
disabled={saving || deleting || deleteDialogPreparing}
aria-label="删除资源"
>
删除
</PlatformActionButton>
<PlatformActionButton
tone="secondary"
onClick={onClose}
disabled={saving || deleting}
>
取消
</PlatformActionButton>
<PlatformActionButton
onClick={() => void saveResourceClassification()}
disabled={saving || deleting}
>
保存
</PlatformActionButton>
</footer>
{deleteDialogOpen ? (
<ResourceAssetDeleteDialog
open
localPath={asset.localPath}
referencedVersions={referencedVersions}
deleteReferencedVersions={deleteReferencedVersions}
deleting={deleting}
onChangeDeleteReferencedVersions={setDeleteReferencedVersions}
onClose={() => setDeleteDialogOpen(false)}
onConfirm={() => void confirmDeleteResource()}
/>
) : null}
</ThemedModal>
);
}