diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx b/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx index a724c4e42..98071bd37 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx +++ b/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx @@ -1,3 +1,6 @@ +export const SPRITE_CHECKERBOARD_CLASS_NAME = + 'bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]'; + export function SpriteImagePreview({ src, alt, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 83e8ff1ae..495db5bd9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -1,6 +1,7 @@ import { Image as ImageIcon, Plus, Type } from 'lucide-react'; import { useMemo } from 'react'; +import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../features/ui-editor/components/SpriteImagePreview'; import { visitUiNodes } from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; @@ -184,7 +185,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { + + + + +
+

选择图片素材

+ +
+ +
+
+ + + {value && !selectedSprite ? ( +
+
+ ) : null} + + {Object.entries(sprites).map(([id, sprite]) => { + const previewUrl = previewUrls[id]; + const hasPreview = + Boolean(previewUrl) && failedPreviewUrls[id] !== previewUrl; + const selected = value === id; + return ( + + ); + })} +
+ {Object.keys(sprites).length === 0 ? ( +

+ 暂无可用素材 +

+ ) : null} +
+
+ + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx index 176021783..06b3e8b53 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx @@ -4,10 +4,12 @@ import type { ImageType } from '../../../../../features/ui-editor/types/ImageTyp import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; import type { ImageEditorProps } from './componentEditorTypes'; import { ComponentNumberInput, ComponentSelect } from './ComponentField'; +import { ImageAssetSelector } from './ImageAssetSelector'; export function ImagePanel({ component, sprites, + previewUrls, readOnly, onChange, }: ImageEditorProps) { @@ -17,24 +19,13 @@ export function ImagePanel({ return (
- - update({ - ...component, - target_graphic: event.target.value || null, - }) - } - > - - {Object.entries(sprites).map(([id, sprite]) => ( - - ))} - + update({ ...component, target_graphic })} + /> ; + previewUrls: Record; fonts: Record; fontFaces: Record; projectPath: string; @@ -35,6 +36,7 @@ export type ComponentEditorProps = { export type ImageEditorProps = ComponentEditorProps & { sprites: Record; + previewUrls: Record; }; export type TextEditorProps = ComponentEditorProps & { diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index ca0d35db0..7557c3727 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -111,6 +111,7 @@ export function InspectorSidebar({ onTransformChange={inspector.setNodeTransform} onLayoutChange={inspector.setNodeLayout} sprites={inspector.sprites} + previewUrls={inspector.previewUrls} fonts={inspector.fonts} fontFaces={inspector.fontFaces} projectPath={inspector.projectPath} @@ -288,6 +289,7 @@ function NodeInspector({ onTransformChange, onLayoutChange, sprites, + previewUrls, fonts, fontFaces, projectPath, @@ -317,6 +319,7 @@ function NodeInspector({ onTransformChange: UiEditorInspectorProjection['setNodeTransform']; onLayoutChange: UiEditorInspectorProjection['setNodeLayout']; sprites: UiEditorInspectorProjection['sprites']; + previewUrls: UiEditorInspectorProjection['previewUrls']; fonts: UiEditorInspectorProjection['fonts']; fontFaces: UiEditorInspectorProjection['fontFaces']; projectPath: string; @@ -497,6 +500,7 @@ function NodeInspector({ = {}, + selectorSprites: typeof sprites = sprites, +) { + return render( + , + ); +} + +describe('ImageAssetSelector', () => { + afterEach(() => cleanup()); + + it('selects a sprite and clears the selection from the modal', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderSelector(null, onChange); + + await user.click(screen.getByRole('button', { name: '选择素材' })); + expect(screen.getByRole('dialog', { name: '选择图片素材' })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /主角/ })); + expect(onChange).toHaveBeenCalledWith('hero'); + expect(screen.queryByRole('dialog')).toBeNull(); + + await user.click(screen.getByRole('button', { name: '选择素材' })); + await user.click(screen.getByRole('button', { name: /清除选择/ })); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it('shows preview images, placeholders, and invalid references', async () => { + const user = userEvent.setup(); + renderSelector('missing', vi.fn(), { hero: 'data:image/png;base64,abc' }); + + expect(screen.getByText('素材不存在(missing)')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '更换素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + expect(within(dialog).getByAltText('主角')).toBeTruthy(); + expect(within(dialog).getByText('素材不存在')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: /清除选择/ }), + ).toBeTruthy(); + }); + + it('cannot open while read only', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: '更换素材' })).toHaveProperty( + 'disabled', + true, + ); + }); + + 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( + , + ); + + expect( + within(screen.getByRole('dialog', { name: '选择图片素材' })).getByAltText( + '主角', + ), + ).toBeTruthy(); + }); + + it('supports keyboard activation for sprite cards', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderSelector(null, onChange); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const hero = screen.getByRole('button', { name: /主角/ }); + hero.focus(); + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledWith('hero'); + }); + + it('closes when the modal backdrop is clicked', async () => { + const user = userEvent.setup(); + renderSelector(); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + const overlay = dialog.parentElement; + expect(overlay).toBeTruthy(); + await user.click(overlay!); + expect(screen.queryByRole('dialog', { name: '选择图片素材' })).toBeNull(); + }); + + it('shows an empty state when no sprites are available', async () => { + const user = userEvent.setup(); + renderSelector(null, vi.fn(), {}, {}); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + expect(within(dialog).getByText('暂无可用素材')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: /清除选择/ }), + ).toBeTruthy(); + }); +}); diff --git a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md index 6e33010bd..12460d0e0 100644 --- a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md +++ b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md @@ -15,6 +15,8 @@ 预览树在会话边界归一化为 `UITree | null`:尚未选择界面图、正在加载或尚未识别树时均以 `null` 表示,不能把 `Array.find` 的 `undefined` 传播到视图接口。 +素材缩略图的加载失败状态按“素材 ID + 预览 URL”记录;同一 URL 失败后显示占位图,资源重新加载或重新导入产生新 URL 时必须允许再次尝试,不能把失败永久绑定到素材 ID。 + 保存与代码生成共享同一份持久化 State/revision。会话层在保存或生成进行期间互斥拦截,且代码生成必须基于已加载的持久化 revision;视图层的保存按钮和“保存并返回”按钮同步遵守该互斥状态。 `UiDesignStateStore` 的 `generateCode(assetId)` 是必需能力,返回成功结果时不得为 nullable;所有注入的 adapter 与测试替身都必须实现该方法。 diff --git a/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md b/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md new file mode 100644 index 000000000..f2b926c74 --- /dev/null +++ b/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md @@ -0,0 +1,41 @@ +# UI 编辑器图片素材选择器 + +> 更新时间:`2026-09-03` + +## 目标 + +在 UI 编辑器 Inspector 的图片组件中,用独立 modal 图片选择器替换原生素材下拉框,使用户绑定素材时可以看到现有预览图或缩略图。 + +## 数据来源与边界 + +- 素材列表继续使用 Inspector 当前的 `sprites`,顺序沿用 `Object.entries(sprites)`。 +- `SpriteAsset` 不新增缩略图字段。预览继续消费 UI 编辑会话已有的 `previewUrls: Record`;预览缺失不影响素材绑定。 +- `previewUrls` 由 Inspector projection 沿 `NodeInspector → ComponentPanel → ImagePanel` 传入选择器。 +- 本次不新增后端接口、持久化字段、搜索筛选或二级大图预览。 + +## 交互合同 + +### Inspector 触发区域 + +- 正常绑定显示素材名称。 +- 空值显示 `未绑定`。 +- 引用不存在的素材显示 `素材不存在()`,保留原 ID,不在打开面板时自动清除。 +- 未绑定时按钮文案为 `选择素材`;已有绑定或失效引用时为 `更换素材`。 +- `readOnly` 时按钮禁用,不能打开选择器。 + +### 素材选择 modal + +- 使用现有 `ThemedModal`,带遮罩、关闭按钮、Esc 关闭和点击遮罩关闭。 +- modal 内容使用纵向 flex;素材区域占据剩余空间并内部滚动,modal 设置最大高度。 +- 素材以弹性网格展示,卡片包含预览图和素材名称,图片使用 `object-contain`。 +- 有预览时显示 `previewUrls[id]`;无预览或加载失败时显示棋盘格背景与 `ImageIcon` 占位,素材仍可选。 +- 当前绑定项显示选中边框或标记。 +- 第一项为明确的“清除选择”,点击立即写入 `null` 并关闭。 +- 点击任意正常素材立即写入其 ID 并关闭;重复点击当前素材也按相同流程处理。 +- 当前失效引用保留为禁用信息卡,显示 ID;用户仍可使用“清除选择”。 +- 素材为空时显示 `暂无可用素材`,保留关闭操作。 +- 卡片支持 Tab 聚焦以及 Enter/Space 选择;关闭后焦点返回触发按钮。 + +## 验收 + +自动化测试覆盖:打开/关闭、按钮和名称状态、当前项高亮、选择 ID、清除 `null`、缺预览占位、失效引用、空列表、`readOnly` 禁止打开、键盘选择和遮罩关闭。实现后运行定向测试、前端类型检查、编码检查及 `git diff --check`。