抽出共享资源筛选条并接入画布标签筛选
- 新增 packages/shared 的 PlatformResourceFilterBar:搜索(可选)+ 功能分类 + 标签的受控筛选条,只承载表现与交互 - 资源画布筛选改为复用该组件,去掉画布内联的分类 PlatformSegmentedTabs,搜索框保持原样不动 - 画布新增标签筛选状态与派生标签库,标签与功能分类共存可叠加 - 新增 3 条组件测试覆盖受控渲染、回调转发与缺省行隐藏
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user