From df8b22f71fb3cb27263775b2f8142a307e4e0eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:45:27 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E9=80=89=E6=8B=A9=E5=99=A8=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 UI 编辑器图片素材选择器的状态、交互与验收合同。 --- ...端设计】UI编辑器图片素材选择器-2026-09-03.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md 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`。 -- 2.52.0 From bc55e524ba22cdbd08563fb1d5f332618c614d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:46:57 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E6=8E=A5=E9=80=9A=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=9C=B0=E5=9D=80=E5=88=B0=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 UI 编辑会话的 previewUrls 沿节点检查器传递到图片组件面板。 --- .../components/Inspector/Components/ComponentPanel.tsx | 1 + .../components/Inspector/Components/componentEditorTypes.ts | 2 ++ .../view/ui-editor/components/Inspector/InspectorSidebar.tsx | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx index 081431a48..ab030488d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx @@ -293,6 +293,7 @@ function renderComponentEditor( updateComponent(index, { Image: next })} /> diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts index d93eb8503..9d3adc06a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts @@ -9,6 +9,7 @@ import type { UiEditorOperationResult } from '../../../../../features/ui-editor/ export type ComponentPanelProps = { components: Component[]; sprites: Record; + 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({ Date: Thu, 3 Sep 2026 13:49:44 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E9=A2=84=E8=A7=88=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 ThemedModal 网格选择器替换图片组件原生素材下拉。 支持预览占位、失效引用、清除绑定、键盘操作与只读状态。 --- .../Components/ImageAssetSelector.tsx | 161 ++++++++++++++++++ .../Inspector/Components/ImagePanel.tsx | 27 +-- 2 files changed, 170 insertions(+), 18 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx new file mode 100644 index 000000000..0e44b2406 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx @@ -0,0 +1,161 @@ +import { Check, ImageIcon, X } from 'lucide-react'; +import { useState } from 'react'; + +import { ThemedModal } from '../../../../../components/modal/ThemedModal'; +import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset'; +import { ComponentField } from './ComponentField'; + +export function ImageAssetSelector({ + value, + sprites, + previewUrls, + readOnly, + onChange, +}: { + value: string | null; + sprites: Record; + previewUrls: Record; + readOnly: boolean; + onChange: (value: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const [failedPreviewIds, setFailedPreviewIds] = useState>( + () => new Set(), + ); + const selectedSprite = value ? sprites[value] : undefined; + const selectedLabel = selectedSprite + ? selectedSprite.metadata.name || value || '未绑定' + : value + ? `素材不存在(${value})` + : '未绑定'; + const buttonLabel = value ? '更换素材' : '选择素材'; + + function close() { + setOpen(false); + } + + function select(nextValue: string | null) { + onChange(nextValue); + close(); + } + + function markPreviewFailed(id: string) { + setFailedPreviewIds((current) => { + if (current.has(id)) return current; + const next = new Set(current); + next.add(id); + return next; + }); + } + + return ( + <> + +
+
+ {selectedLabel} +
+ +
+
+ + +
+

选择图片素材

+ +
+ +
+
+ + + {value && !selectedSprite ? ( +
+
+ ) : null} + + {Object.entries(sprites).map(([id, sprite]) => { + const previewUrl = previewUrls[id]; + const hasPreview = + Boolean(previewUrl) && !failedPreviewIds.has(id); + 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 })} + /> Date: Thu, 3 Sep 2026 13:52:29 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E9=80=89=E6=8B=A9=E5=99=A8=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖选择、清除、预览占位、失效引用、只读和键盘操作。 --- .../Components/ImageAssetSelector.tsx | 6 +- .../tests/imageAssetSelector.test.tsx | 103 ++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx index 0e44b2406..6ec0e1517 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { ThemedModal } from '../../../../../components/modal/ThemedModal'; import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset'; -import { ComponentField } from './ComponentField'; export function ImageAssetSelector({ value, @@ -50,7 +49,8 @@ export function ImageAssetSelector({ return ( <> - +
+ 目标素材
- +
= {}, +) { + 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('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'); + }); +}); -- 2.52.0 From 520904a58604e6de50b964179e676623335402fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 14:17:44 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=A4=B1=E8=B4=A5=E5=90=8E=E7=9A=84=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按预览 URL 记录失败状态,允许资源重新生成地址后再次加载 补充预览重试回归测试并同步 UI 编辑会话边界文档 --- .../Components/ImageAssetSelector.tsx | 20 +++++----- .../tests/imageAssetSelector.test.tsx | 37 ++++++++++++++++++- ...前端架构】UI编辑会话模块边界-2026-08-19.md | 2 + 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx index 6ec0e1517..42c15c56f 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx @@ -18,9 +18,9 @@ export function ImageAssetSelector({ onChange: (value: string | null) => void; }) { const [open, setOpen] = useState(false); - const [failedPreviewIds, setFailedPreviewIds] = useState>( - () => new Set(), - ); + const [failedPreviewUrls, setFailedPreviewUrls] = useState< + Record + >({}); 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 (