diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index a9c1dc758..a1d0aecea 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6204,6 +6204,25 @@ iframe.preview-frame { color: #b5582f; } +/* + * 筛选浮层:与搜索浮层同一个右下角锚点、同一套向上展开口径(`bottom: 58px` = + * Dock 的 `bottom: 14px` + Dock 高度 `34px` + 10px 间隙,`z-index: 40` 高于画本场景 + * 的 20),所以两者永远不会落到栏目标题栏那条带上,也不会互相遮挡视线。 + * + * 定位留在宿主:共享的 `PlatformFilterPanel` 不自带 position,由这层给出锚点。 + */ +.game-resource-filter-panel { + position: absolute; + right: 14px; + bottom: 58px; + z-index: 40; +} + +.game-resource-filter-panel__card { + max-height: calc(100% - 72px); + overflow-y: auto; +} + .game-resource-search input { width: 100%; min-width: 0; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx new file mode 100644 index 000000000..4916b172c --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx @@ -0,0 +1,191 @@ +import { type RefObject, useEffect, useId, useRef } from 'react'; + +import { + PlatformFilterPanel, + PlatformFilterPanelField, +} from '../../../../../packages/shared/src/components/PlatformFilterPanel'; +import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge'; +import { + PlatformSelectField, + PlatformTextField, +} from '../../../../../packages/shared/src/components/PlatformTextField'; +import { + RESOURCE_FILTER_REGION_OPTIONS, + type ResourceFilterRegion, + type ResourceFilterTagOption, +} from './resourceCanvasFilterModel'; + +/** + * 已选标签的 chip 用与既有筛选条同一套类名,保持「选中的标签长什么样」只有一处实现。 + * `PlatformResourceFilterBar` 自己渲染未选标签时用的是同一个类。 + */ +function resourceFilterTagChipClassName(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(' '); +} + +type ResourceFilterPanelProps = { + onClose: () => void; + /** 关键词与画布搜索浮层共用同一份状态,这里只转发。 */ + keyword: string; + onKeywordChange: (value: string) => void; + /** 区域即画布当前栏目;本面板不改动栏目,只把选择交给宿主。 */ + region: ResourceFilterRegion; + onRegionChange: (region: ResourceFilterRegion) => void; + /** 已选标签,AND 语义。 */ + activeTags: readonly string[]; + onToggleTag: (tag: string) => void; + /** 标签库由宿主按当前区域派生后传入。 */ + tagItems: readonly ResourceFilterTagOption[]; + /** 浮层自己与触发按钮:点按钮只走按钮的开合,不被判成外部点击。 */ + triggerRef: RefObject; +}; + +/** + * 资源画布筛选浮层。 + * + * 领域规则都留在这一层:区域取值就是画布栏目、关键词与搜索浮层共用一份状态、 + * 标签用 AND 语义。共享层只提供浮层外壳与表单原语,不知道这些概念。 + */ +export function ResourceFilterPanel({ + onClose, + keyword, + onKeywordChange, + region, + onRegionChange, + activeTags, + onToggleTag, + tagItems, + triggerRef, +}: ResourceFilterPanelProps) { + const panelRef = useRef(null); + const idPrefix = useId(); + + /** + * 「点外部 / Esc 关闭」。与既有搜索浮层同一口径:document 上同时接管 click 与 + * Escape,点在边界外即关、点在边界内保留;Escape 在 document 上截断,画布全局的 + * Esc(清选中)不会跟着一起触发。 + * + * 这里不直接复用美术画布的 `useImageCanvasFloatingOptionDismiss`:那个 hook 判定 + * 「焦点是否在浮层里」用的是它自己的 `.image-canvas-editor__portal-menu` 类,本浮层 + * 不匹配,会导致 Esc 关闭后焦点不回到触发按钮,所以按同一口径在这里写。 + */ + useEffect(() => { + if (typeof document === 'undefined') return undefined; + + const isInsideBoundary = (target: EventTarget | null) => { + if (!(target instanceof Node)) return false; + return ( + panelRef.current?.contains(target) === true || + triggerRef.current?.contains(target) === true + ); + }; + + const handleClick = (event: MouseEvent) => { + if (isInsideBoundary(event.target)) return; + onClose(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + // 画布全局快捷键在 window 上监听同一个 Escape;浮层打开时 Escape 归浮层所有。 + event.stopPropagation(); + const shouldRestoreFocus = isInsideBoundary(document.activeElement); + onClose(); + if (shouldRestoreFocus) { + triggerRef.current?.focus(); + } + }; + + document.addEventListener('click', handleClick); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('click', handleClick); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose, triggerRef]); + + return ( +
+ + + onKeywordChange(event.currentTarget.value)} + /> + + + + + onRegionChange(event.currentTarget.value as ResourceFilterRegion) + } + > + {RESOURCE_FILTER_REGION_OPTIONS.map((option) => ( + + ))} + + + + {/* + 「状态」字段不做:manifest 资产与资源投影都没有状态事实源,唯一的候选状态 + (任务状态、附件导入状态)在投影里已被上游过滤掉,硬用就是恒单值的假筛选。 + 这里不渲染占位下拉。 + */} + {tagItems.length > 0 ? ( + +
+ {tagItems.map((option) => { + const active = activeTags.includes(option.tag); + return ( + + ); + })} +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasFilterModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasFilterModel.ts index 528a9f4e3..4cef9f6a1 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasFilterModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasFilterModel.ts @@ -123,6 +123,20 @@ export function filterCanvasResources( ); } +/** + * 筛选条件是否生效。 + * + * 供宿主给 Dock 按钮打高亮用:条件不会随浮层收起一起消失,按钮必须能看出 + * 「当前有筛选」,否则用户会以为资源丢了——沿用搜索按钮既有的 `is-active` 口径。 + */ +export function resourceFilterIsActive(filter: ResourceCanvasFilter) { + return ( + filter.region !== RESOURCE_FILTER_ALL_REGION || + filter.tags.length > 0 || + filter.keyword.trim().length > 0 + ); +} + /** * 从当前资源集合派生可用标签库(去重、按使用次数降序)。 * diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts index a058916af..631338e90 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts @@ -5,6 +5,7 @@ import { filterCanvasResources, RESOURCE_FILTER_ALL_REGION, RESOURCE_FILTER_REGION_OPTIONS, + resourceFilterIsActive, resourceFilterRegionLabel, resourceMatchesCanvasFilter, resourceMatchesKeyword, @@ -255,6 +256,25 @@ describe('资源画布筛选模型', () => { expect(resourceMatchesRegion(UI_RESOURCE, 'character')).toBe(false); }); + it('筛选生效判据:区域非全部、或有标签、或有关键词', () => { + expect( + resourceFilterIsActive({ region: 'all', tags: [], keyword: '' }), + ).toBe(false); + // 纯空白关键词等同没有条件,否则按钮会为一次空输入长期高亮。 + expect( + resourceFilterIsActive({ region: 'all', tags: [], keyword: ' ' }), + ).toBe(false); + expect( + resourceFilterIsActive({ region: 'audio', tags: [], keyword: '' }), + ).toBe(true); + expect( + resourceFilterIsActive({ region: 'all', tags: ['主页'], keyword: '' }), + ).toBe(true); + expect( + resourceFilterIsActive({ region: 'all', tags: [], keyword: '按钮' }), + ).toBe(true); + }); + it('标签库只统计传入区域内的资源,按使用次数降序、同次数按标签升序', () => { // 两个标签各被两条资源使用,次数相同 → 回退到 `zh-CN` 升序:节日 < 主页。 expect(buildResourceFilterTagOptions(ALL_RESOURCES)).toEqual([ diff --git a/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx new file mode 100644 index 000000000..d84125a85 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx @@ -0,0 +1,166 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ResourceFilterPanel } from '../src/view/project-development/ResourceFilterPanel'; + +function renderPanel( + overrides: { + keyword?: string; + region?: 'all' | 'ui-interaction' | 'character'; + activeTags?: string[]; + tagItems?: Array<{ tag: string; assetCount: number }>; + onKeywordChange?: (value: string) => void; + onRegionChange?: (region: string) => void; + onToggleTag?: (tag: string) => void; + onClose?: () => void; + } = {}, +) { + const triggerRef = createRef(); + const handlers = { + onClose: overrides.onClose ?? vi.fn(), + onKeywordChange: overrides.onKeywordChange ?? vi.fn(), + onRegionChange: overrides.onRegionChange ?? vi.fn(), + onToggleTag: overrides.onToggleTag ?? vi.fn(), + }; + const view = render( +
+ + void} + activeTags={overrides.activeTags ?? []} + onToggleTag={handlers.onToggleTag} + tagItems={ + overrides.tagItems ?? [ + { tag: '主页', assetCount: 2 }, + { tag: '节日', assetCount: 1 }, + ] + } + triggerRef={triggerRef} + /> +
, + ); + return { ...handlers, ...view, triggerRef }; +} + +describe('ResourceFilterPanel', () => { + it('渲染关键词、区域与标签三个字段,且不渲染「状态」占位字段', () => { + renderPanel(); + + expect(screen.getByRole('dialog', { name: '筛选资源' })).toBeTruthy(); + expect(screen.getByLabelText('查找素材')).toBeTruthy(); + expect(screen.getByLabelText('所在区域')).toBeTruthy(); + expect(screen.getByRole('group', { name: '自定义标签' })).toBeTruthy(); + + // 「状态」没有事实源,不得出现占位下拉。 + expect(screen.queryByLabelText('状态')).toBeNull(); + expect(screen.queryByText('全部状态')).toBeNull(); + }); + + it('区域下拉取自现行分区栏目,顺序与画布栏目同源', () => { + renderPanel(); + const region = screen.getByLabelText('所在区域') as HTMLSelectElement; + const values = Array.from(region.options, (option) => option.value); + + expect(values).toEqual([ + 'all', + 'ui-interaction', + 'character', + 'scene', + 'audio', + 'document', + 'unclassified', + 'version', + ]); + expect(Array.from(region.options, (option) => option.textContent)).toEqual([ + '全部区域', + 'UI 交互', + '角色与对象', + '场景与环境', + '音频', + '文档', + '待归类', + '项目版本', + ]); + }); + + it('关键词输入把值交回宿主,且回显宿主给的值', () => { + const onKeywordChange = vi.fn(); + renderPanel({ keyword: '按钮', onKeywordChange }); + + const input = screen.getByLabelText('查找素材') as HTMLInputElement; + expect(input.value).toBe('按钮'); + + fireEvent.change(input, { target: { value: '主角' } }); + expect(onKeywordChange).toHaveBeenCalledWith('主角'); + }); + + it('切换区域只把选择交回宿主(区域即画布栏目,由宿主切栏目)', () => { + const onRegionChange = vi.fn(); + renderPanel({ region: 'all', onRegionChange }); + + fireEvent.change(screen.getByLabelText('所在区域'), { + target: { value: 'character' }, + }); + expect(onRegionChange).toHaveBeenCalledWith('character'); + }); + + it('标签是受控多选,按下状态来自宿主,点击交回标签值', () => { + const onToggleTag = vi.fn(); + renderPanel({ activeTags: ['主页'], onToggleTag }); + + const home = screen.getByRole('button', { name: /主页/ }); + const festival = screen.getByRole('button', { name: /节日/ }); + expect(home.getAttribute('aria-pressed')).toBe('true'); + expect(festival.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(festival); + expect(onToggleTag).toHaveBeenCalledWith('节日'); + }); + + it('标签库为空时不渲染标签字段', () => { + renderPanel({ tagItems: [] }); + expect(screen.queryByRole('group', { name: '自定义标签' })).toBeNull(); + }); + + it('右上角关闭键与 Escape 都关浮层,点外部也关;点触发按钮不算外部', () => { + const onClose = vi.fn(); + const first = renderPanel({ onClose }); + fireEvent.click(screen.getByRole('button', { name: '关闭筛选' })); + expect(onClose).toHaveBeenCalledTimes(1); + first.unmount(); + + const second = renderPanel({ onClose: vi.fn() }); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(second.onClose).toHaveBeenCalledTimes(1); + + // 点浮层内部不关。 + fireEvent.click(screen.getByLabelText('查找素材')); + expect(second.onClose).toHaveBeenCalledTimes(1); + + // 点触发按钮不关:按钮自己负责开合,不能被判成外部点击。 + fireEvent.click(second.triggerRef.current!); + expect(second.onClose).toHaveBeenCalledTimes(1); + + // 点浮层外部才关。 + fireEvent.click(document.body); + expect(second.onClose).toHaveBeenCalledTimes(2); + }); + + it('Escape 关闭后把焦点还给触发按钮', () => { + const { triggerRef } = renderPanel({ onClose: vi.fn() }); + const input = screen.getByLabelText('查找素材'); + input.focus(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(document.activeElement).toBe(triggerRef.current); + }); +});