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} +
+ ); +}