From 4d0cd425e1f656d3874fa7b8ce37251d16ec6e7e Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 10 Sep 2026 14:54:06 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20AGC=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=BA=93=E8=81=9A=E5=90=88=E6=B4=BE=E7=94=9F=E7=BA=AF?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 gameCreationAppAssetTagLibrary:从 manifest assets[].tags 派生去重、稳定排序、含使用计数的标签库 - 标签归一化沿用写入路径 normalizeGameCreationAppAssetTags,同素材重复标签只计一次 - 新增 assetTagsMatchSelection / gameCreationAppAssetMatchesTags 标签筛选判据(AND、空选择不过滤) - 新增 5 条纯模块测试覆盖去重计数、顺序稳定、空标签与筛选判据 --- .../gameCreationAppAssetTagLibrary.test.ts | 65 ++++++++++++++++ .../gameCreationAppAssetTagLibrary.ts | 78 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts create mode 100644 packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts diff --git a/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts new file mode 100644 index 000000000..f2bd3b2be --- /dev/null +++ b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + assetTagsMatchSelection, + buildGameCreationAppAssetTagLibrary, + gameCreationAppAssetMatchesTags, +} from './gameCreationAppAssetTagLibrary'; + +describe('AI 游戏创作 App 标签库派生', () => { + it('dedupes tags and counts how many assets use each tag', () => { + const library = buildGameCreationAppAssetTagLibrary([ + { id: 'asset-a', tags: ['像素风', '主角'] }, + { id: 'asset-b', tags: ['像素风'] }, + { id: 'asset-c', tags: [] }, + { id: 'asset-d', tags: undefined }, + ]); + + expect(library).toEqual([ + { tag: '像素风', assetCount: 2, assetIds: ['asset-a', 'asset-b'] }, + { tag: '主角', assetCount: 1, assetIds: ['asset-a'] }, + ]); + }); + + it('keeps the order stable across input permutations', () => { + const first = buildGameCreationAppAssetTagLibrary([ + { id: 'asset-a', tags: ['b', 'a'] }, + { id: 'asset-b', tags: ['b', 'c'] }, + ]); + const second = buildGameCreationAppAssetTagLibrary([ + { id: 'asset-b', tags: ['c', 'b'] }, + { id: 'asset-a', tags: ['a', 'b'] }, + ]); + + expect(second).toEqual(first); + expect(first.map((entry) => entry.tag)).toEqual(['b', 'a', 'c']); + }); + + it('counts a repeated tag on one asset once and ignores blank tags', () => { + const library = buildGameCreationAppAssetTagLibrary([ + { id: 'asset-a', tags: ['像素风', '像素风', ' ', ''] }, + ]); + + expect(library).toEqual([ + { tag: '像素风', assetCount: 1, assetIds: ['asset-a'] }, + ]); + }); + + it('returns an empty library without registered assets', () => { + expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]); + }); + + it('requires every selected tag to match and treats an empty selection as no filter', () => { + const tags = ['像素风', '主角']; + + expect(assetTagsMatchSelection(tags, [])).toBe(true); + expect(assetTagsMatchSelection(tags, ['像素风'])).toBe(true); + expect(assetTagsMatchSelection(tags, ['像素风', '主角'])).toBe(true); + expect(assetTagsMatchSelection(tags, ['像素风', '场景'])).toBe(false); + expect(assetTagsMatchSelection(undefined, ['像素风'])).toBe(false); + expect(gameCreationAppAssetMatchesTags({ tags }, ['主角'])).toBe(true); + expect(gameCreationAppAssetMatchesTags({ tags: undefined }, ['主角'])).toBe( + false, + ); + }); +}); diff --git a/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts new file mode 100644 index 000000000..0ebcfdb71 --- /dev/null +++ b/packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts @@ -0,0 +1,78 @@ +import { + type GameCreationAppAssetManifestEntry, + gameCreationAppAssetTags, + normalizeGameCreationAppAssetTags, +} from './gameCreationApp'; + +/** + * 标签库里的一项。标签库是从 manifest `assets[].tags` 派生出来的数据, + * 不新增任何持久化字段:manifest 一改,标签库重算即可。 + */ +export interface GameCreationAppAssetTagLibraryEntry { + tag: string; + /** 使用该标签的已登记素材数量。 */ + assetCount: number; + /** 使用该标签的素材 id,按 id 稳定升序,与 manifest 中的写入顺序无关。 */ + assetIds: string[]; +} + +type AssetTagSource = Pick; + +/** + * 派生标签库:按标签去重、统计使用次数,并给出稳定总序。 + * + * 排序口径:使用次数降序 → 标签 `zh-CN` 本地化升序。 + * 两条规则都能在任意输入顺序下复现同一结果,因此标签筛选 UI 不会因为 + * manifest 里素材顺序变化而抖动。 + */ +export function buildGameCreationAppAssetTagLibrary( + assets: readonly AssetTagSource[], +): GameCreationAppAssetTagLibraryEntry[] { + const byTag = new Map(); + for (const asset of assets) { + // 归一化口径沿用写入路径:去空白、丢空串、同素材内去重。 + for (const tag of normalizeGameCreationAppAssetTags( + gameCreationAppAssetTags(asset), + )) { + const assetIds = byTag.get(tag); + if (!assetIds) { + byTag.set(tag, [asset.id]); + continue; + } + // 同一条素材重复写同一个标签只算一次。 + if (!assetIds.includes(asset.id)) { + assetIds.push(asset.id); + } + } + } + return Array.from(byTag, ([tag, assetIds]) => ({ + tag, + assetCount: assetIds.length, + assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)), + })).sort( + (left, right) => + right.assetCount - left.assetCount || + left.tag.localeCompare(right.tag, 'zh-CN'), + ); +} + +/** + * 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。 + * 与 `category` 筛选共存时由调用方叠加,本函数只看标签。 + */ +export function assetTagsMatchSelection( + tags: readonly string[] | undefined, + selectedTags: readonly string[], +): boolean { + if (selectedTags.length === 0) return true; + const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? [])); + return selectedTags.every((tag) => normalized.has(tag)); +} + +/** manifest 资产条目上的标签筛选,口径与 `assetTagsMatchSelection` 一致。 */ +export function gameCreationAppAssetMatchesTags( + asset: Pick, + selectedTags: readonly string[], +): boolean { + return assetTagsMatchSelection(gameCreationAppAssetTags(asset), selectedTags); +} From 3379e6009e1ca1306724ac96b519ff4e64b5dd56 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 10 Sep 2026 15:02:55 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=AD=9B=E9=80=89=E6=9D=A1=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=94=BB=E5=B8=83=E6=A0=87=E7=AD=BE=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 packages/shared 的 PlatformResourceFilterBar:搜索(可选)+ 功能分类 + 标签的受控筛选条,只承载表现与交互 - 资源画布筛选改为复用该组件,去掉画布内联的分类 PlatformSegmentedTabs,搜索框保持原样不动 - 画布新增标签筛选状态与派生标签库,标签与功能分类共存可叠加 - 新增 3 条组件测试覆盖受控渲染、回调转发与缺省行隐藏 --- .../src/view/project-development/index.tsx | 59 ++++++-- .../PlatformResourceFilterBar.test.tsx | 96 +++++++++++++ .../components/PlatformResourceFilterBar.tsx | 131 ++++++++++++++++++ 3 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/components/PlatformResourceFilterBar.test.tsx create mode 100644 packages/shared/src/components/PlatformResourceFilterBar.tsx diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 4ec5fa8d1..69c79c1f2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -43,7 +43,7 @@ import { import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; +import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar'; import type { GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, @@ -52,6 +52,10 @@ import type { GameIterationVersion, ProjectResourceCanvasLayoutMode, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + assetTagsMatchSelection, + buildGameCreationAppAssetTagLibrary, +} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary'; import { LocalGamePreviewFrame, type LocalGamePreviewInspectSelection, @@ -1197,6 +1201,13 @@ export default function ProjectDevelopmentView({ useState( defaultResourceCanvasCategoryFilters, ); + /** + * 画布标签筛选:与「按依赖 / 按类型」无关,是画布级的收窄条件; + * 与 `@` 面板、参考图弹窗的筛选状态互相独立。 + */ + const [resourceCanvasActiveTags, setResourceCanvasActiveTags] = useState< + string[] + >([]); const [resourceCanvasViewports, setResourceCanvasViewports] = useState(defaultResourceCanvasViewports); const resourceCanvasViewportTargetsRef = useRef( @@ -1753,6 +1764,11 @@ export default function ProjectDevelopmentView({ }, [resources, selectedResourceId]); const normalizedSearch = searchText.trim().toLowerCase(); const resourceCategoryFilter = resourceCanvasCategoryFilters[sortMode]; + /** 标签库是从 manifest `assets[].tags` 派生的数据,manifest 一改就重算。 */ + const resourceTagLibrary = useMemo( + () => buildGameCreationAppAssetTagLibrary(manifest.assets), + [manifest.assets], + ); const visibleResources = useMemo( () => canvasResources.filter((resource) => { @@ -1762,6 +1778,15 @@ export default function ProjectDevelopmentView({ ) { return false; } + // 标签筛选与功能分类筛选共存且可叠加:分类过了还得命中全部已选标签。 + if ( + !assetTagsMatchSelection( + resource.assetTags ?? [], + resourceCanvasActiveTags, + ) + ) { + return false; + } return normalizedSearch ? [ resource.label, @@ -1771,7 +1796,12 @@ export default function ProjectDevelopmentView({ ].some((value) => value.toLowerCase().includes(normalizedSearch)) : true; }), - [canvasResources, normalizedSearch, resourceCategoryFilter], + [ + canvasResources, + normalizedSearch, + resourceCanvasActiveTags, + resourceCategoryFilter, + ], ); const visibleResourceIds = useMemo( () => new Set(visibleResources.map((resource) => resource.id)), @@ -5365,16 +5395,21 @@ export default function ProjectDevelopmentView({ }} /> - { + setResourceCanvasActiveTags((current) => + current.includes(tag) + ? current.filter((item) => item !== tag) + : [...current, tag], + ); + }} + className="game-resource-category-filter" /> {resourceWorkbenchNotice ? (
diff --git a/packages/shared/src/components/PlatformResourceFilterBar.test.tsx b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx new file mode 100644 index 000000000..e7d459d38 --- /dev/null +++ b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx @@ -0,0 +1,96 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { PlatformResourceFilterBar } from './PlatformResourceFilterBar'; + +const CATEGORY_ITEMS = [ + { id: 'all', label: '全部' }, + { id: 'character', label: '角色与对象' }, +]; + +describe('PlatformResourceFilterBar', () => { + test('renders a controlled search box, category tabs and tag chips', () => { + render( + {}, + }} + categoryItems={CATEGORY_ITEMS} + activeCategoryId="character" + onCategoryChange={() => {}} + tagItems={[ + { tag: '像素风', assetCount: 2 }, + { tag: '主角', assetCount: 1 }, + ]} + activeTags={['主角']} + onToggleTag={() => {}} + />, + ); + + const search = screen.getByLabelText('搜索素材') as HTMLInputElement; + expect(search.value).toBe('像素'); + expect(search.getAttribute('placeholder')).toBe('搜索名称'); + expect( + screen + .getByRole('button', { name: '角色与对象' }) + .getAttribute('aria-pressed'), + ).toBe('true'); + expect( + screen.getByRole('button', { name: /主角/ }).getAttribute('aria-pressed'), + ).toBe('true'); + expect( + screen + .getByRole('button', { name: /像素风/ }) + .getAttribute('aria-pressed'), + ).toBe('false'); + }); + + test('reports search, category and tag changes without owning state', () => { + const onChange = vi.fn(); + const onCategoryChange = vi.fn(); + const onToggleTag = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByLabelText('搜索素材'), { + target: { value: '主角' }, + }); + fireEvent.click(screen.getByRole('button', { name: '角色与对象' })); + fireEvent.click(screen.getByRole('button', { name: /像素风/ })); + + expect(onChange).toHaveBeenCalledWith('主角'); + expect(onCategoryChange).toHaveBeenCalledWith('character'); + expect(onToggleTag).toHaveBeenCalledWith('像素风'); + }); + + test('hides the search row and the tag row when the host omits them', () => { + render( + {}} + />, + ); + + expect(screen.queryByRole('searchbox')).toBeNull(); + expect(screen.queryByRole('group', { name: '素材筛选标签' })).toBeNull(); + expect(screen.getByRole('button', { name: '全部' })).toBeTruthy(); + }); +}); diff --git a/packages/shared/src/components/PlatformResourceFilterBar.tsx b/packages/shared/src/components/PlatformResourceFilterBar.tsx new file mode 100644 index 000000000..7d24f96f8 --- /dev/null +++ b/packages/shared/src/components/PlatformResourceFilterBar.tsx @@ -0,0 +1,131 @@ +import { Search, Tag } from 'lucide-react'; +import type { Ref } from 'react'; + +import { PlatformSegmentedTabs } from './PlatformSegmentedTabs'; + +export type PlatformResourceFilterOption = { + id: TId; + label: string; +}; + +export type PlatformResourceTagOption = { + tag: string; + assetCount: number; +}; + +export type PlatformResourceFilterSearch = { + value: string; + label: string; + placeholder?: string; + onChange: (value: string) => void; + inputRef?: Ref; +}; + +export type PlatformResourceFilterBarProps = { + /** 控件组无障碍名称,例如「资源筛选」。 */ + ariaLabel: string; + /** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */ + search?: PlatformResourceFilterSearch; + /** 功能分类选项,通常来自唯一的分类口径常量。 */ + categoryItems: readonly PlatformResourceFilterOption[]; + activeCategoryId: TId; + onCategoryChange: (id: TId) => void; + /** 派生标签库;为空时不渲染标签行。 */ + tagItems?: readonly PlatformResourceTagOption[]; + /** 已选标签(多选叠加)。 */ + activeTags?: readonly string[]; + onToggleTag?: (tag: string) => void; + className?: string; +}; + +function tagChipClassName(active: boolean) { + return [ + 'platform-category-chip gap-1.5 px-2.5 text-xs font-bold', + active ? 'platform-category-chip--active' : null, + ] + .filter(Boolean) + .join(' '); +} + +/** + * 资源搜索 + 功能分类 + 标签的筛选条。 + * + * 只承载表现与交互:筛选条件(搜索词、分类、已选标签)与筛后结果都由宿主持有, + * 因此同一个组件可以在资源画布、参考图弹窗等位置各自持有一份互不影响的筛选状态。 + */ +export function PlatformResourceFilterBar({ + ariaLabel, + search, + categoryItems, + activeCategoryId, + onCategoryChange, + tagItems, + activeTags = [], + onToggleTag, + className, +}: PlatformResourceFilterBarProps) { + const tagOptions = tagItems ?? []; + + return ( +
+ {search ? ( + + ) : null} + + {tagOptions.length > 0 ? ( +
+ {tagOptions.map((option) => { + const active = activeTags.includes(option.tag); + return ( + + ); + })} +
+ ) : null} +
+ ); +} From 3508957eda0f3f8f02eeb3a4a69ef7b11f3a933a Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 10 Sep 2026 15:13:40 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20AGC=20V3=20C9=20?= =?UTF-8?q?=E5=8F=82=E8=80=83=E5=9B=BE=E9=80=89=E6=8B=A9=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E4=B8=8E=E6=8F=90=E4=BA=A4=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ImageCanvasProjectAssetPickerDialog:独立弹窗,网格卡片、多选、筛选+搜索、清空/确认/取消,筛选状态自持 - 新增 projectAssetReferencePickerModel:素材分类与筛选判据、素材 id 快照引用构造、弹窗引用前缀判定 - 生成 composer 参考图来源菜单新增「从项目素材中选择」,由宿主决定是否出现,不改变原有从画布选择与上传两条路径 - 新增 replaceProjectAssetPickerReferences:确认时整组替换素材库来源的参考图,保留画布点选与上传的参考图 - 素材 id 经 sourceAssetId 走既有 createGenerationInputReference 落进 generationInputs.references,不新增后端接口 - 新增 13 条测试覆盖弹窗交互、筛选叠加、快照落库与整组替换语义 --- ...ImageCanvasBasicGenerationComposerView.tsx | 17 ++ .../image-editor/ImageCanvasEditorView.tsx | 1 + .../ImageCanvasGenerationComposerView.tsx | 4 + .../ImageCanvasGenerationDialogModel.ts | 34 +++ ...ageCanvasProjectAssetPickerDialog.test.tsx | 189 +++++++++++++++ .../ImageCanvasProjectAssetPickerDialog.tsx | 221 ++++++++++++++++++ .../projectAssetReferencePickerModel.test.ts | 207 ++++++++++++++++ .../projectAssetReferencePickerModel.ts | 93 ++++++++ .../useImageCanvasGenerationSurface.tsx | 62 +++++ 9 files changed, 828 insertions(+) create mode 100644 src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx create mode 100644 src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx create mode 100644 src/components/image-editor/projectAssetReferencePickerModel.test.ts create mode 100644 src/components/image-editor/projectAssetReferencePickerModel.ts diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 2a58554a1..fad81478e 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -60,6 +60,11 @@ type ImageCanvasBasicGenerationComposerViewProps = { ) => CSSProperties; onRequestUpload: (target: UploadTarget) => void; onPickReferenceFromCanvas?: () => void; + /** + * 从项目素材库选择参考图。传入时参考图来源菜单多一项, + * 点击由宿主打开独立的参考图选择弹窗。 + */ + onRequestProjectAssetPicker?: () => void; onToggleReferenceMenu?: () => void; onRememberImageModel?: (model: string) => void; hasPendingImageReferenceUploads?: boolean; @@ -115,6 +120,7 @@ export function ImageCanvasBasicGenerationComposerView({ buildPortalMenuStyle = () => ({}), onRequestUpload, onPickReferenceFromCanvas, + onRequestProjectAssetPicker, onToggleReferenceMenu, onRememberImageModel = () => {}, hasPendingImageReferenceUploads = false, @@ -419,6 +425,17 @@ export function ImageCanvasBasicGenerationComposerView({ > 上传图片 + {onRequestProjectAssetPicker ? ( + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestProjectAssetPicker(); + }} + > + 从项目素材中选择 + + ) : null} , ) : null} diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 5abf3d6cf..8aca4173e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1465,6 +1465,7 @@ export function ImageCanvasEditorView({ setMetadataLayer, setImageContextMenu, requestUpload, + projectAssets: assets, persistGeneratedAsset, persistUpdatedLayerResource, projectId, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index e0a5406ae..883a8499f 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -173,6 +173,8 @@ type ImageCanvasGenerationComposerViewProps = { setIsPickingUiDesignSpecFromCanvas: Dispatch>; onOpenSpecDialog: (specType: SpecGenerationType) => void; onRequestUpload: (target: UploadTarget) => void; + /** 参考图来源菜单里的「从项目素材中选择」;不传则不显示该项。 */ + onRequestProjectAssetPicker?: () => void; onSubmitImageGeneration: (dialog: GenerateDialogState) => void; onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void; onSubmitQuickEdit: () => void; @@ -1489,6 +1491,7 @@ export function ImageCanvasGenerationComposerView({ hasPendingImageReferenceUploads = false, onOpenSpecDialog, onRequestUpload, + onRequestProjectAssetPicker, onSubmitImageGeneration, onSubmitIconSpritesheetGeneration, onSubmitQuickEdit, @@ -1563,6 +1566,7 @@ export function ImageCanvasGenerationComposerView({ renderEditorPortal={renderEditorPortal} buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} + onRequestProjectAssetPicker={onRequestProjectAssetPicker} onToggleReferenceMenu={() => setIsGenerationReferenceMenuOpen((open) => !open) } diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index b59c7187b..6c6a5fb0f 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -13,6 +13,7 @@ import type { CanvasLayer, CanvasViewport, CharacterAnimationPanelState, + CharacterReferenceImage, GenerateDialogState, PublicationMaterialsWorkflowId, QuickEditPanelState, @@ -56,6 +57,7 @@ import { SPEC_FRAME_ORIGINAL_SIZE, } from './ImageCanvasGenerationModel'; import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; +import { isProjectAssetPickerReference } from './projectAssetReferencePickerModel'; type CanvasSize = { width: number; height: number }; type SourceGenerationDialogDraftContext = { @@ -2208,6 +2210,38 @@ export function appendGenerationReference( : dialog; } +/** + * 参考图弹窗确认:把「从项目素材中选择」进来的参考图整组替换, + * 画布点选与上传得到的参考图保持原样。 + * 素材 id 通过 `sourceAssetId` 随引用进入提交数据,即本次任务的参考图 ID 快照。 + */ +export function replaceProjectAssetPickerReferences( + dialog: GenerateDialogState | null, + references: CharacterReferenceImage[], +): GenerateDialogState | null { + if ( + !dialog || + (dialog.mode !== 'generate' && + dialog.mode !== 'scene' && + dialog.mode !== 'quick-edit' && + dialog.mode !== 'icon' && + dialog.mode !== 'ui-design') + ) { + return dialog; + } + return { + ...resetFailedGenerationDialog(dialog), + generationReferences: appendLimitedImageReferences( + (dialog.generationReferences ?? []).filter( + (reference) => !isProjectAssetPickerReference(reference), + ), + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), + composerOpen: true, + }; +} + export function appendPublicationReference( dialog: GenerateDialogState | null, layer: CanvasLayer, diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx new file mode 100644 index 000000000..89dca93c4 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx @@ -0,0 +1,189 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EditorAsset } from './ImageCanvasEditorTypes'; +import { ImageCanvasProjectAssetPickerDialog } from './ImageCanvasProjectAssetPickerDialog'; + +function createAsset(overrides: Partial): EditorAsset { + return { + id: 'asset-1', + label: '素材 1', + src: 'https://example.test/asset-1.png', + mediaType: 'image', + width: 64, + height: 64, + folderId: 'folder-1', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + ...overrides, + }; +} + +const ASSETS: EditorAsset[] = [ + createAsset({ id: 'asset-hero', label: '主角立绘' }), + createAsset({ + id: 'asset-town', + label: '小镇背景', + mediaType: 'video', + src: 'https://example.test/town.mp4', + }), + createAsset({ + id: 'asset-bgm', + label: '背景音乐', + mediaType: 'audio', + src: 'https://example.test/bgm.mp3', + }), +]; + +describe('ImageCanvasProjectAssetPickerDialog', () => { + it('selects several assets, shows the count and returns the id snapshot', () => { + const onConfirm = vi.fn(); + render( + {}} + onConfirm={onConfirm} + />, + ); + + fireEvent.click(screen.getByRole('option', { name: '选择参考图主角立绘' })); + fireEvent.click(screen.getByRole('option', { name: '选择参考图小镇背景' })); + + expect(screen.getByText('已选 2 个')).toBeTruthy(); + expect( + screen + .getByRole('option', { name: '选择参考图主角立绘' }) + .getAttribute('aria-selected'), + ).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: '确认选择参考图' })); + expect(onConfirm).toHaveBeenCalledWith(['asset-hero', 'asset-town']); + }); + + it('clears only the current selection and keeps cancel free of side effects', () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '清空' })); + expect(screen.getByText('已选 0 个')).toBeTruthy(); + expect(onConfirm).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: '取消' })); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('deselects one asset from the selected chips', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + const selectedRow = screen.getByLabelText('已选参考图'); + fireEvent.click( + within(selectedRow).getByRole('button', { name: '取消参考图主角立绘' }), + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + }); + + it('filters by category and search text with dialog-local state', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '视频' })); + expect( + screen.queryByRole('option', { name: '选择参考图主角立绘' }), + ).toBeNull(); + expect( + screen.getByRole('option', { name: '选择参考图小镇背景' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '全部' })); + fireEvent.change(screen.getByLabelText('搜索参考图素材'), { + target: { value: '音乐' }, + }); + expect( + screen.getByRole('option', { name: '选择参考图背景音乐' }), + ).toBeTruthy(); + expect( + screen.queryByRole('option', { name: '选择参考图小镇背景' }), + ).toBeNull(); + }); + + it('restarts from the caller-provided selection each time it opens', () => { + const { rerender } = render( + {}} + onConfirm={() => {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: '清空' })); + expect(screen.getByText('已选 0 个')).toBeTruthy(); + + rerender( + {}} + onConfirm={() => {}} + />, + ); + rerender( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + }); + + it('shows an empty state when the project has no registered assets', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx new file mode 100644 index 000000000..b49a1a872 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx @@ -0,0 +1,221 @@ +import { Check, ImageIcon, Music, Search, Video } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; + +import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar'; +import { UnifiedModal } from '../common/UnifiedModal'; +import type { EditorAsset } from './ImageCanvasEditorTypes'; +import { + PROJECT_ASSET_PICKER_CATEGORY_OPTIONS, + projectAssetMatchesPickerFilter, + type ProjectAssetPickerCategory, + projectAssetPickerCategory, +} from './projectAssetReferencePickerModel'; + +type ImageCanvasProjectAssetPickerDialogProps = { + open: boolean; + /** 项目已登记素材;弹窗只负责选择,不改素材本身。 */ + assets: readonly EditorAsset[]; + /** 打开时的初始选择,取消时原选择保持不变。 */ + selectedAssetIds: readonly string[]; + onCancel: () => void; + onConfirm: (assetIds: string[]) => void; +}; + +function assetIcon(asset: EditorAsset) { + const category = projectAssetPickerCategory(asset); + if (category === 'video') { + return