接线资源筛选浮层并把资源卡旁两个浮层改成互斥
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m16s
Project CI / Native shell tests (pull_request) Successful in 19m58s

- index.tsx:新增 resourceFilterOpen 与 resourceFilterTags 两个 state,区域不新增 state(唯一新增的筛选相关 state 是已选标签)
- index.tsx:可见资源由内联关键词过滤改为调用 filterCanvasResources,维度为区域 / 标签 / 关键词;区域由 resourceBookState 派生(不在分页栏目里时为「全部区域」),因此用户用滚轮 / 总览卡片 / 下一页换栏目时区域自动跟随,无需双向同步
- index.tsx:标签库按「区域 + 关键词」派生并故意不含已选标签——若拿已按标签收窄的集合派生,选了一个标签后列表只剩该标签,用户再也选不出第二个标签,AND 多选会形同废掉
- index.tsx:Dock 上紧邻搜索按钮新增筛选按钮,复用 game-resource-book-zoom-button 与 is-active 口径;两个浮层共用同一个右下角锚点,因此开筛选即收起搜索、开搜索即收起筛选
- index.tsx:挂载 ResourceFilterPanel,区域变更转交既有 openResourceBookChild(与栏目切换同一条路径),关键词与搜索浮层共用同一份 searchText
- resourceCanvasFilterModel.ts:resourceFilterIsActive 只看关键词与标签、不含区域——区域就是画布当前栏目,用户换栏时它照样在变,算进高亮会让按钮在「只是换了一栏看」时也常亮
- 新增 resourceFloatingPanelModel.ts 与其测试:资源卡旁「快速编辑」与「生成动画」的互斥规则收在一处并被两个开启入口共用,修掉「先开生成动画再点快速编辑两个浮层并存」的缺口;关闭仍只关自己,生成收尾不会吞掉用户刚打开的另一个面板
- 断言:新增 5 条互斥用例(含两个方向各自一条与来回点击序列),加上既有筛选用例;变异验证实测——把互斥模型的 closed 写成恒为 quick-edit(等价只修一个方向)→ 4 failed,还原后 5 passed
This commit is contained in:
2026-09-11 20:29:45 +08:00
parent cfd89ba570
commit 0ef1a4ec15
5 changed files with 308 additions and 26 deletions
@@ -188,6 +188,13 @@ import {
zoomResourceBookViewport,
} from './resourceBookViewport';
import { createResourceCanvasPageCategorySignature } from './resourceCanvasController';
import {
buildResourceFilterTagOptions,
filterCanvasResources,
RESOURCE_FILTER_ALL_REGION,
resourceFilterIsActive,
type ResourceFilterRegion,
} from './resourceCanvasFilterModel';
import {
createResourceCanvasCardSizeByResourceId,
fitResourceCanvasViewportToContent,
@@ -231,6 +238,11 @@ import {
resolveResourceEditRequestIdentity,
type ResourceEditRequestIdentity,
} from './resourceEditModel';
import { ResourceFilterPanel } from './ResourceFilterPanel';
import {
openResourceCardFloatingPanelState,
type ResourceCardFloatingPanelKind,
} from './resourceFloatingPanelModel';
import {
ResourceInfoFieldsView,
ResourceInfoPanelView,
@@ -1402,6 +1414,15 @@ export default function ProjectDevelopmentView({
*/
const [resourceSearchOpen, setResourceSearchOpen] = useState(false);
const resourceSearchId = useId();
/**
*
*
* ****
* `resourceFilterRegion` `resourceBookState`
*/
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
const resourceFilterId = useId();
const [resourceFilterTags, setResourceFilterTags] = useState<string[]>([]);
const [resourceCanvasViewports, setResourceCanvasViewports] =
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
const resourceCanvasViewportTargetsRef = useRef(
@@ -1535,6 +1556,8 @@ export default function ProjectDevelopmentView({
/** 右下角 Dock 里的搜索按钮:开合搜索浮层,也是浮层「点外部 / Esc」边界的一半。 */
const resourceSearchTriggerRef = useRef<HTMLButtonElement>(null);
const resourceSearchPopoverRef = useRef<HTMLLabelElement>(null);
/** 右下角 Dock 里的筛选按钮:开合筛选浮层,也是该浮层「点外部 / Esc」边界的一半。 */
const resourceFilterTriggerRef = useRef<HTMLButtonElement>(null);
const resourceFocusProjectPathRef = useRef(projectPath);
/** 框选命中测试用的资源卡图层与当前画本视口,渲染期同步赋值。 */
const marqueeHitTestLayersRef = useRef<CanvasLayer[]>([]);
@@ -1576,6 +1599,24 @@ export default function ProjectDevelopmentView({
resourceCharacterAnimationRequestRef.current = null;
}, []);
/**
* ****
*
*
* `resourceFloatingPanelModel`
*/
const applyResourceCardFloatingPanelOpen = useCallback(
(kind: ResourceCardFloatingPanelKind) => {
const { closed } = openResourceCardFloatingPanelState(kind);
if (closed === 'quick-edit') {
closeResourceQuickEditPanel();
} else {
closeResourceCharacterAnimationPanel();
}
},
[closeResourceCharacterAnimationPanel, closeResourceQuickEditPanel],
);
/**
* `clearCanvasFocus()`
*
@@ -2212,25 +2253,56 @@ export default function ProjectDevelopmentView({
.map((resource) => resource.id),
);
}, [resources, selectedResourceId]);
const normalizedSearch = searchText.trim().toLowerCase();
/**
*
* `categoryOrder` / /
* = ****
*
*
* `openResourceBookChild` / /
*
*/
const resourceFilterRegion: ResourceFilterRegion =
resourceBookView === 'child' && resourceBookState.category !== null
? (resourceBookState.category as ResourceFilterRegion)
: RESOURCE_FILTER_ALL_REGION;
/**
* AND
*
* `searchText` `resourceCanvasFilterModel`
* `categoryOrder` /
* / `visibleResourcesByCategory`
* ==
*/
const visibleResources = useMemo(
() =>
canvasResources.filter((resource) =>
normalizedSearch
? [
resource.label,
resource.path,
resource.mediaType,
resource.taskTitle ?? '',
].some((value) => value.toLowerCase().includes(normalizedSearch))
: true,
),
[canvasResources, normalizedSearch],
filterCanvasResources(canvasResources, {
region: resourceFilterRegion,
tags: resourceFilterTags,
keyword: searchText,
}),
[canvasResources, resourceFilterRegion, resourceFilterTags, searchText],
);
/**
* + ****
*
* `visibleResources`
* AND +
*
*/
const resourceFilterTagItems = useMemo(
() =>
buildResourceFilterTagOptions(
filterCanvasResources(canvasResources, {
region: resourceFilterRegion,
tags: [],
keyword: searchText,
}),
),
[canvasResources, resourceFilterRegion, searchText],
);
const resourceFilterActive = resourceFilterIsActive({
tags: resourceFilterTags,
keyword: searchText,
});
const visibleResourceIds = useMemo(
() => new Set(visibleResources.map((resource) => resource.id)),
[visibleResources],
@@ -5348,6 +5420,8 @@ export default function ProjectDevelopmentView({
if (!resource) {
return;
}
// 与「生成动画」互斥:两个浮层挂在同一位置,开这个就收起那个。
applyResourceCardFloatingPanelOpen('quick-edit');
setQuickEditSourceLayer(layer);
const panelDraft = createResourceQuickEditPanelDraft(layer);
setQuickEditPanel(panelDraft);
@@ -5359,7 +5433,7 @@ export default function ProjectDevelopmentView({
};
setResourceWorkbenchNotice('');
},
[canvasResources],
[applyResourceCardFloatingPanelOpen, canvasResources],
);
/**
@@ -5542,7 +5616,8 @@ export default function ProjectDevelopmentView({
if (!panelDraft) {
return;
}
closeResourceQuickEditPanel();
// 与「快速编辑」互斥:两个浮层挂在同一位置,开这个就收起那个。
applyResourceCardFloatingPanelOpen('character-animation');
setCharacterAnimationSourceLayer(layer);
setCharacterAnimationPanel({
...panelDraft,
@@ -5556,7 +5631,7 @@ export default function ProjectDevelopmentView({
};
setResourceWorkbenchNotice('');
},
[canvasResources, closeResourceQuickEditPanel],
[applyResourceCardFloatingPanelOpen, canvasResources],
);
/**
@@ -6367,10 +6442,30 @@ export default function ProjectDevelopmentView({
title="搜索资源(Ctrl/Cmd+F"
aria-expanded={resourceSearchOpen}
aria-controls={resourceSearchId}
onClick={() => setResourceSearchOpen((current) => !current)}
onClick={() => {
// 搜索与筛选是同一个右下角锚点上的两个浮层,不得同时展开。
setResourceFilterOpen(false);
setResourceSearchOpen((current) => !current);
}}
>
<Search size={16} aria-hidden="true" />
</button>
<button
ref={resourceFilterTriggerRef}
type="button"
className={`game-resource-book-zoom-button${resourceFilterActive ? ' is-active' : ''}`}
aria-label="筛选资源"
title="筛选资源"
aria-expanded={resourceFilterOpen}
aria-controls={resourceFilterId}
onClick={() => {
// 与搜索浮层互斥:打开筛选即收起搜索,避免两个浮层叠在同一点。
setResourceSearchOpen(false);
setResourceFilterOpen((current) => !current);
}}
>
<SlidersHorizontal size={16} aria-hidden="true" />
</button>
</div>
{resourceSearchOpen ? (
<label
@@ -6400,6 +6495,35 @@ export default function ProjectDevelopmentView({
/>
</label>
) : null}
{resourceFilterOpen ? (
<div id={resourceFilterId}>
<ResourceFilterPanel
onClose={() => setResourceFilterOpen(false)}
keyword={searchText}
onKeywordChange={(value) => {
advanceFocusGeneration();
setSearchText(value);
setHiddenCommittedResourceId(null);
}}
region={resourceFilterRegion}
onRegionChange={(region) => {
// 区域即画布栏目:走既有栏目切换,不另维护一份区域状态。
// 「全部区域」对应既有的「所有资源」分组页。
openResourceBookChild(region);
}}
activeTags={resourceFilterTags}
onToggleTag={(tag) => {
setResourceFilterTags((current) =>
current.includes(tag)
? current.filter((item) => item !== tag)
: [...current, tag],
);
}}
tagItems={resourceFilterTagItems}
triggerRef={resourceFilterTriggerRef}
/>
</div>
) : null}
<div className="game-resource-book-notices">
{resourceLayoutReadNotice ? (
<div
@@ -128,13 +128,15 @@ 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
);
export function resourceFilterIsActive(
filter: Pick<ResourceCanvasFilter, 'tags' | 'keyword'>,
) {
return filter.tags.length > 0 || filter.keyword.trim().length > 0;
}
/**
@@ -0,0 +1,31 @@
/**
* 资源卡旁那一组浮层——「快速编辑」与「生成动画」——的开关合成。
*
* 两者挂在同一个选中资源卡旁、同一位置:同时打开就会叠在一起。**互斥规则只有一处**,
* 两个开启入口都读它,因此「开动画→点快速编辑」与「开快速编辑→点动画」不会只修好一边。
*/
export type ResourceCardFloatingPanelKind =
| 'quick-edit'
| 'character-animation';
/** 另一个浮层是什么。互斥就是「开 A 必然关 B」,所以只要求出 B 即可。 */
export function opposingResourceCardFloatingPanel(
kind: ResourceCardFloatingPanelKind,
): ResourceCardFloatingPanelKind {
return kind === 'quick-edit' ? 'character-animation' : 'quick-edit';
}
/**
* 开启某个浮层时,两个浮层各自应当处于什么状态。
*
* 返回的 `open` 就是被点开的那个,`closed` 是必然被收起的另一个;因此结果里
* **永远不可能两个都为真**。
*/
export function openResourceCardFloatingPanelState(
kind: ResourceCardFloatingPanelKind,
): {
open: ResourceCardFloatingPanelKind;
closed: ResourceCardFloatingPanelKind;
} {
return { open: kind, closed: opposingResourceCardFloatingPanel(kind) };
}
@@ -256,7 +256,7 @@ describe('资源画布筛选模型', () => {
expect(resourceMatchesRegion(UI_RESOURCE, 'character')).toBe(false);
});
it('筛选生效判据:区域非全部、或有标签、或有关键词', () => {
it('筛选生效判据只看关键词与标签:区域只是画布当前栏目,不计入高亮', () => {
expect(
resourceFilterIsActive({ region: 'all', tags: [], keyword: '' }),
).toBe(false);
@@ -264,9 +264,10 @@ describe('资源画布筛选模型', () => {
expect(
resourceFilterIsActive({ region: 'all', tags: [], keyword: ' ' }),
).toBe(false);
// 区域切到别的栏目不等于「有筛选」:用户滚轮换栏时按钮不该常亮。
expect(
resourceFilterIsActive({ region: 'audio', tags: [], keyword: '' }),
).toBe(true);
).toBe(false);
expect(
resourceFilterIsActive({ region: 'all', tags: ['主页'], keyword: '' }),
).toBe(true);
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import {
openResourceCardFloatingPanelState,
opposingResourceCardFloatingPanel,
type ResourceCardFloatingPanelKind,
} from '../src/view/project-development/resourceFloatingPanelModel';
/**
* 用一个真实的两布尔状态跑「开启某个浮层」这一步,模拟宿主里两个 state 的实际结果,
* 这样断言的是「最终会不会两个都开着」,而不是只看模型返回值的形状。
*/
function openPanel(
state: { quickEditOpen: boolean; characterAnimationOpen: boolean },
kind: ResourceCardFloatingPanelKind,
) {
const { open, closed } = openResourceCardFloatingPanelState(kind);
const next = { ...state };
if (open === 'quick-edit') next.quickEditOpen = true;
else next.characterAnimationOpen = true;
if (closed === 'quick-edit') next.quickEditOpen = false;
else next.characterAnimationOpen = false;
return next;
}
const BOTH_CLOSED = { quickEditOpen: false, characterAnimationOpen: false };
describe('资源卡旁浮层的互斥开关', () => {
it('互相是对方的对端,且对端是自身的逆', () => {
expect(opposingResourceCardFloatingPanel('quick-edit')).toBe(
'character-animation',
);
expect(opposingResourceCardFloatingPanel('character-animation')).toBe(
'quick-edit',
);
for (const kind of [
'quick-edit',
'character-animation',
] as const satisfies readonly ResourceCardFloatingPanelKind[]) {
expect(
opposingResourceCardFloatingPanel(
opposingResourceCardFloatingPanel(kind),
),
).toBe(kind);
}
});
it('开一个必然关另一个:返回的 open 与 closed 永远不同', () => {
for (const kind of [
'quick-edit',
'character-animation',
] as const satisfies readonly ResourceCardFloatingPanelKind[]) {
const { open, closed } = openResourceCardFloatingPanelState(kind);
expect(open).toBe(kind);
expect(closed).not.toBe(open);
}
});
/**
* 变异验证①:把 `openResourceCardFloatingPanelState` 的 `closed` 写成恒为
* `'quick-edit'`(或删掉对端推导),下面这一条必须变红。
*
* 方向一:先开「生成动画」,再点「快速编辑」→ 动画必须被收起。
*/
it('开动画后再点快速编辑,动画被收起', () => {
const afterAnimation = openPanel(BOTH_CLOSED, 'character-animation');
expect(afterAnimation).toEqual({
quickEditOpen: false,
characterAnimationOpen: true,
});
const afterQuickEdit = openPanel(afterAnimation, 'quick-edit');
expect(afterQuickEdit).toEqual({
quickEditOpen: true,
characterAnimationOpen: false,
});
});
/**
* 变异验证②:同一条规则的另一半。
*
* 方向二:先开「快速编辑」,再点「生成动画」→ 快速编辑必须被收起。
* 只修一个方向时这条会红。
*/
it('开快速编辑后再点动画,快速编辑被收起', () => {
const afterQuickEdit = openPanel(BOTH_CLOSED, 'quick-edit');
expect(afterQuickEdit).toEqual({
quickEditOpen: true,
characterAnimationOpen: false,
});
const afterAnimation = openPanel(afterQuickEdit, 'character-animation');
expect(afterAnimation).toEqual({
quickEditOpen: false,
characterAnimationOpen: true,
});
});
it('反复来回点也永远不会两个同时开着', () => {
let state = BOTH_CLOSED;
const sequence: ResourceCardFloatingPanelKind[] = [
'quick-edit',
'character-animation',
'character-animation',
'quick-edit',
'quick-edit',
'character-animation',
];
for (const kind of sequence) {
state = openPanel(state, kind);
expect(state.quickEditOpen && state.characterAnimationOpen).toBe(false);
expect(
state.quickEditOpen || state.characterAnimationOpen,
`${kind} 之后至少应有一个开着`,
).toBe(true);
// 被点开的那个必须是当前开着的那个。
expect(
kind === 'quick-edit'
? state.quickEditOpen
: state.characterAnimationOpen,
).toBe(true);
}
});
});