抽出共享资源筛选条并接入画布标签筛选

- 新增 packages/shared 的 PlatformResourceFilterBar:搜索(可选)+ 功能分类 + 标签的受控筛选条,只承载表现与交互

- 资源画布筛选改为复用该组件,去掉画布内联的分类 PlatformSegmentedTabs,搜索框保持原样不动

- 画布新增标签筛选状态与派生标签库,标签与功能分类共存可叠加

- 新增 3 条组件测试覆盖受控渲染、回调转发与缺省行隐藏
This commit is contained in:
agent
2026-09-10 15:02:55 +08:00
parent 4d0cd425e1
commit 3379e6009e
3 changed files with 274 additions and 12 deletions
@@ -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<ResourceCanvasCategoryFilterBySortMode>(
defaultResourceCanvasCategoryFilters,
);
/**
* 画布标签筛选:与「按依赖 / 按类型」无关,是画布级的收窄条件;
* 与 `@` 面板、参考图弹窗的筛选状态互相独立。
*/
const [resourceCanvasActiveTags, setResourceCanvasActiveTags] = useState<
string[]
>([]);
const [resourceCanvasViewports, setResourceCanvasViewports] =
useState<ResourceCanvasViewportBySortMode>(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({
}}
/>
</label>
<PlatformSegmentedTabs
items={RESOURCE_REFERENCE_FILTERS}
activeId={resourceCategoryFilter}
onChange={showResourceCategoryFilter}
layout="scroll"
gap="sm"
frame="bare"
surface="transparent"
size="sm"
className="platform-theme platform-theme--light game-resource-category-filter"
<PlatformResourceFilterBar
ariaLabel="资源筛选"
categoryItems={RESOURCE_REFERENCE_FILTERS}
activeCategoryId={resourceCategoryFilter}
onCategoryChange={showResourceCategoryFilter}
tagItems={resourceTagLibrary}
activeTags={resourceCanvasActiveTags}
onToggleTag={(tag) => {
setResourceCanvasActiveTags((current) =>
current.includes(tag)
? current.filter((item) => item !== tag)
: [...current, tag],
);
}}
className="game-resource-category-filter"
/>
{resourceWorkbenchNotice ? (
<div className="game-resource-live-notice" role="status">
@@ -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(
<PlatformResourceFilterBar
ariaLabel="素材筛选"
search={{
value: '像素',
label: '搜索素材',
placeholder: '搜索名称',
onChange: () => {},
}}
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(
<PlatformResourceFilterBar
ariaLabel="素材筛选"
search={{ value: '', label: '搜索素材', onChange }}
categoryItems={CATEGORY_ITEMS}
activeCategoryId="all"
onCategoryChange={onCategoryChange}
tagItems={[{ tag: '像素风', assetCount: 2 }]}
activeTags={[]}
onToggleTag={onToggleTag}
/>,
);
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(
<PlatformResourceFilterBar
ariaLabel="素材筛选"
categoryItems={CATEGORY_ITEMS}
activeCategoryId="all"
onCategoryChange={() => {}}
/>,
);
expect(screen.queryByRole('searchbox')).toBeNull();
expect(screen.queryByRole('group', { name: '素材筛选标签' })).toBeNull();
expect(screen.getByRole('button', { name: '全部' })).toBeTruthy();
});
});
@@ -0,0 +1,131 @@
import { Search, Tag } from 'lucide-react';
import type { Ref } from 'react';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
export type PlatformResourceFilterOption<TId extends string = string> = {
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<HTMLInputElement>;
};
export type PlatformResourceFilterBarProps<TId extends string = string> = {
/** 控件组无障碍名称,例如「资源筛选」。 */
ariaLabel: string;
/** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */
search?: PlatformResourceFilterSearch;
/** 功能分类选项,通常来自唯一的分类口径常量。 */
categoryItems: readonly PlatformResourceFilterOption<TId>[];
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<TId extends string = string>({
ariaLabel,
search,
categoryItems,
activeCategoryId,
onCategoryChange,
tagItems,
activeTags = [],
onToggleTag,
className,
}: PlatformResourceFilterBarProps<TId>) {
const tagOptions = tagItems ?? [];
return (
<div
className={['flex min-w-0 flex-wrap items-center gap-2', className]
.filter(Boolean)
.join(' ')}
role="group"
aria-label={ariaLabel}
>
{search ? (
<label className="flex min-h-10 min-w-[9rem] flex-1 items-center gap-2 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/70 px-3">
<Search
className="h-4 w-4 shrink-0 text-[var(--platform-text-muted)]"
aria-hidden="true"
/>
<input
ref={search.inputRef}
type="search"
aria-label={search.label}
className="min-w-0 flex-1 border-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none"
value={search.value}
placeholder={search.placeholder}
onChange={(event) => search.onChange(event.currentTarget.value)}
/>
</label>
) : null}
<PlatformSegmentedTabs
items={categoryItems}
activeId={activeCategoryId}
onChange={onCategoryChange}
layout="scroll"
gap="sm"
frame="bare"
surface="transparent"
size="sm"
className="platform-theme platform-theme--light min-w-0 flex-none"
/>
{tagOptions.length > 0 ? (
<div
className="platform-category-chip-scroll min-w-0 flex-1"
role="group"
aria-label={`${ariaLabel}标签`}
>
{tagOptions.map((option) => {
const active = activeTags.includes(option.tag);
return (
<button
key={option.tag}
type="button"
aria-pressed={active}
className={tagChipClassName(active)}
onClick={() => onToggleTag?.(option.tag)}
>
<Tag className="h-3 w-3" aria-hidden="true" />
<span>{option.tag}</span>
<span aria-hidden="true">{option.assetCount}</span>
</button>
);
})}
</div>
) : null}
</div>
);
}