WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
4 changed files with 380 additions and 28 deletions
Showing only changes of commit 33ed85ba54 - Show all commits
+56
View File
@@ -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;
@@ -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>
);
}
@@ -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();
});
});