From 45ccc58969734625f6632912fa23155206e42096 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 22:29:16 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=EF=BC=9A=E5=A4=B1=E5=8E=BB=E7=84=A6=E7=82=B9?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=94=B6=E8=B5=B7=E3=80=81=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=8C=AA=E5=88=B0=E6=8E=92=E5=88=97=E6=96=B9=E5=BC=8F=E4=B9=8B?= =?UTF-8?q?=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧栏展开时挂 document 级 pointerdown 捕获监听:点在侧栏内部与工具条那枚开合按钮以外的地方(画布、其他工具条按钮、对话框)即自动收起侧栏 - 排除开合按钮本身:它自己负责 toggle,若在「点外部」里也命中会先收起再被 toggle 打开,表现为按钮失灵 - 工具条「生成任务」入口加 data-resource-generation-task-toggle 标记,供上面的判定排除 - 「生成任务」入口从资源排列方式之后挪到之前:动作在前、排列方式(依赖 / 类型)收在行尾;入口抽成可复用元素,资源页签与运行页签各渲染一次,同一时刻只渲染一处 - 新增 tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx:点外部收起、点内部与开合按钮不收起且仍能 toggle、入口排在依赖/类型之前 验证:tsc --noEmit 通过;vitest(新用例 3 条 + 生成任务侧栏 8 条 + 侧栏样式 9 条 + 画布手动布局 9 条)通过;变异验证:关掉自动收起 effect 与把入口挪回排列方式之后,对应断言分别变红 --- .../src/view/project-development/index.tsx | 90 ++++++++--- ...nvasGenerationTasksSidebarDismiss.test.tsx | 140 ++++++++++++++++++ 2 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index be85a1063..c990b4f47 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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 = ( + + ); + if (planningStartMode) { return (
{resourceLayoutSaving ? '保存中' : resourceLayoutNotice} + {/* + 「生成任务」入口排在「依赖 / 类型」排列方式之前:一个是开合任务侧栏的动作, + 一个是画布排列方式——动作在前、排列方式收在行尾。运行页签下由下面同一枚 + 元素补渲染(见 `generationTasksEntry`)。 + */} + {generationTasksEntry}
) : null} - {/* - 「生成任务」入口:**两个页签下都常驻**(不受 `mode === 'resources'` 限制)。 - 侧栏本体是无条件渲染的非模态浮层,运行态一样可见;入口若只留在资源页签, - 用户切到运行后关掉侧栏就再也打不开了。有在途任务时带上数量,一眼看出还有几条在跑。 - */} - + {/* 运行页签(或 UI 编辑器)下资源类按钮整组收起,但「生成任务」入口必须留着。 */} + {mode === 'resources' && !uiEditorRoute + ? null + : generationTasksEntry}
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx new file mode 100644 index 000000000..2d7a47d2f --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx @@ -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) => { + 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); + }); +});