补齐 AGC V3 附二素材重命名前端链路

- 新增 ResourceRenameDialog:独立重命名面板,只输入新文件名,规则交给 Rust 侧强校验

- 资源详情面板新增「重命名」动作,严格按 deny_unknown_fields 只传 projectPath/assetId/newFileName

- 改名成功后复用 reloadManifestAfterAssetCommand 这条 manifest 重载路径刷新资源卡与 @ 面板显示名

- 删除 check-config.mjs 过渡 allowlist 里的 normalize_local_project_raster_resource,并移除已无调用方的同名 Tauri 命令与注册

- 新增 2 条前端测试覆盖严格入参、manifest 重载与改名后资源卡显示名刷新
This commit is contained in:
agent
2026-09-10 15:24:20 +08:00
parent 3508957eda
commit f32445d700
6 changed files with 425 additions and 11 deletions
@@ -111,7 +111,6 @@ const allowedUncalledTauriCommands = [
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
'normalize_local_project_raster_resource',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
@@ -2172,15 +2172,6 @@ pub(crate) async fn archive_failed_local_project_resource_edit(
archive_failed_local_project_resource_edit_at(input).await
}
#[tauri::command]
pub(crate) fn normalize_local_project_raster_resource(
input: NormalizeLocalProjectRasterResourceInput,
) -> Result<NormalizeLocalProjectRasterResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
normalize_local_project_raster_resource_at(input)
}
#[tauri::command]
pub(crate) fn import_canvas_asset(
project_path: String,
@@ -2622,7 +2622,6 @@ fn main() {
request_local_project_resource_edit_service_identity_confirmation,
confirm_local_project_resource_edit_service_identity,
archive_failed_local_project_resource_edit,
normalize_local_project_raster_resource,
import_canvas_asset,
import_canvas_export,
sync_canvas_project_assets,
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
export type RenameLocalProjectAssetResult = {
asset: GameCreationAppAssetManifestEntry;
previousLocalPath: string;
committedProjectRevision: number;
};
function assetFileName(localPath: string) {
return localPath.split(/[\\/]/u).pop() ?? localPath;
}
type ResourceRenameDialogProps = {
open: boolean;
asset: GameCreationAppAssetManifestEntry;
renaming: boolean;
error: string | null;
onClose: () => void;
onConfirm: (newFileName: string) => void;
};
/**
* 素材重命名面板。
*
* 只输入新文件名:目录由资产的 `localPath` 决定,用户不能换目录;
* 扩展名一致、同名冲突等规则由 Rust 侧 `rename_local_project_asset` 强校验,
* 这里只做输入与错误呈现。
*/
export function ResourceRenameDialog({
open,
asset,
renaming,
error,
onClose,
onConfirm,
}: ResourceRenameDialogProps) {
const [draft, setDraft] = useState(() => assetFileName(asset.localPath));
return (
<ThemedModal
open={open}
ariaLabel="重命名素材"
onClose={onClose}
panelClassName="game-approval-dialog game-resource-rename-dialog"
>
<header>
<div>
<h2></h2>
<p>{asset.localPath}</p>
</div>
<button
type="button"
aria-label="关闭重命名素材"
onClick={onClose}
disabled={renaming}
>
×
</button>
</header>
<div className="game-resource-rename-body">
<PlatformTextField
aria-label="新文件名"
value={draft}
disabled={renaming}
autoFocus
onChange={(event) => setDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !renaming && draft.trim() !== '') {
event.preventDefault();
onConfirm(draft);
}
}}
/>
{error ? (
<p className="game-resource-rename-error" role="alert">
{error}
</p>
) : null}
</div>
<footer>
<PlatformActionButton
tone="secondary"
onClick={onClose}
disabled={renaming}
>
</PlatformActionButton>
<PlatformActionButton
aria-label="确认重命名素材"
onClick={() => onConfirm(draft)}
disabled={renaming || draft.trim() === ''}
>
</PlatformActionButton>
</footer>
</ThemedModal>
);
}
@@ -161,6 +161,10 @@ import {
projectResourcesFromReadModels,
projectResourceTypeLabel,
} from './resourceProjectionModel';
import {
type RenameLocalProjectAssetResult,
ResourceRenameDialog,
} from './ResourceRenameDialog';
import {
clampProjectResourceSectionZoom,
projectResourceSectionZoomFromWheel,
@@ -1251,6 +1255,14 @@ export default function ProjectDevelopmentView({
useState(false);
const [resourceClassificationAssetId, setResourceClassificationAssetId] =
useState<string | null>(null);
/** 正在重命名的素材;改名沿用分类面板同一条 manifest 重载路径。 */
const [resourceRenameAssetId, setResourceRenameAssetId] = useState<
string | null
>(null);
const [resourceRenaming, setResourceRenaming] = useState(false);
const [resourceRenameError, setResourceRenameError] = useState<string | null>(
null,
);
const [pendingResourceEditActionIds, setPendingResourceEditActionIds] =
useState<Set<string>>(() => new Set());
const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] =
@@ -2223,6 +2235,15 @@ export default function ProjectDevelopmentView({
: null,
[manifest.assets, resourceClassificationAssetId],
);
const resourceRenameAsset = useMemo(
() =>
resourceRenameAssetId
? (manifest.assets.find(
(asset) => asset.id === resourceRenameAssetId,
) ?? null)
: null,
[manifest.assets, resourceRenameAssetId],
);
/**
* 资源分类更新与素材删除都只改 manifest,成功后重读一次并按新 revision 投影;
* 两者共用同一条重载路径,避免出现两份略有差异的刷新逻辑。
@@ -2230,6 +2251,7 @@ export default function ProjectDevelopmentView({
const reloadManifestAfterAssetCommand = useCallback(
async (committedProjectRevision: number, commitId: string) => {
setResourceClassificationAssetId(null);
setResourceRenameAssetId(null);
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !onManifestChange) return;
try {
@@ -2273,6 +2295,48 @@ export default function ProjectDevelopmentView({
},
[reloadManifestAfterAssetCommand],
);
/**
* 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。
* Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。
*/
const confirmResourceRename = useCallback(
async (newFileName: string) => {
const asset = resourceRenameAsset;
const invoke = window.__TAURI__?.core?.invoke;
if (!asset) return;
if (!invoke) {
setResourceRenameError('重命名素材需要在客户端内执行');
return;
}
setResourceRenaming(true);
setResourceRenameError(null);
try {
const result = await invoke<RenameLocalProjectAssetResult>(
'rename_local_project_asset',
{
input: {
projectPath,
assetId: asset.id,
newFileName,
},
},
);
await reloadManifestAfterAssetCommand(
result.committedProjectRevision,
`asset-rename:${asset.id}`,
);
} catch (renameError) {
const message =
renameError instanceof Error
? renameError.message
: String(renameError);
setResourceRenameError(message.trim() || '重命名素材失败');
} finally {
setResourceRenaming(false);
}
},
[projectPath, reloadManifestAfterAssetCommand, resourceRenameAsset],
);
const focusedResourceDependencyDetails = useMemo(() => {
if (!focusedResource) {
return {
@@ -5043,6 +5107,20 @@ export default function ProjectDevelopmentView({
</button>
) : null}
{focusedResource.manifestAssetId ? (
<button
type="button"
className="game-resource-focus-action"
onClick={() => {
setResourceRenameError(null);
setResourceRenameAssetId(
focusedResource.manifestAssetId,
);
}}
>
</button>
) : null}
<button
type="button"
className="game-resource-focus-close"
@@ -5861,6 +5939,20 @@ export default function ProjectDevelopmentView({
}
/>
) : null}
{resourceRenameAsset ? (
<ResourceRenameDialog
key={resourceRenameAsset.id}
open
asset={resourceRenameAsset}
renaming={resourceRenaming}
error={resourceRenameError}
onClose={() => {
setResourceRenameError(null);
setResourceRenameAssetId(null);
}}
onConfirm={(newFileName) => void confirmResourceRename(newFileName)}
/>
) : null}
{resourceRecoveryPanelOpen ? (
<div
className="game-approval-backdrop"
@@ -0,0 +1,230 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
cleanup,
createGameCreationAppManifest,
findResourceDetailButton,
fireEvent,
getResourceDetailButton,
ProjectDevelopmentView,
React,
render,
screen,
waitFor,
within,
} from './appSurface/harness';
const PROJECT_PATH = '/tmp/workbench-asset-rename';
/** 资源卡懒加载预览要靠 IntersectionObserver 才认成可见,这里只做最小替身。 */
function installResourceCardIntersectionObserver() {
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
constructor(readonly callback: IntersectionObserverCallback) {}
observe(element: Element) {
this.observed.add(element);
}
unobserve(element: Element) {
this.observed.delete(element);
}
disconnect() {
this.observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: ResourceCardIntersectionObserver,
});
}
function installInvoke(
implementation: (command: string, args?: unknown) => Promise<unknown>,
) {
const invoke = vi.fn(implementation);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
return invoke;
}
function createManifestWithHero(localPath: string): GameCreationAppManifest {
const manifest = createGameCreationAppManifest(
'workbench-asset-rename',
'素材重命名测试',
);
manifest.assets = [
{
id: 'asset-hero',
kind: 'character',
mediaType: 'image/png',
localPath,
source: { kind: 'generated' },
},
];
return manifest;
}
async function openHeroCard(name = 'hero.png') {
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
// 资源画本先切到子画布再渲染卡片,等它落到 art 栏目再找卡。
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
expect(
manager?.querySelector(
'.game-resource-book-scene-titlebar.is-active[data-resource-book-category="art"]',
),
).not.toBeNull();
});
return findResourceDetailButton(name);
}
function renderWorkbench(
manifest: GameCreationAppManifest,
onManifestChange?: (
projectPath: string,
next: GameCreationAppManifest,
metadata: unknown,
) => void,
) {
const { rerender } = render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: PROJECT_PATH,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onManifestChange,
}),
);
return {
rerenderWith(next: GameCreationAppManifest) {
rerender(
React.createElement(ProjectDevelopmentView, {
projectName: next.name,
projectPath: PROJECT_PATH,
manifest: next,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onManifestChange,
}),
);
},
};
}
afterEach(() => {
cleanup();
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
});
describe('素材重命名前端链路', () => {
test('renames through the strict native command and refreshes the resource card name', async () => {
installResourceCardIntersectionObserver();
const renamedManifest = createManifestWithHero('assets/hero-v2.png');
const invoke = installInvoke(async (command) => {
if (command === 'rename_local_project_asset') {
return {
asset: renamedManifest.assets[0],
previousLocalPath: 'assets/hero.png',
committedProjectRevision: 12,
};
}
if (command === 'get_local_game_manifest') {
return renamedManifest;
}
throw new Error(`unexpected command: ${command}`);
});
const onManifestChange = vi.fn();
const rendered = renderWorkbench(
createManifestWithHero('assets/hero.png'),
onManifestChange,
);
fireEvent.click(await openHeroCard());
fireEvent.click(await screen.findByRole('button', { name: '重命名' }));
const field = (await screen.findByLabelText(
'新文件名',
)) as HTMLInputElement;
expect(field.value).toBe('hero.png');
fireEvent.change(field, { target: { value: 'hero-v2.png' } });
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
await waitFor(() => {
expect(onManifestChange).toHaveBeenCalledTimes(1);
});
// 入参是 deny_unknown_fields 结构体:只允许这三个字段,多传会被 Rust 直接拒绝。
expect(invoke).toHaveBeenCalledWith('rename_local_project_asset', {
input: {
projectPath: PROJECT_PATH,
assetId: 'asset-hero',
newFileName: 'hero-v2.png',
},
});
expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', {
projectPath: PROJECT_PATH,
commandId: 'asset.list',
});
expect(onManifestChange.mock.calls[0]?.[2]).toMatchObject({
revision: 12,
source: 'asset-command',
commitId: 'asset-rename:asset-hero',
});
rendered.rerenderWith(renamedManifest);
await waitFor(() => {
expect(getResourceDetailButton('hero-v2.png')).not.toBeNull();
});
});
test('keeps the panel open and surfaces the native rejection', async () => {
installResourceCardIntersectionObserver();
installInvoke(async (command) => {
if (command === 'rename_local_project_asset') {
throw '新文件名非法:扩展名必须与原文件一致';
}
throw new Error(`unexpected command: ${command}`);
});
renderWorkbench(createManifestWithHero('assets/hero.png'));
fireEvent.click(await openHeroCard());
fireEvent.click(await screen.findByRole('button', { name: '重命名' }));
const field = (await screen.findByLabelText(
'新文件名',
)) as HTMLInputElement;
fireEvent.change(field, { target: { value: 'hero.jpg' } });
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('扩展名必须与原文件一致');
expect(screen.getByLabelText('新文件名')).not.toBeNull();
});
});