From 3379e6009e1ca1306724ac96b519ff4e64b5dd56 Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 10 Sep 2026 15:02:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=85=B1=E4=BA=AB=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=AD=9B=E9=80=89=E6=9D=A1=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=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} +
+ ); +}