修复素材预览失败后的重试逻辑

按预览 URL 记录失败状态,允许资源重新生成地址后再次加载

补充预览重试回归测试并同步 UI 编辑会话边界文档
This commit is contained in:
2026-09-03 14:17:44 +08:00
parent 9165418c48
commit 520904a586
3 changed files with 47 additions and 12 deletions
@@ -18,9 +18,9 @@ export function ImageAssetSelector({
onChange: (value: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const [failedPreviewIds, setFailedPreviewIds] = useState<Set<string>>(
() => new Set(),
);
const [failedPreviewUrls, setFailedPreviewUrls] = useState<
Record<string, string>
>({});
const selectedSprite = value ? sprites[value] : undefined;
const selectedLabel = selectedSprite
? selectedSprite.metadata.name || value || '未绑定'
@@ -38,12 +38,10 @@ export function ImageAssetSelector({
close();
}
function markPreviewFailed(id: string) {
setFailedPreviewIds((current) => {
if (current.has(id)) return current;
const next = new Set(current);
next.add(id);
return next;
function markPreviewFailed(id: string, url: string) {
setFailedPreviewUrls((current) => {
if (current[id] === url) return current;
return { ...current, [id]: url };
});
}
@@ -111,7 +109,7 @@ export function ImageAssetSelector({
{Object.entries(sprites).map(([id, sprite]) => {
const previewUrl = previewUrls[id];
const hasPreview =
Boolean(previewUrl) && !failedPreviewIds.has(id);
Boolean(previewUrl) && failedPreviewUrls[id] !== previewUrl;
const selected = value === id;
return (
<button
@@ -127,7 +125,7 @@ export function ImageAssetSelector({
src={previewUrl}
alt={sprite.metadata.name || id}
className="size-full object-contain"
onError={() => markPreviewFailed(id)}
onError={() => markPreviewFailed(id, previewUrl)}
/>
) : (
<ImageIcon
@@ -1,5 +1,11 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen, within } from '@testing-library/react';
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -90,6 +96,35 @@ describe('ImageAssetSelector', () => {
);
});
it('retries a preview when its URL is regenerated', async () => {
const user = userEvent.setup();
const view = renderSelector(null, vi.fn(), {
hero: 'data:image/png;base64,first',
});
await user.click(screen.getByRole('button', { name: '选择素材' }));
const dialog = screen.getByRole('dialog', { name: '选择图片素材' });
const image = within(dialog).getByAltText('主角');
fireEvent.error(image);
expect(within(dialog).queryByAltText('主角')).toBeNull();
view.rerender(
<ImageAssetSelector
value={null}
sprites={sprites}
previewUrls={{ hero: 'data:image/png;base64,regenerated' }}
readOnly={false}
onChange={vi.fn()}
/>,
);
expect(
within(screen.getByRole('dialog', { name: '选择图片素材' })).getByAltText(
'主角',
),
).toBeTruthy();
});
it('supports keyboard activation for sprite cards', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
@@ -15,6 +15,8 @@
预览树在会话边界归一化为 `UITree | null`:尚未选择界面图、正在加载或尚未识别树时均以 `null` 表示,不能把 `Array.find``undefined` 传播到视图接口。
素材缩略图的加载失败状态按“素材 ID + 预览 URL”记录;同一 URL 失败后显示占位图,资源重新加载或重新导入产生新 URL 时必须允许再次尝试,不能把失败永久绑定到素材 ID。
保存与代码生成共享同一份持久化 State/revision。会话层在保存或生成进行期间互斥拦截,且代码生成必须基于已加载的持久化 revision;视图层的保存按钮和“保存并返回”按钮同步遵守该互斥状态。
`UiDesignStateStore``generateCode(assetId)` 是必需能力,返回成功结果时不得为 nullable;所有注入的 adapter 与测试替身都必须实现该方法。