补齐 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user