AGC 生成任务侧栏:失去焦点自动收起、入口挪到排列方式之前
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m4s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 1m53s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m2s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m32s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 1m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m56s
Project CI / Repository checks (pull_request) Successful in 3m45s
Project CI / Frontend tests (pull_request) Successful in 4m12s
Project CI / Native shell tests (pull_request) Failing after 4m48s
Project CI / Backend tests (pull_request) Successful in 7m7s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m17s

- 侧栏展开时挂 document 级 pointerdown 捕获监听:点在侧栏内部与工具条那枚开合按钮以外的地方(画布、其他工具条按钮、对话框)即自动收起侧栏
- 排除开合按钮本身:它自己负责 toggle,若在「点外部」里也命中会先收起再被 toggle 打开,表现为按钮失灵
- 工具条「生成任务」入口加 data-resource-generation-task-toggle 标记,供上面的判定排除
- 「生成任务」入口从资源排列方式之后挪到之前:动作在前、排列方式(依赖 / 类型)收在行尾;入口抽成可复用元素,资源页签与运行页签各渲染一次,同一时刻只渲染一处
- 新增 tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx:点外部收起、点内部与开合按钮不收起且仍能 toggle、入口排在依赖/类型之前

验证:tsc --noEmit 通过;vitest(新用例 3 条 + 生成任务侧栏 8 条 + 侧栏样式 9 条 + 画布手动布局 9 条)通过;变异验证:关掉自动收起 effect 与把入口挪回排列方式之后,对应断言分别变红
This commit is contained in:
2026-09-14 22:29:16 +08:00
parent 9e709ce727
commit 45ccc58969
2 changed files with 208 additions and 22 deletions
@@ -5767,6 +5767,39 @@ export default function ProjectDevelopmentView({
visibleResourceIds,
]);
/**
*
*
*
* -
* - toggle toggle
* pointerdown click
*
* pointerdown click pointerdown `preventDefault()`
* document click
*/
useEffect(() => {
if (!resourceAssetGenerationTasksPanelOpen) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
if (
target.closest('.game-resource-generation-tasks-sidebar') ||
target.closest('[data-resource-generation-task-toggle]')
) {
return;
}
setResourceAssetGenerationTasksPanelOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () =>
document.removeEventListener('pointerdown', handlePointerDown, true);
}, [resourceAssetGenerationTasksPanelOpen]);
/**
*
*
@@ -7414,6 +7447,31 @@ export default function ProjectDevelopmentView({
[],
);
/**
* ****
*
* /
* 访
*/
const generationTasksEntry = (
<button
type="button"
className="game-workbench-resource-panel-button"
aria-label="生成任务"
aria-expanded={resourceAssetGenerationTasksPanelOpen}
data-resource-generation-task-toggle=""
data-resource-generation-task-count={resourceAssetGenerationInFlightCount}
onClick={() =>
setResourceAssetGenerationTasksPanelOpen((current) => !current)
}
>
<ListChecks size={15} aria-hidden="true" />
{resourceAssetGenerationInFlightCount > 0
? `生成任务 · ${resourceAssetGenerationInFlightCount}`
: '生成任务'}
</button>
);
if (planningStartMode) {
return (
<section
@@ -7612,6 +7670,12 @@ export default function ProjectDevelopmentView({
>
{resourceLayoutSaving ? '保存中' : resourceLayoutNotice}
</span>
{/*
/
`generationTasksEntry`
*/}
{generationTasksEntry}
<div
className="game-workbench-tabs game-resource-sort-tabs"
role="group"
@@ -7640,28 +7704,10 @@ export default function ProjectDevelopmentView({
</div>
</>
) : null}
{/*
**** `mode === 'resources'`
*/}
<button
type="button"
className="game-workbench-resource-panel-button"
aria-label="生成任务"
aria-expanded={resourceAssetGenerationTasksPanelOpen}
data-resource-generation-task-count={
resourceAssetGenerationInFlightCount
}
onClick={() =>
setResourceAssetGenerationTasksPanelOpen((current) => !current)
}
>
<ListChecks size={15} aria-hidden="true" />
{resourceAssetGenerationInFlightCount > 0
? `生成任务 · ${resourceAssetGenerationInFlightCount}`
: '生成任务'}
</button>
{/* 运行页签(或 UI 编辑器)下资源类按钮整组收起,但「生成任务」入口必须留着。 */}
{mode === 'resources' && !uiEditorRoute
? null
: generationTasksEntry}
</div>
</div>
@@ -0,0 +1,140 @@
/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import ProjectDevelopmentView from '../src/view/project-development';
import {
createGameCreationAppManifest,
fireEvent,
React,
render,
screen,
waitFor,
} from './appSurface/harness';
/**
* 「生成任务」侧栏的两条交互口径:
*
* 1. **失去焦点即收起**:点画布、点别的工具栏按钮、点侧栏外部都算失去焦点;点侧栏内部
* (含任务卡与「定位到素材」)与点那枚开合按钮不算——后者自己负责 toggle,否则会先被
* 收起再被 toggle 打开,表现为按钮失灵。
* 2. **入口顺序**:「生成任务」排在「依赖 / 类型」排列方式之前(动作在前、排列方式收行尾)。
*/
function resourceGraphFor(resources: unknown) {
const resourceIds = Array.isArray(resources)
? resources.map(
(resource) => (resource as { resourceId?: string }).resourceId ?? '',
)
: [];
return {
resourceIds,
referenceEdges: [],
taskFlows: [],
categories: [],
diagnostics: [],
};
}
function installInvoke() {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphFor(args?.resources);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
}
async function renderWorkbench(projectId: string) {
installInvoke();
const manifest = createGameCreationAppManifest(
projectId,
`${projectId} 项目`,
);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: `/tmp/${projectId}`,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const entry = await screen.findByRole('button', { name: '生成任务' });
fireEvent.click(entry);
return screen.findByRole('region', { name: '生成任务' });
}
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
describe('「生成任务」侧栏的开合与入口位置', () => {
it('点侧栏外部(画布 / 其他区域)自动收起', async () => {
const sidebar = await renderWorkbench('workbench-tasks-dismiss-outside');
expect(sidebar).not.toBeNull();
fireEvent.pointerDown(document.body);
await waitFor(() =>
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
);
});
it('点侧栏内部与开合按钮都不收起:内部保持打开,按钮仍能 toggle 收起来', async () => {
const sidebar = await renderWorkbench('workbench-tasks-dismiss-inside');
// 侧栏内部点击(任务列表区域)不该被当成点外部。
fireEvent.pointerDown(sidebar);
expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull();
// 开合按钮自己负责 toggle:点一次必须真的收起(不能先被「点外部」收起再被 toggle 打开)。
const entry = screen.getByRole('button', { name: '生成任务' });
fireEvent.pointerDown(entry);
fireEvent.click(entry);
await waitFor(() =>
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
);
// 再点一次要能回来。
fireEvent.click(entry);
expect(
await screen.findByRole('region', { name: '生成任务' }),
).not.toBeNull();
});
it('「生成任务」入口排在「依赖 / 类型」排列方式之前', async () => {
await renderWorkbench('workbench-tasks-entry-order');
const actionsRow = document.querySelector('.game-workbench-view-actions');
expect(actionsRow).not.toBeNull();
const buttons = Array.from(actionsRow!.querySelectorAll('button'));
const tasksIndex = buttons.findIndex(
(button) => button.getAttribute('aria-label') === '生成任务',
);
const sortIndex = buttons.findIndex(
(button) => button.getAttribute('aria-label') === '按依赖',
);
expect(tasksIndex).toBeGreaterThanOrEqual(0);
expect(sortIndex).toBeGreaterThanOrEqual(0);
// 依赖 / 类型收在行尾,生成任务在它前面。
expect(tasksIndex).toBeLessThan(sortIndex);
expect(sortIndex).toBe(buttons.length - 2);
});
});