WIP: AGC 资源画布与替换改造 V3.0 #316
@@ -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;
|
||||
|
||||
+111
@@ -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 (
|
||||
<ThemedModal
|
||||
open={open}
|
||||
ariaLabel="确认删除资源"
|
||||
onClose={onClose}
|
||||
panelClassName="game-approval-dialog game-resource-delete-dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2>删除资源</h2>
|
||||
<p>{localPath}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭删除资源确认"
|
||||
onClick={onClose}
|
||||
disabled={deleting}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
{referencedVersions.length > 0 ? (
|
||||
<div className="game-resource-delete-body">
|
||||
<p className="game-resource-delete-summary">
|
||||
{`被 ${referencedVersions.length} 个游戏版本使用`}
|
||||
</p>
|
||||
<ul className="game-resource-delete-versions">
|
||||
{referencedVersions.map((version) => (
|
||||
<li key={version.versionId}>
|
||||
<span>{version.versionId}</span>
|
||||
<small>{referenceVersionTimeLabel(version.createdAt)}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="game-resource-delete-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteReferencedVersions}
|
||||
disabled={deleting}
|
||||
onChange={(event) =>
|
||||
onChangeDeleteReferencedVersions(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<span>把相关游戏版本一并删除</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<footer>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
onClick={onClose}
|
||||
disabled={deleting}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="danger"
|
||||
onClick={onConfirm}
|
||||
disabled={deleting}
|
||||
aria-label="确认删除资源"
|
||||
>
|
||||
删除
|
||||
</PlatformActionButton>
|
||||
</footer>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
+57
-15
@@ -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<string | null>(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<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('删除资源需要在客户端内执行');
|
||||
@@ -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({
|
||||
<footer>
|
||||
<PlatformActionButton
|
||||
tone="danger"
|
||||
onClick={() => void deleteResourceClassificationAsset()}
|
||||
disabled={saving || deleting}
|
||||
aria-label={
|
||||
deleteArmed ? '确认删除资源登记' : '删除资源登记(保留素材文件)'
|
||||
}
|
||||
title="只移除项目里的资源登记,素材文件保留在磁盘上"
|
||||
onClick={() => void openDeleteResourceDialog()}
|
||||
disabled={saving || deleting || deleteDialogPreparing}
|
||||
aria-label="删除资源"
|
||||
>
|
||||
{deleteArmed ? '确认删除(文件保留)' : '删除'}
|
||||
删除
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
@@ -223,6 +253,18 @@ export function ResourceClassificationPanel({
|
||||
保存
|
||||
</PlatformActionButton>
|
||||
</footer>
|
||||
{deleteDialogOpen ? (
|
||||
<ResourceAssetDeleteDialog
|
||||
open
|
||||
localPath={asset.localPath}
|
||||
referencedVersions={referencedVersions}
|
||||
deleteReferencedVersions={deleteReferencedVersions}
|
||||
deleting={deleting}
|
||||
onChangeDeleteReferencedVersions={setDeleteReferencedVersions}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={() => void confirmDeleteResource()}
|
||||
/>
|
||||
) : null}
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<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 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(
|
||||
<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 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user