修复项目资源预览刷新与运行提示

切换资源布局时复用现有预览与尺寸状态
精修提交后仅失效对应素材的卡片预览
避免资源编辑状态下显示运行不可用提示
补充资源预览与项目工作台回归测试
This commit is contained in:
2026-08-24 12:01:45 +08:00
parent f231a7e8ef
commit 4598391729
5 changed files with 145 additions and 39 deletions
@@ -855,6 +855,8 @@ export default function ProjectDevelopmentView({
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
string | null
>(null);
const [resourcePreviewVersionByResourceId, setResourcePreviewVersionByResourceId] =
useState<Map<string, string>>(() => new Map());
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [approvalNotice, setApprovalNotice] = useState('');
@@ -1228,6 +1230,7 @@ export default function ProjectDevelopmentView({
resources,
canvasRef: resourceCanvasRef,
eagerPreviewLimit: 12,
previewVersionByResourceId: resourcePreviewVersionByResourceId,
});
const resourceCardSizeByResourceId = useMemo(
() =>
@@ -2147,6 +2150,7 @@ export default function ProjectDevelopmentView({
setPendingResourceEditsError('');
setPendingResourceEditActionErrors(new Map());
setResourceEditServiceIdentityConfirmations(new Map());
setResourcePreviewVersionByResourceId(new Map());
resourceRecoveryPanelEpochRef.current += 1;
setResourceRecoveryPanelOpen(false);
pendingResourceEditActionIdsRef.current = new Set();
@@ -3627,6 +3631,15 @@ export default function ProjectDevelopmentView({
) {
return;
}
const committedResourceId = `asset:${notification.assetId}`;
setResourcePreviewVersionByResourceId((current) => {
if (current.get(committedResourceId) === notification.commitId) {
return current;
}
const next = new Map(current);
next.set(committedResourceId, notification.commitId);
return next;
});
onManifestChange?.(projectPath, notification.manifest, {
projectId: notification.projectId,
revision: notification.projectRevision,
@@ -3659,7 +3672,7 @@ export default function ProjectDevelopmentView({
}
pendingResourceFocusRef.current = {
...focusIntent,
resourceId: `asset:${notification.assetId}`,
resourceId: committedResourceId,
};
setAssetCanvasRoute(null);
setMode('resources');
@@ -3832,6 +3845,13 @@ export default function ProjectDevelopmentView({
setMode('run');
}
const showRunUnavailableHint =
!runAvailable &&
!focusedResource &&
!assetCanvasRoute &&
!resourceEditorRoute &&
!uiEditorRoute;
return (
<section
className="launcher-page launcher-project-development game-project-workbench"
@@ -3876,7 +3896,7 @@ export default function ProjectDevelopmentView({
role="tab"
aria-selected={mode === 'run'}
aria-describedby={
!runAvailable ? 'run-unavailable-hint' : undefined
showRunUnavailableHint ? 'run-unavailable-hint' : undefined
}
data-unavailable={!runAvailable || uiEditorRoute || undefined}
className={mode === 'run' ? 'is-active' : ''}
@@ -3968,7 +3988,7 @@ export default function ProjectDevelopmentView({
</div>
</div>
{!runAvailable && !assetCanvasRoute && !resourceEditorRoute ? (
{showRunUnavailableHint ? (
<p
id="run-unavailable-hint"
className="game-run-unavailable"
@@ -1,4 +1,3 @@
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectResource } from './resourceProjectionModel';
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
@@ -174,17 +173,17 @@ export function projectResourceCardPreviewKind(
export function projectResourceCardPreviewIdentity(input: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
previewVersion?: string;
resource: ProjectResource;
}) {
return JSON.stringify([
input.projectPath,
input.projectId,
input.mode,
input.resource.id,
input.resource.category,
input.resource.path,
input.resource.mediaType,
input.previewVersion ?? '',
]);
}
@@ -199,12 +199,9 @@ export function useProjectResourceCardPreviews(input: {
resources: ProjectResource[];
canvasRef: React.RefObject<HTMLDivElement | null>;
eagerPreviewLimit?: number;
previewVersionByResourceId?: ReadonlyMap<string, string>;
}) {
const scopeKey = JSON.stringify([
input.projectPath,
input.projectId,
input.mode,
]);
const scopeKey = JSON.stringify([input.projectPath, input.projectId]);
const identityByResourceId = useMemo(
() =>
new Map(
@@ -213,12 +210,18 @@ export function useProjectResourceCardPreviews(input: {
projectResourceCardPreviewIdentity({
projectPath: input.projectPath,
projectId: input.projectId,
mode: input.mode,
previewVersion:
input.previewVersionByResourceId?.get(resource.id) ?? '',
resource,
}),
]),
),
[input.mode, input.projectId, input.projectPath, input.resources],
[
input.previewVersionByResourceId,
input.projectId,
input.projectPath,
input.resources,
],
);
const [previews, setPreviews] = useState<
Map<string, ProjectResourceCardPreviewState>
@@ -1963,7 +1963,12 @@ export function registerProjectWorkbenchFoundationTests() {
'resources.focused.document',
);
expect(screen.getByLabelText('搜索项目资源')).not.toBeNull();
expect(screen.getByRole('button', { name: '新增资源' })).not.toBeNull();
expect(screen.getByRole('button', { name: '生成视频' })).not.toBeNull();
expect(screen.getByRole('button', { name: '生成音效' })).not.toBeNull();
expect(
screen.getByRole('button', { name: '生成背景音乐' }),
).not.toBeNull();
expect(screen.getByRole('button', { name: '新增 UI 设计' })).not.toBeNull();
expect(screen.getByRole('button', { name: '按依赖' })).not.toBeNull();
expect(
screen
@@ -4012,6 +4017,35 @@ export function registerProjectWorkbenchFoundationTests() {
expect(projectDevelopmentSource).toMatch(
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/,
);
expect(projectDevelopmentSource).toMatch(
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!assetCanvasRoute\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s,
);
expect(projectDevelopmentSource).toMatch(
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
);
expect(projectDevelopmentSource).toMatch(
/\{showRunUnavailableHint\s*\?\s*\(\s*<p\s+id="run-unavailable-hint"/s,
);
});
it('invalidates only the committed asset preview after an in-place refine', () => {
const projectDevelopmentSource = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
);
expect(projectDevelopmentSource).toMatch(
/previewVersionByResourceId:\s*resourcePreviewVersionByResourceId/,
);
expect(projectDevelopmentSource).toMatch(
/const committedResourceId = `asset:\$\{notification\.assetId\}`;[\s\S]*?next\.set\(committedResourceId, notification\.commitId\)/,
);
expect(projectDevelopmentSource).toMatch(
/setResourcePreviewVersionByResourceId\(new Map\(\)\)/,
);
});
it('keeps resource sort tab keyboard focus inside the clipped segmented control', () => {
@@ -583,13 +583,13 @@ describe('useProjectResourceCardPreviews', () => {
await waitFor(() => expect(previewReadCalls(invoke)).toHaveLength(7));
});
it('rotates the native scope for a mode-only change and cancels the final scope on unmount', () => {
it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => {
const art = resource('mode-switch-art');
const invoke = vi.fn((command: string) => {
const invoke = vi.fn(async (command: string) => {
if (command === 'cancel_local_project_resource_preview_scope') {
return Promise.resolve();
return undefined;
}
return new Promise(() => undefined);
return preview(art.path);
});
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
@@ -601,35 +601,35 @@ describe('useProjectResourceCardPreviews', () => {
mode,
resources: [art],
canvasRef,
eagerPreviewLimit: 1,
}),
{ initialProps: 'dependency' as const },
);
act(() =>
result.current.requestPreview(
art,
result.current.identityByResourceId.get(art.id)!,
'visible',
),
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'),
);
const firstScopeId = previewReadCalls(invoke)[0]?.[1]?.scopeId;
expect(firstScopeId).toEqual(expect.any(String));
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
rerender('type');
expect(invoke).toHaveBeenCalledWith(
'cancel_local_project_resource_preview_scope',
{ scopeId: firstScopeId },
);
act(() =>
result.current.requestPreview(
art,
result.current.identityByResourceId.get(art.id)!,
'visible',
expect(result.current.identityByResourceId.get(art.id)).toBe(firstIdentity);
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded');
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
expect(previewReadCalls(invoke)).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'cancel_local_project_resource_preview_scope',
),
);
const secondScopeId = previewReadCalls(invoke)[1]?.[1]?.scopeId;
expect(secondScopeId).toEqual(expect.any(String));
expect(secondScopeId).not.toBe(firstScopeId);
).toHaveLength(0);
unmount();
const cancelledScopeIds = invoke.mock.calls
@@ -638,7 +638,57 @@ describe('useProjectResourceCardPreviews', () => {
command === 'cancel_local_project_resource_preview_scope',
)
.map((call) => call[1]?.scopeId);
expect(cancelledScopeIds).toEqual([firstScopeId, secondScopeId]);
expect(cancelledScopeIds).toEqual([firstScopeId]);
});
it('invalidates an in-place asset preview when its committed version changes', async () => {
const art = resource('refined-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
(previewVersion: string) =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-refine-version',
projectId: 'preview-refine-version',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 1,
previewVersionByResourceId: new Map([[art.id, previewVersion]]),
}),
{ initialProps: 'commit-1' },
);
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'),
);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
rerender('commit-2');
const secondIdentity = result.current.identityByResourceId.get(art.id)!;
expect(secondIdentity).not.toBe(firstIdentity);
await waitFor(() =>
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'blob:resource-preview-0',
),
);
await waitFor(() =>
expect(result.current.previews.get(secondIdentity)?.status).toBe(
'loaded',
),
);
expect(previewReadCalls(invoke)).toHaveLength(2);
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
expect(result.current.previews.has(firstIdentity)).toBe(false);
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
});
it('rejects an A-B-A late result even when scope and identity match again', async () => {