新增资源筛选浮层组件与右下角锚点样式

- ResourceFilterPanel.tsx:AGC 侧筛选浮层,领域规则留在这一层——区域取值就是画布当前栏目(只把选择交回宿主,面板自己不改栏目也不另存区域),关键词与画布搜索浮层共用同一份状态,标签走 AND 语义;共享层只提供浮层外壳与表单原语
- ResourceFilterPanel.tsx:不渲染「状态」字段,并按注释写明理由——manifest 资产与资源投影都没有状态事实源,唯二候选(任务状态、附件导入状态)在投影里已被上游过滤,硬用即恒单值假筛选
- ResourceFilterPanel.tsx:「点外部 / Esc 关闭」按既有搜索浮层口径在 document 上同时接管 click 与 Escape,点触发按钮不被判成外部;不复用美术画布那个 dismiss hook,因为它判定焦点是否在浮层里用的是自己的 portal-menu 类,本浮层不匹配会导致 Esc 关闭后焦点不回触发按钮
- ResourceFilterPanel.tsx:区域下拉由 RESOURCE_FILTER_REGION_OPTIONS 派生,显示名与 @ 面板同一份口径;标签 chip 用既有 platform-category-chip 类,选中态不另造样式
- resourceCanvasFilterModel.ts:筛选生效判据 resourceFilterIsActive 落在模型层,供宿主给 Dock 按钮打高亮(条件不随浮层收起一起消失);纯空白关键词不计入,避免按钮为一次空输入长期高亮
- styles.css:新增 .game-resource-filter-panel 右下角锚点(与搜索浮层同一 bottom / z-index 口径,向上展开),定位留在宿主,共享浮层外壳保持不自带 position
- tests/resourceFilterPanel.test.tsx 与 tests/resourceCanvasFilterModel.test.ts:覆盖三字段渲染且无「状态」占位、区域选项取值与中文显示名逐个钉住、关键词回显与回调、区域切换只回调、标签受控多选与 aria-pressed、空标签库隐藏该字段、关闭键 / Escape / 点外部 / 点触发按钮四种关闭口径、Escape 后焦点回触发按钮、筛选生效判据
This commit is contained in:
2026-09-11 19:55:22 +08:00
parent 70256c7585
commit 1c43f2c2b2
5 changed files with 410 additions and 0 deletions
+19
View File
@@ -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;
@@ -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<HTMLElement | null>;
};
/**
* 资源画布筛选浮层。
*
* 领域规则都留在这一层:区域取值就是画布栏目、关键词与搜索浮层共用一份状态、
* 标签用 AND 语义。共享层只提供浮层外壳与表单原语,不知道这些概念。
*/
export function ResourceFilterPanel({
onClose,
keyword,
onKeywordChange,
region,
onRegionChange,
activeTags,
onToggleTag,
tagItems,
triggerRef,
}: ResourceFilterPanelProps) {
const panelRef = useRef<HTMLElement | null>(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 (
<div ref={panelRef} className="game-resource-filter-panel">
<PlatformFilterPanel
ariaLabel="筛选资源"
title="筛选"
onClose={onClose}
className="game-resource-filter-panel__card"
>
<PlatformFilterPanelField
label="查找素材"
controlId={`${idPrefix}-keyword`}
>
<PlatformTextField
id={`${idPrefix}-keyword`}
type="search"
aria-label="查找素材"
placeholder="名称、路径或类型"
density="compact"
value={keyword}
onChange={(event) => onKeywordChange(event.currentTarget.value)}
/>
</PlatformFilterPanelField>
<PlatformFilterPanelField
label="所在区域"
controlId={`${idPrefix}-region`}
>
<PlatformSelectField
id={`${idPrefix}-region`}
aria-label="所在区域"
density="compact"
value={region}
onChange={(event) =>
onRegionChange(event.currentTarget.value as ResourceFilterRegion)
}
>
{RESOURCE_FILTER_REGION_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</PlatformSelectField>
</PlatformFilterPanelField>
{/*
「状态」字段不做:manifest 资产与资源投影都没有状态事实源,唯一的候选状态
(任务状态、附件导入状态)在投影里已被上游过滤掉,硬用就是恒单值的假筛选。
这里不渲染占位下拉。
*/}
{tagItems.length > 0 ? (
<PlatformFilterPanelField label="自定义标签">
<div
className="flex min-w-0 flex-wrap gap-1.5"
role="group"
aria-label="自定义标签"
>
{tagItems.map((option) => {
const active = activeTags.includes(option.tag);
return (
<button
key={option.tag}
type="button"
aria-pressed={active}
className={resourceFilterTagChipClassName(active)}
onClick={() => onToggleTag(option.tag)}
>
<span>{option.tag}</span>
<PlatformPillBadge tone="neutral" size="xs">
{option.assetCount}
</PlatformPillBadge>
</button>
);
})}
</div>
</PlatformFilterPanelField>
) : null}
</PlatformFilterPanel>
</div>
);
}
@@ -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
);
}
/**
* 从当前资源集合派生可用标签库(去重、按使用次数降序)。
*
@@ -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([
@@ -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<HTMLButtonElement>();
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(
<div>
<button ref={triggerRef} type="button">
</button>
<ResourceFilterPanel
onClose={handlers.onClose}
keyword={overrides.keyword ?? ''}
onKeywordChange={handlers.onKeywordChange}
region={overrides.region ?? 'all'}
onRegionChange={handlers.onRegionChange as (region: 'all') => void}
activeTags={overrides.activeTags ?? []}
onToggleTag={handlers.onToggleTag}
tagItems={
overrides.tagItems ?? [
{ tag: '主页', assetCount: 2 },
{ tag: '节日', assetCount: 1 },
]
}
triggerRef={triggerRef}
/>
</div>,
);
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);
});
});