From d56b5ff15afc9d98b11784475bde7e6968bc56a2 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 21 Sep 2026 03:25:53 +0800 Subject: [PATCH 1/6] =?UTF-8?q?AGC=E3=80=8C=E7=94=9F=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E3=80=8D=E4=BE=A7=E6=A0=8F=E7=A7=BB=E5=88=B0=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E5=8F=B3=E4=B8=8A=E8=A7=92=EF=BC=9A=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E5=B8=B8=E9=A9=BB=E3=80=81=E9=9D=A2=E6=9D=BF=E8=B4=B4=E7=9D=80?= =?UTF-8?q?=E5=AE=83=E5=B1=95=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增右上角锚点 game-resource-generation-tasks-anchor,挂在 game-workbench-stage 内;资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口。 - 开关改由 ResourceCanvasAssetGenerationTasksPanelView 自己渲染(保留 data-resource-generation-task-toggle 与计数属性、可访问名唯一),收起态只剩这一枚开关;工具条上两处重复入口与宿主那份 resourceAssetGenerationInFlightCount 一并删除。 - 面板自己不再定位,改由 max-height 封顶 + 内部滚动;进场 / 退场动画位移方向跟着锚点翻到正 X。 - 开关沿用原工具条那枚次级胶囊外观,颜色 / 圆角 / 字重 / 阴影继续全部走平台 token。 - 测试:面板用例新增「折叠只剩右上角开关」「开关计数与面板头部同源」「锚点按工作面分档」;样式用例改为锚点坐标 / 运行档下移 / 面板不再自定位 / 开关走 token;dismiss 用例改为右上角入口且工具条不再有生成任务按钮。 - 文档:PRD 3.10 与更新时间、decision-log 新增 2026-09-21 条目、画布验收待办登记第四批并勾掉该条。 --- ...rceCanvasAssetGenerationTasksPanelView.tsx | 252 ++++++++++-------- ...ourceCanvasAssetGenerationTasksSidebar.css | 90 ++++++- .../src/view/project-development/index.tsx | 95 ++----- ...ceCanvasAssetGenerationTasksPanel.test.tsx | 94 ++++++- ...asAssetGenerationTasksSidebarStyle.test.ts | 73 ++++- ...nvasGenerationTasksSidebarDismiss.test.tsx | 35 ++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 4 +- .../shared-memory/decision-log.md | 10 + .../【待办】画布验收后续修复-2026-09-18.md | 12 +- 9 files changed, 448 insertions(+), 217 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index 0a6cc43ff..bbd71f3a6 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -43,6 +43,13 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = { onToggleOpen: () => void; /** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */ onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void; + /** + * 锚点落在哪个工作面:资源栏目画布 / 运行表现层 / UI 编辑器。 + * + * 三处右上角的既有 chrome 不同(运行表现层右上角是版本入口、UI 编辑器是整页编辑器壳), + * 所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算。 + */ + placement?: 'canvas' | 'run' | 'editor'; }; /** @@ -222,14 +229,15 @@ function resourceCanvasGenerationTaskRowNode( /** * 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。 * - * 形态对齐网页端美术画布的任务侧栏:贴边的独立 ` + ) : null} + ); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css index 56abc9d1c..9eac7ab55 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css @@ -1,21 +1,80 @@ /* 「生成任务」侧栏的样式。 * * 分层与网页端美术画布的任务侧栏(`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6084+`)一一对应: - * 容器 → 头部(标题 + 在途计数)→ 分栏标题(各带条数)→ 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。 - * 差别只有一处:**颜色全部换成 AGC 的平台设计 token(`--platform-*`)**,不再照抄网页端的固定色值, + * 右上角锚点(开关 + 浮动面板)→ 头部(标题 + 在途计数)→ 分栏标题(各带条数)→ + * 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。 + * + * 位置与开合形态照抄美术画布:**开关常驻画布右上角,面板在开关左侧展开**,收起态只剩那一枚 + * 开关(美术画布同样如此:列表为空且侧栏关闭时只保留图标开关)。差别只有一处: + * **颜色与外形全部走 AGC 的平台设计 token(`--platform-*`)**,不再照抄网页端的固定色值, * 这样亮/暗主题都跟着 `platform-theme` 走,也不引入第二套色板。 * * 行为(分栏、条数、已完成封顶、折叠不清列表、提交后自动展开)都不在这里,样式只负责表现。 */ -.game-resource-generation-tasks-sidebar { - position: fixed; - top: 4rem; - bottom: 6rem; - left: 0.75rem; +/* + * 右上角锚点:开关与面板同挂在这里,所以「收起只剩开关、展开面板贴在同一角」这两个状态 + * 共用同一处坐标,不会出现开关与面板各锚一处、开合时互相漂移。 + * + * 锚点相对 `.game-workbench-stage` 定位(stage `position: relative`,见 styles.css): + * 工具条占第一行(min-height 48px),下面的 auto 行在资源态是空行(高 0),所以 + * `top: 3.5rem` 正好落在画布区顶边再留 8px —— 资源画布右上角。运行表现层是另一个工作面: + * 它的右上角(`top: 20px; right: 20px`)被版本入口占着,锚点因此下移到版本入口之下。 + */ +.game-resource-generation-tasks-anchor { + position: absolute; + top: 3.5rem; + right: 0.85rem; z-index: 40; + display: flex; + /* 锚点被拉到整宽时(窄屏),开关与面板仍然贴住右上角,而不是散在左侧。 */ + justify-content: flex-end; + align-items: flex-start; + gap: 0.45rem; + pointer-events: auto; +} + +.game-resource-generation-tasks-anchor[data-generation-tasks-placement='run'] { + top: 7rem; + right: 1.25rem; +} + +/* + * 右上角开关:外观沿用原工具条那一枚「生成任务」按钮(次级胶囊:描边 + secondary 填充 + + * 12px/700),搬到画布上多一档面板投影,保证压在画布素材上仍然看得清。 + */ +.game-resource-generation-tasks-toggle { + display: inline-flex; + min-height: 30px; + align-items: center; + gap: 5px; + padding: 0 11px; + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + background: var(--platform-button-secondary-fill); + box-shadow: var(--platform-panel-shadow); + color: var(--platform-button-secondary-text); + cursor: pointer; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.game-resource-generation-tasks-toggle:hover { + border-color: var(--platform-accent); + color: var(--platform-text-strong); +} + +.game-resource-generation-tasks-toggle:focus-visible { + outline: 2px solid var(--platform-accent); + outline-offset: 2px; + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.game-resource-generation-tasks-sidebar { display: flex; width: min(300px, 80vw); + max-height: min(30rem, calc(100vh - 12rem)); flex-direction: column; overflow: hidden; border: 1px solid var(--platform-subpanel-border); @@ -27,11 +86,12 @@ animation: game-resource-generation-tasks-enter 160ms ease-out; } -/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 */ +/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 + * 进场方向跟着锚点走:面板从右上角往左展开,所以位移取正 X。 */ @keyframes game-resource-generation-tasks-enter { from { opacity: 0; - transform: translateX(-0.5rem); + transform: translateX(0.5rem); } to { @@ -54,7 +114,7 @@ to { opacity: 0; - transform: translateX(-0.5rem); + transform: translateX(0.5rem); } } @@ -356,11 +416,13 @@ /* 窄屏(含 360px):侧栏占满可用宽度,不挡画布操作。 */ @media (max-width: 480px) { - .game-resource-generation-tasks-sidebar { - top: 3.5rem; - right: 0.5rem; - bottom: 5.5rem; + .game-resource-generation-tasks-anchor { left: 0.5rem; + right: 0.5rem; + } + + .game-resource-generation-tasks-sidebar { + max-height: calc(100vh - 12rem); width: auto; } } 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 f0895eaf7..8a833b798 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 @@ -30,7 +30,6 @@ import { Info, Layers, LayoutGrid, - ListChecks, ListFilter, Maximize2, Minus, @@ -6849,7 +6848,8 @@ export default function ProjectDevelopmentView({ * * 两处必须排除,否则会把用户刚做的动作吃掉: * - 侧栏内部:点任务卡、点「定位到素材」都不能收起侧栏; - * - 工具条上那枚开合按钮:它自己负责 toggle,若在这里也被判成「点外部」就会先收起再被 toggle + * - 右上角那枚开合开关(`data-resource-generation-task-toggle`):它自己负责 toggle,若在这里被 + * 判成「点外部」就会先收起再被 toggle * 打开,表现为按钮失灵。点它时这里的 pointerdown 直接放行,收尾交给它的 click。 * * 用 pointerdown 而不是 click:画布空白处的左键 pointerdown 会 `preventDefault()`,掐掉指针的 @@ -8396,17 +8396,6 @@ export default function ProjectDevelopmentView({ }), [pendingResourceEdits, resourceEditSubmissions], ); - /** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务(两套账本都算)。 */ - const resourceAssetGenerationInFlightCount = - resourceAssetGenerationTasks.filter( - (task) => - task.projectId === manifest.projectId && - !resourceCanvasAssetGenerationTaskIsTerminal(task), - ).length + - resourceCanvasResourceEdits.filter( - (task) => !resourceCanvasResourceEditTaskIsTerminal(task), - ).length; - const replaceResourceAssetGenerationTask = useCallback( (next: ResourceCanvasAssetGenerationTask) => { const current = resourceAssetGenerationTasksRef.current; @@ -9350,31 +9339,6 @@ export default function ProjectDevelopmentView({ ], ); - /** - * 「生成任务」开合入口。**两个页签下都常驻**:侧栏本体是无条件渲染的非模态浮层,运行态一样 - * 可见,入口若只在资源页签,用户切到运行后关掉侧栏就再也打不开了。资源页签里它排在 - * 「依赖 / 类型」排列方式之前(动作在前、排列方式收在行尾),所以这里做成一个可复用的元素, - * 由两处分支各自渲染一次——同一时刻只渲染一处,可访问名唯一。 - */ - const generationTasksEntry = ( - - ); - if (planningStartMode) { return (
) : null} - {/* - 「生成任务」入口排在「依赖 / 类型」排列方式之前:一个是开合任务侧栏的动作, - 一个是画布排列方式——动作在前、排列方式收在行尾。运行页签下由下面同一枚 - 元素补渲染(见 `generationTasksEntry`)。 - */} - {generationTasksEntry}
) : null} - {/* 运行页签(或 UI 编辑器)下资源类按钮整组收起,但「生成任务」入口必须留着。 */} - {mode === 'resources' && !uiEditorRoute - ? null - : generationTasksEntry}
{/* 布局状态提示**不进动作行**:它是一段随保存过程变长的文案(空 →「保存中」→ @@ -10742,6 +10696,31 @@ export default function ProjectDevelopmentView({
)} + {/* + 「生成任务」侧栏:常驻在**工作面右上角**(资源画布 / 运行表现层 / UI 编辑器各自一档坐标), + 可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据—— + 生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本 + 与本地队列里。位置与开合形态照抄美术画布(开关常驻右上角 + 面板贴着它展开), + 颜色与外形仍走 AGC 的平台 token。 + */} + task.projectId === manifest.projectId, + )} + resourceEditTasks={resourceCanvasResourceEdits.filter( + (task) => + !task.restored || + !resourceCanvasResourceEditTaskIsTerminal(task), + )} + open={resourceAssetGenerationTasksPanelOpen} + onToggleOpen={() => + setResourceAssetGenerationTasksPanelOpen((current) => !current) + } + onFocusTask={focusResourceAssetGenerationTask} + placement={ + uiEditorRoute ? 'editor' : mode === 'run' ? 'run' : 'canvas' + } + /> {!uiEditorRoute ? ( @@ -10803,26 +10782,6 @@ export default function ProjectDevelopmentView({ ) : null} - {/* - 「生成任务」侧栏:常驻、可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` - 的模态遮挡判据——生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图, - 任务本身活在账本与本地队列里。 - */} - task.projectId === manifest.projectId, - )} - resourceEditTasks={resourceCanvasResourceEdits.filter( - (task) => - !task.restored || !resourceCanvasResourceEditTaskIsTerminal(task), - )} - open={resourceAssetGenerationTasksPanelOpen} - onToggleOpen={() => - setResourceAssetGenerationTasksPanelOpen((current) => !current) - } - onFocusTask={focusResourceAssetGenerationTask} - /> - {mode === 'resources' && !uiEditorRoute && selectedResource && diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 72a316f36..4050577a5 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -55,7 +55,10 @@ function task( function renderSidebar( tasks: readonly ResourceCanvasAssetGenerationTask[], - options: { open?: boolean } = {}, + options: { + open?: boolean; + placement?: 'canvas' | 'run' | 'editor'; + } = {}, ) { const onToggleOpen = vi.fn(); const onFocusTask = vi.fn(); @@ -65,6 +68,7 @@ function renderSidebar( open={options.open ?? true} onToggleOpen={onToggleOpen} onFocusTask={onFocusTask} + placement={options.placement} />, ); return { onToggleOpen, onFocusTask, ...view }; @@ -180,16 +184,98 @@ describe('「生成任务」侧栏', () => { ).not.toBeNull(); }); - test('折叠后不再渲染,画布上不留任何常驻入口或把手', async () => { - renderSidebar([], { open: false }); + test('折叠后只剩画布右上角那一枚开关:面板不渲染,开关自己就是开合口', async () => { + const user = userEvent.setup(); + const { onToggleOpen, container } = renderSidebar([], { open: false }); expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(); - expect(screen.queryByRole('button')).toBeNull(); expect( document.querySelector('.game-resource-generation-tasks-handle'), ).toBeNull(); expect( document.querySelector('.game-resource-generation-tasks-sidebar'), ).toBeNull(); + + // 开关落在右上角锚点里(形态照抄美术画布),并且是「生成任务」这个可访问名的唯一持有者。 + const toggle = screen.getByRole('button', { name: '生成任务' }); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.dataset.resourceGenerationTaskCount).toBe('0'); + expect( + toggle.closest('.game-resource-generation-tasks-anchor'), + ).not.toBeNull(); + expect( + container.querySelectorAll('.game-resource-generation-tasks-toggle'), + ).toHaveLength(1); + + await user.click(toggle); + expect(onToggleOpen).toHaveBeenCalledTimes(1); + }); + + test('开关上的在途计数与面板头部同源,且跟着任务收口归零', () => { + const running = task({ + taskId: 't1', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }); + const { rerender } = render( + , + ); + const collapsedToggle = screen.getByRole('button', { name: '生成任务' }); + expect(collapsedToggle.dataset.resourceGenerationTaskCount).toBe('1'); + expect(collapsedToggle.textContent).toContain('生成任务 · 1'); + + rerender( + , + ); + expect( + screen.getByRole('button', { name: '生成任务' }).dataset + .resourceGenerationTaskCount, + ).toBe('0'); + expect(screen.getByRole('button', { name: '生成任务' }).textContent).toBe( + '生成任务', + ); + expect(screen.getByLabelText('在途生成任务 0').textContent).toBe('0'); + }); + + test('锚点按工作面分档:资源画布 / 运行表现层 / UI 编辑器各挂一档', () => { + const { container, rerender } = renderSidebar([], { placement: 'run' }); + const placementOf = () => + container + .querySelector('.game-resource-generation-tasks-anchor') + ?.getAttribute('data-generation-tasks-placement'); + expect(placementOf()).toBe('run'); + + rerender( + , + ); + expect(placementOf()).toBe('editor'); + + // 缺省 = 资源栏目画布,老调用方不传这个 prop 也落在画布右上角。 + rerender( + , + ); + expect(placementOf()).toBe('canvas'); }); test('收起时先播完收起动画再卸载,动画期间列表内容不跳', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts index d2b3f8b71..60fa535d0 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts @@ -188,18 +188,75 @@ describe('「生成任务」侧栏样式', () => { 'var(--platform-line-soft)', ); - // 侧栏高度有界:上下边界都给死,不靠内容撑高(内容再多也只滚列表)。 + // 面板高度有界:不再靠上下边界拉满,改成锚点里的浮动卡片 + 封顶高度(内容再多也只滚列表)。 const sidebar = resolved(['.game-resource-generation-tasks-sidebar']); - expect(declaration(sidebar, 'top')).toBe('4rem'); - expect(declaration(sidebar, 'bottom')).toBe('6rem'); expect(declaration(sidebar, 'width')).toBe('min(300px, 80vw)'); + expect(declaration(sidebar, 'max-height')).toBe( + 'min(30rem, calc(100vh - 12rem))', + ); + // 面板自己不再定位:坐标只由右上角锚点一处决定,避免两处各锚一处互相漂移。 + expect(sidebar.has('position')).toBe(false); + expect(sidebar.has('left')).toBe(false); }); - test('窄屏(360px)下侧栏不溢出不挡把手', () => { - const narrow = resolved(['.game-resource-generation-tasks-sidebar'], 360); - expect(declaration(narrow, 'width')).toBe('auto'); - expect(declaration(narrow, 'left')).toBe('0.5rem'); - expect(declaration(narrow, 'right')).toBe('0.5rem'); + test('锚点贴在工作面右上角,运行表现层让开版本入口,开关沿用次级胶囊样式', () => { + const anchor = resolved(['.game-resource-generation-tasks-anchor']); + expect(declaration(anchor, 'position')).toBe('absolute'); + expect(declaration(anchor, 'top')).toBe('3.5rem'); + expect(declaration(anchor, 'right')).toBe('0.85rem'); + expect(declaration(anchor, 'z-index')).toBe('40'); + expect(declaration(anchor, 'align-items')).toBe('flex-start'); + // 画布与开关之间只留一档小间距,开关不会盖住面板。 + expect(declaration(anchor, 'gap')).toBe('0.45rem'); + + // 运行表现层右上角被版本入口(`game-run-version-picker`:top 20px / right 20px)占着, + // 这一档必须把开关压到那枚版本入口下面。 + const runAnchor = resolved([ + '.game-resource-generation-tasks-anchor', + ".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']", + ]); + expect(declaration(runAnchor, 'top')).toBe('7rem'); + expect( + Number.parseFloat(declaration(runAnchor, 'top') as string), + ).toBeGreaterThan(5); + + // 开关保留原来工具条那一枚的外观(次级胶囊),只是搬到画布上并加一档面板投影; + // 取值仍然全部走平台 token,不引入第二套色板。 + const toggle = resolved(['.game-resource-generation-tasks-toggle']); + expect(declaration(toggle, 'border-radius')).toBe('999px'); + expect(declaration(toggle, 'border')).toBe( + '1px solid var(--platform-surface-border)', + ); + expect(declaration(toggle, 'background')).toBe( + 'var(--platform-button-secondary-fill)', + ); + expect(declaration(toggle, 'color')).toBe( + 'var(--platform-button-secondary-text)', + ); + expect(declaration(toggle, 'box-shadow')).toBe( + 'var(--platform-panel-shadow)', + ); + for (const property of ['border', 'background', 'color', 'box-shadow']) { + expect(declaration(toggle, property)).not.toMatch( + /#[0-9a-f]{3,8}|rgba?\(|hsla?\(/iu, + ); + } + }); + + test('窄屏(360px)下锚点占满可用宽度,面板宽度自适应不溢出', () => { + const narrowAnchor = resolved( + ['.game-resource-generation-tasks-anchor'], + 360, + ); + expect(declaration(narrowAnchor, 'left')).toBe('0.5rem'); + expect(declaration(narrowAnchor, 'right')).toBe('0.5rem'); + expect(declaration(narrowAnchor, 'justify-content')).toBe('flex-end'); + + const narrowSidebar = resolved( + ['.game-resource-generation-tasks-sidebar'], + 360, + ); + expect(declaration(narrowSidebar, 'width')).toBe('auto'); }); test('prefers-reduced-motion 下进场动画与过渡都被关掉', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx index e4eccc910..3f098464f 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx @@ -16,12 +16,13 @@ import { import { repoPath } from './repoPath'; /** - * 「生成任务」侧栏与工具条入口的三条口径: + * 「生成任务」侧栏与右上角入口的三条口径: * * 1. **失去焦点即收起**:点画布、点别的工具栏按钮、点侧栏外部都算失去焦点;点侧栏内部 - * (含任务卡与「定位到素材」)与点那枚开合按钮不算——后者自己负责 toggle,否则会先被 + * (含任务卡与「定位到素材」)与点那枚开合开关不算——后者自己负责 toggle,否则会先被 * 收起再被 toggle 打开,表现为按钮失灵。 - * 2. **入口顺序**:「生成任务」排在「依赖 / 类型」排列方式之前(动作在前、排列方式收行尾)。 + * 2. **入口位置**:开关常驻画布**右上角**的锚点里(形态照抄美术画布),不再占工具条那一行; + * 工具条上因此只剩资源动作与排列方式。 * 3. **行内间距只有一套**:「依赖 / 类型」与前一按钮之间不能只剩分段控件自带的那点间隙。 */ @@ -124,22 +125,36 @@ describe('「生成任务」侧栏的开合与入口位置', () => { ).not.toBeNull(); }); - it('「生成任务」入口排在「依赖 / 类型」排列方式之前', async () => { + 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') === '生成任务', - ); + // 工具条不再有「生成任务」入口:入口只有一个,就是右上角那一枚开关。 + expect( + buttons.some( + (button) => button.getAttribute('aria-label') === '生成任务', + ), + ).toBe(false); 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); + + const toggle = screen.getByRole('button', { name: '生成任务' }); + const anchor = toggle.closest('.game-resource-generation-tasks-anchor'); + expect(anchor).not.toBeNull(); + expect(anchor?.getAttribute('data-generation-tasks-placement')).toBe( + 'canvas', + ); + // 锚点挂在主视窗(stage)里:右上角坐标是相对画布工作面算的,不是相对整个窗口。 + expect( + document + .querySelector('.game-workbench-stage') + ?.contains(anchor as Element), + ).toBe(true); }); it('「依赖 / 类型」与前一按钮之间的间距跟行内其他按钮一致', () => { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index d23c50d12..02242aad9 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」,见 §3.10 / §7.9;2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变) +更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**,见 §3.10 / §7.9;2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变) ## 1. 产品定位 @@ -164,7 +164,7 @@ - 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。 - 外壳用共享 chrome(`packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。 - 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。 -- 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布且不留贴边把手,开合只走工具条上常驻的「生成任务 · N」按钮(两个页签下都在);每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。 +- 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(位置与开合形态照抄网页端美术画布的任务侧栏的右上角锚点,颜色与外形仍走 AGC 平台 token;2026-09-21 由「左侧贴边 + 工具条入口」改为「右上角锚点 + 常驻开关」):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布、只留那一枚右上角开关(工具条上不再有重复入口),开合只走画布右上角常驻的「生成任务 · N」按钮(两个页签下都在);每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布右上角锚点里(照抄美术画布那一处)、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。锚点按工作面分档:资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。 - 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。**点「生成」即同步关闭面板**(不等 IPC、不等排队、不等生成),面板里**不出现**「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是任务侧栏与工具栏提示条。**只有「点击瞬间就失败」**(校验不过、权限拒绝、start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消请求(请求挂在任务与账本上,不挂在面板生命周期上)。 - 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。 - 规范类和固定档:生成规范下的图标规范 / 角色规范 / 自定义规范都是固定档——只读展示当前规格,不给点了不生效的比例控件。本地 IPC 只有一条规范通道(`spec` 在 Rust 侧收口到 `icon-spec`),**没有 `specType` 入参**,因此角色规范与自定义规范共用该通道,靠素材名与提示词区分。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8d0bd4a5a..9a59c0263 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-09-21 AGC「生成任务」侧栏移到画布右上角(照抄美术画布,保留 AGC 样式) + +- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合」。改前状态:侧栏本体是 `position: fixed; top: 4rem; bottom: 6rem; left: 0.75rem` 的左侧贴边面板,开合口只有工具条上那一枚「生成任务 · N」按钮(资源 / 运行两个页签各渲染一次),折叠态不留任何常驻入口;分组、失败可见、限高滚动、提交后自动展开这四条当时已经具备。 +- 决策:侧栏改挂**画布右上角的锚点**(`.game-resource-generation-tasks-anchor`),形态照抄网页端美术画布的任务侧栏(`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6072+` 的 `.image-canvas-editor__task-sidebar*`):**开关常驻右上角、面板在开关左侧展开**,收起态只剩那一枚开关;工具条上那两处重复入口删掉——同一个功能两个入口本身就是两处随时会漂移的状态。锚点是覆盖式的,仍然不 reflow 画布视口。颜色、圆角、字重、阴影**全部继续走 `--platform-*` token**,不照搬网页端的固定色值(该 CSS 文件头既有的口径)。 +- 原因:右上角是画布上唯一「不被右侧『智能创作』对话面板占、也不与左下角栏目工具栏 / 右下角缩放 Dock 打架」的稳定空位;折叠态仍留一枚开关以后,用户不必先想起工具条在哪一行,也不再需要「关掉以后打不开」的兜底(原设计正是靠工具条入口常驻来解决这个问题)。 +- 决策(分档坐标):锚点由 view 的 `placement`(`canvas | run | editor`)落成 `data-generation-tasks-placement`,坐标在样式里分档:资源栏目画布与 UI 编辑器用画布顶边那一档(`top: 3.5rem; right: 0.85rem`),**运行表现层下移到 `top: 7rem`**——它右上角 `top: 20px; right: 20px` 被 C7 版本入口 `game-run-version-picker` 占着,不去抢那一块。 +- 实现要点:开关改由 view 自己渲染(`data-resource-generation-task-toggle` 与计数属性原样保留,可访问名 `生成任务` 唯一),在途计数只在 view 内算一次、开关与面板头部同源(宿主原先那份 `resourceAssetGenerationInFlightCount` 因此删除);面板改由 `max-height: min(30rem, calc(100vh - 12rem))` 封顶 + 内部滚动,自己不再定位(坐标只由锚点一处决定);进场 / 退场动画位移方向跟着锚点翻到正 X。宿主的「点外部收起」判据(排除侧栏本体与 `data-resource-generation-task-toggle`)与「提交受理后自动展开」均未改。 +- 验证:`resourceCanvasAssetGenerationTasksPanel.test.tsx` 11 passed(新增「折叠只剩右上角开关」「开关计数与面板头部同源」「锚点按工作面分档」)、`resourceCanvasAssetGenerationTasksSidebarStyle.test.ts` 11 passed(锚点坐标 / 运行档下移 / 面板不再自定位 / 开关走 token 且无硬编码色 / 窄屏占满宽度)、`resourceCanvasGenerationTasksSidebarDismiss.test.tsx` 5 passed(入口位置改为右上角锚点、工具条不再有生成任务按钮)、`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 5 passed;`appSurface.test.ts` 538 passed / 17 skipped / 0 failed(与既有基线逐条一致);`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。 +- 影响范围:`apps/ai-game-creator-shell/src/features/resource-canvas/{ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTasksSidebar.css}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/{resourceCanvasAssetGenerationTasksPanel.test.tsx,resourceCanvasAssetGenerationTasksSidebarStyle.test.ts,resourceCanvasGenerationTasksSidebarDismiss.test.tsx}`、PRD §3.10。未动 Rust、SpacetimeDB、`packages/`、共享弹窗组件。 +- 已知未覆盖:真实客户端观感(右上角坐标相对画布顶边的落点、与运行表现层版本入口的间距)未在 Tauri 里目视确认;窄屏(≤480px)只有声明级断言。 ## Unity 与 Godot 常用操作指导 两种编辑器的操作指导复用客户端审核 Skill pack:DirectProject 通过原生 Skill 或既有审核资源读取入口按需取得,Agent Runtime 的对应执行工具说明嵌入同源参考。指南不改变插件可用性、执行授权或 Runner 回执;只读说明不能证明编辑器已连接。常用示例与执行失败/部分修改、保存、撤销边界在同一参考中维护,避免提示词和文档各存一份代码。 diff --git a/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md b/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md index 4d6a3033a..9ec83f1d3 100644 --- a/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md +++ b/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md @@ -55,10 +55,20 @@ - 范围核对:48 个改动文件全部落在 AGC 前端、3 个共享画布组件与 3 份文档;`server-rs` / `spacetime` / `docs/openapi` / `src-tauri` **零改动**(后端与契约无红线涉及),无 `.env`、密钥、构建产物或日志进入改动。 - 合并后仍**未复验**的只有两类,且都不阻塞合并:① 真实客户端观感(本批动了动画面板样式、生图浮层紧凑排布与卡片基类 token——声明级与结构级都有守卫,真机要客户端验收确认);② 远程 CI(runner/镜像问题仍在处理,见 Issue #431;按用户要求本批不追)。 +### 2026-09-21 第四批(「生成任务」侧栏移到画布右上角,待客户端验收) + +需求原文:「生成任务列表移到画布右上角(照抄美术画布,但是保留当前的样式)」,对应本文件「后续需求」里那条。 + +- 改前:侧栏本体是 `position: fixed; top: 4rem; bottom: 6rem; left: 0.75rem` 的左侧贴边面板,开合口只有工具条上那一枚「生成任务 · N」按钮(资源 / 运行两个页签各渲染一次);分组、失败可见、限高滚动、提交受理后自动展开这四条本批之前已经具备,本轮只动**位置与开合形态**。 +- 改后:新增右上角锚点 `.game-resource-generation-tasks-anchor`(`position: absolute; top: 3.5rem; right: 0.85rem; z-index: 40`,挂在 `.game-workbench-stage` 里),**开关常驻右上角、面板在开关左侧展开、收起只剩那一枚开关**(与美术画布同一形态);工具条上那两处重复入口删除;面板改由 `max-height: min(30rem, calc(100vh - 12rem))` 封顶 + 内部滚动,自己不再定位。 +- 颜色 / 圆角 / 字重 / 阴影仍全部走 `--platform-*` token(开关沿用原工具条那枚次级胶囊的外观,只多一档面板投影),没有照搬网页端的固定色值。 +- 分档坐标:view 的 `placement`(`canvas | run | editor`)落成 `data-generation-tasks-placement`;运行表现层下移到 `top: 7rem`,避开它右上角 `game-run-version-picker`(`top: 20px; right: 20px`)。 +- 本机执行过:`resourceCanvasAssetGenerationTasksPanel.test.tsx` 11 passed、`resourceCanvasAssetGenerationTasksSidebarStyle.test.ts` 11 passed、`resourceCanvasGenerationTasksSidebarDismiss.test.tsx` 5 passed、`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 5 passed;仓库根 `tests/appSurface.test.ts` **538 passed / 17 skipped / 0 failed**(与既有基线逐条一致);`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。 +- 仍未做:真实客户端目视(右上角落点、与版本入口的间距)与远程 CI 都没跑;窄屏(≤480px)只有声明级断言。 ## 后续需求 - 资源卡顶部独立名称区与全类型统一样式,确认 assetName/用户素材名的正式来源。 -- 生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合。 +- ~~生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合。~~ **2026-09-21 已处理**:侧栏改挂画布右上角锚点(开关常驻、面板在开关左侧展开,形态照抄美术画布,样式仍走 AGC 平台 token),工具条上的重复入口删除;`placement` 分档让运行表现层让开右上角版本入口。见下「2026-09-21 第四批」。 - 素材替换关系与画布点选替换;聊天拖拽批量引用、复制对话保留有效引用。 ## 边界与验收 -- 2.52.0 From 9639a080c60dd524ed7c33f82c0154efd377c6dd Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 21 Sep 2026 03:37:43 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8D=A1=E6=8B=96?= =?UTF-8?q?=E5=88=B0=E5=AF=B9=E8=AF=9D=E6=A0=8F=E5=AE=9E=E7=8E=B0=E6=89=B9?= =?UTF-8?q?=E9=87=8F=20@=20=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增落点模型 resourceCardReferenceDropModel:对话栏矩形量取(0 x 0 判成没有落点,避免画布内拖动被误判)、命中判据、引用构造与落点提示文案。 - 引用构造与工具条「引用」按钮逐字一致(只认已登记 manifest 素材、source 为 resource-card、kind/分类/标签取自资源投影),一条都构造不出来时用提示条说明原因。 - 拖动中指针落到对话栏上即从排版切到引用:卡片不再跟指针走,改铺虚线落点浮层并提示「松手即可 @ 引用 N 项素材」;拖回画布内松手仍是原来的排版语义。 - 批量范围与拖动位移同一个集合(多选整批、单选只引用按住的那张);新增批量事件 RESOURCE_REFERENCE_INSERT_MANY_EVENT,App 侧一次插入整批并只聚焦一次,空批次不派发。 - 对话栏挂 ref(不 document.querySelector)、落点浮层按对话栏当时的屏幕矩形铺在根节点上,不改对话栏自身的定位与排版。 - 测试:新增落点模型用例 5 条与画布集成用例 4 条(单卡/多选/拖回画布/pointercancel),resourceReferenceInput 新增批量事件与一次插入整批 chip 两条,共 39 条定向全绿。 - 文档:功能说明新增「拖拽引用」一节与完成清单条目、PRD 更新时间、decision-log 新增 2026-09-21 条目。 --- apps/ai-game-creator-shell/src/App.tsx | 28 +- .../project-workspace/resourceReferences.ts | 25 ++ apps/ai-game-creator-shell/src/styles.css | 133 +++++- .../src/view/project-development/index.tsx | 134 +++++- .../resourceCardReferenceDropModel.ts | 101 +++++ .../resourceCanvasChatReferenceDrop.test.tsx | 398 ++++++++++++++++++ .../resourceCardReferenceDropModel.test.ts | 145 +++++++ .../tests/resourceReferenceInput.test.tsx | 66 ++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 2 +- .../shared-memory/decision-log.md | 9 + .../【功能说明】AGC聊天素材引用-2026-09-08.md | 11 +- 11 files changed, 1023 insertions(+), 29 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceCardReferenceDropModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceCardReferenceDropModel.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7be35aff2..4d28f041a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -283,7 +283,9 @@ import type { import { chatComposerDraftToDirectCodexUserItem, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertEventDetail, + type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; @@ -3633,15 +3635,39 @@ export function App({ chatComposerRef.current?.insertReferences([detail.reference]); chatComposerRef.current?.focus(); }; + /** + * 画布拖拽的批量 @ 引用:与单条入口同一个消费点,只是一次把整批插进去。 + * + * 逐条派发单条事件也能跑,但每次都会重建一次草稿并重新聚焦,而且插入顺序只能靠事件顺序兜着; + * 批次为空时静默返回(不开一次空事务)。 + */ + const handleResourceReferenceInsertMany = (event: Event) => { + const detail = ( + event as CustomEvent + ).detail; + const references = detail?.references ?? []; + if (references.length === 0) return; + chatComposerRef.current?.insertReferences(references); + chatComposerRef.current?.focus(); + }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - return () => + window.addEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + return () => { window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); + window.removeEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + }; }, []); function prepareProjectAssetRegisterDraft(localPath: string) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 938e65e3c..a150971df 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -74,6 +74,31 @@ export function dispatchResourceReferenceInsert(reference: ChatReference) { ); } +/** + * 一次插入多条引用的事件(画布拖拽批量 @ 引用用)。 + * + * 与单条事件同一条链路、同一个消费点,只是把「N 条」合成一次派发:逐条派发会让草稿重建 + * N 次、聚焦 N 次,而且插入顺序只能靠事件顺序兜着。批次为空时什么都不派发(不开一次空事务)。 + */ +export const RESOURCE_REFERENCE_INSERT_MANY_EVENT = + 'agc-resource-reference-insert-many'; + +export type ResourceReferenceInsertManyEventDetail = { + references: ChatReference[]; +}; + +export function dispatchResourceReferenceInsertMany( + references: ChatReference[], +) { + if (typeof window === 'undefined' || references.length === 0) return; + window.dispatchEvent( + new CustomEvent( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + { detail: { references } }, + ), + ); +} + /** * `@` 输入区 portal 到 `document.body` 的两块浮层:资源选择器与输入区候选菜单。 * diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 6833fd921..d75d23d39 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -1070,7 +1070,11 @@ textarea { padding: 10px 12px; border: 1px solid var(--platform-subpanel-border); border-radius: 12px; - background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent); + background: color-mix( + in srgb, + var(--platform-subpanel-fill) 94%, + transparent + ); box-shadow: 0 8px 24px rgb(31 24 16 / 8%); } @@ -5904,6 +5908,38 @@ iframe.preview-frame { box-shadow: var(--platform-nav-active-shadow); } +/* + * 「拖动素材到对话」的落点提示:按对话栏当时的屏幕矩形铺一层虚线描边 + 一枚说明 chip。 + * + * 纯只读浮层(`pointer-events: none`):不接管命中,也不改对话栏自身的排版与定位——拖动是指针 + * 捕获的,命中判定本来就在画布那侧算,这里只负责把"松手会发生什么"说出来。 + */ +.game-workbench-chat-reference-drop { + position: fixed; + z-index: 60; + display: grid; + place-items: start center; + padding-top: 1rem; + border: 2px dashed var(--platform-accent); + border-radius: 12px; + background: color-mix(in srgb, var(--platform-accent) 8%, transparent); + pointer-events: none; +} + +.game-workbench-chat-reference-drop-chip { + display: inline-flex; + min-height: 30px; + align-items: center; + padding: 0 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 999px; + background: var(--platform-subpanel-fill); + box-shadow: var(--platform-panel-shadow); + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 700; +} + .game-workbench-stage[data-resource-view-state='resources.ui-editor'] { grid-template-rows: auto minmax(0, 1fr); } @@ -7557,7 +7593,6 @@ iframe.preview-frame { padding-right: 50px; } - /* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。 卡片底部整条是卡面名称(`.game-resource-card-name`),所以血缘角标压在名称条之上; 右下角仍是媒体播放钮,最大宽度按右侧让出播放钮的宽度。 @@ -10423,7 +10458,9 @@ button.design-workspace-tree__entry:hover, overflow: visible; } -.game-workbench-layout--design .game-workbench-chat .project-supervisor-surface { +.game-workbench-layout--design + .game-workbench-chat + .project-supervisor-surface { grid-row: 2; } @@ -10781,8 +10818,7 @@ button.design-workspace-tree__entry:hover, 这样「消息内容 / 顶栏 / 输入盒」三者共用同一个基准值,不会再出现 「消息内缩 16px、输入盒只有 10px」这种左右不齐,或不一致的上/下间距。 (非 direct-codex 的面板仍走上面那条 `padding: 10px`。) */ -.game-workbench-chat - .project-supervisor-surface.is-direct-codex { +.game-workbench-chat .project-supervisor-surface.is-direct-codex { padding: 0; } @@ -10921,7 +10957,9 @@ button.design-workspace-tree__entry:hover, 菜单跑到**整个 composer 上方**、和触发钮之间隔着整个输入区,输入区一变高(多行、 引用 chip、AI 润色)菜单与提示就跟着往上飘,看起来就是"编辑框把弹层挤开了"。 `relative` 不会把它移出控制排(`right/bottom: auto` 仍在原位),只补回锚点。 */ -.game-workbench-layout--design .project-supervisor-composer-controls .conversation-model-select, +.game-workbench-layout--design + .project-supervisor-composer-controls + .conversation-model-select, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex @@ -10957,7 +10995,9 @@ button.design-workspace-tree__entry:hover, /* 控制排三只方钮(`+` 附件 / `@` 引用 / 发送)共用一套尺寸。发送钮单独加圆角与主色, 见下面两条规则。 */ -.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button, +.game-workbench-layout--design + .project-supervisor-composer-controls + .project-supervisor-submit-button, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex @@ -11021,7 +11061,9 @@ button.design-workspace-tree__entry:hover, } /* 发送钮是圆形主色块(Codex 观感):直径与左右两只方钮同档,圆角收到 999px。 */ -.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button, +.game-workbench-layout--design + .project-supervisor-composer-controls + .project-supervisor-submit-button, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex @@ -12253,7 +12295,8 @@ button.design-workspace-tree__entry:hover, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - > * + * { + > * + + * { margin-top: 14px; } @@ -12310,14 +12353,16 @@ button.design-workspace-tree__entry:hover, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - details[data-testid='live-reasoning'] > summary { + details[data-testid='live-reasoning'] + > summary { cursor: pointer; } .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - details[data-testid='live-reasoning'] pre { + details[data-testid='live-reasoning'] + pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; @@ -12330,14 +12375,33 @@ button.design-workspace-tree__entry:hover, .project-supervisor-surface.is-direct-codex .project-supervisor-message-list .message - :where(p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, pre, code, strong, em, th, td) { + :where( + p, + h1, + h2, + h3, + h4, + h5, + h6, + ul, + ol, + li, + blockquote, + pre, + code, + strong, + em, + th, + td + ) { color: inherit; } .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message p { + .message + p { opacity: 1; } @@ -12361,19 +12425,23 @@ button.design-workspace-tree__entry:hover, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message p, + .message + p, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message ul, + .message + ul, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message ol, + .message + ol, .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list - .message li { + .message + li { line-height: 1.5 !important; } @@ -12429,7 +12497,11 @@ button.design-workspace-tree__entry:hover, .message-turn-process > summary > .agent-process-summary - :is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron) { + :is( + .agent-process-summary-icon, + .agent-process-summary-meta, + .agent-process-summary-chevron + ) { color: inherit; } @@ -12501,8 +12573,14 @@ button.design-workspace-tree__entry:hover, } /* chevron 旋转只有一处来源:折叠态不旋转,展开态 rotate(180deg)(120ms 过渡内截图会看到中间角度)。 */ -.message-turn-process > summary .agent-process-summary .agent-process-summary-chevron, -.design-agent-reasoning > summary .agent-process-summary .agent-process-summary-chevron { +.message-turn-process + > summary + .agent-process-summary + .agent-process-summary-chevron, +.design-agent-reasoning + > summary + .agent-process-summary + .agent-process-summary-chevron { transform: none; } @@ -12559,10 +12637,21 @@ details.design-agent-reasoning[open] .project-supervisor-surface.is-direct-codex .agent-tool-call-group[data-has-failure='true'] > .agent-tool-call-group-head - :is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron), + :is( + .agent-process-summary-icon, + .agent-process-summary-meta, + .agent-process-summary-chevron + ), .game-workbench-chat .project-supervisor-surface.is-direct-codex .agent-tool-call-group-row[data-status='failed'] - :is(.agent-tool-call-row-head, .agent-tool-call-row-text, .agent-tool-call-row-status, .agent-tool-call-row-icon, .agent-tool-call-row-duration, .agent-tool-call-row-chevron) { + :is( + .agent-tool-call-row-head, + .agent-tool-call-row-text, + .agent-tool-call-row-status, + .agent-tool-call-row-icon, + .agent-tool-call-row-duration, + .agent-tool-call-row-chevron + ) { color: var(--platform-button-danger-text, #a6402f); } 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 8a833b798..af73d293f 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 @@ -117,7 +117,9 @@ import { ResourceReferenceInput } from '../../features/project-workspace/Resourc import { type ChatComposerDraft, type ChatReference, + dedupeChatReferences, dispatchResourceReferenceInsert, + dispatchResourceReferenceInsertMany, isResourceReferenceOverlayTarget, resolveActiveIterationVersion, resourceReferenceCategoryLabel, @@ -344,6 +346,13 @@ import { projectResourcePathExtension, projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; +import { + resourceCardReferenceDropContains, + resourceCardReferenceDropHintLabel, + type ResourceCardReferenceDropRect, + resourceCardReferenceDropRect, + resourceCardReferenceDropReferences, +} from './resourceCardReferenceDropModel'; import { ResourceClassificationPanel } from './ResourceClassificationPanel'; import { EMPTY_PROJECT_RESOURCE_GRAPH, @@ -2365,6 +2374,24 @@ export default function ProjectDevelopmentView({ } | null>(null); const resourceDependencyOverlayRef = useRef(null); + /** + * 「拖动素材到对话」的落点:Agent 对话栏(`.game-workbench-chat`)。 + * + * 拖动是**指针捕获**的:指针跑到对话栏上时,pointermove 仍然回到卡片上,所以落点只能自己量。 + * 用 ref 拿对话栏元素(不 `document.querySelector`),对话栏在 UI 编辑器下不渲染,读不到就是 + * 没有落点。 + */ + const workbenchChatRef = useRef(null); + /** + * 正在拖到对话上时的提示(`null` = 不在落点上)。 + * + * 只活在这次拖动里:松手(无论落点在哪)与拖动取消都在同两处收干净,不会留下一个挂着的提示条。 + * `count` 是要插入的引用条数,`rect` 是对话栏当时的屏幕矩形(提示条按它铺)。 + */ + const [resourceCardReferenceDrop, setResourceCardReferenceDrop] = useState<{ + count: number; + rect: ResourceCardReferenceDropRect; + } | null>(null); const skipNextResourceCardClickRef = useRef(null); const skipNextResourceCardClickTimerRef = useRef(null); /** @@ -4179,6 +4206,8 @@ export default function ProjectDevelopmentView({ } resourceCardDragRef.current = null; setResourceCardDragPreview(null); + // 取消也要把「拖到对话」的落点提示收干净:它和拖动是同一个生命周期。 + setResourceCardReferenceDrop(null); resourceDependencyOverlayRef.current?.clearDragPreview(); clearSkippedResourceCardClick(); }, [clearSkippedResourceCardClick]); @@ -5830,6 +5859,27 @@ export default function ProjectDevelopmentView({ [], ); + /** + * 「拖到对话」的落点裁决:指针是否在 Agent 对话栏上,以及这次松手会插入几条引用。 + * + * pointermove(画提示)与 pointerup(真正派发)共用这一条判据,所以提示条上说的 + * 「松手即可 @ 引用 N 项素材」与实际插入的条数不会分成两套口径。 + */ + const resolveResourceCardReferenceDrop = useCallback( + (clientX: number, clientY: number, movedResourceIds: readonly string[]) => { + const rect = resourceCardReferenceDropRect(workbenchChatRef.current); + if (!rect || !resourceCardReferenceDropContains(rect, clientX, clientY)) { + return null; + } + return { + count: resourceCardReferenceDropReferences(movedResourceIds, resources) + .length, + rect, + }; + }, + [resources], + ); + const handleResourceCardPointerDown = useCallback( (event: ReactPointerEvent, resource: ProjectResource) => { if (event.button !== 0) { @@ -5927,6 +5977,23 @@ export default function ProjectDevelopmentView({ skipNextResourceCardClickRef.current = drag.resourceId; } drag.changed = true; + /** + * 拖到对话栏上:这一刻的语义从「排版」切成「引用」。 + * + * 卡片不再跟着指针走(否则整批卡会被拖到画布外的对话栏上,像要把素材搬出项目), + * 落点提示按对话栏矩形铺开;指针移回画布时预览会在下一次 pointermove 自动恢复。 + */ + const referenceDrop = resolveResourceCardReferenceDrop( + event.clientX, + event.clientY, + drag.moves.map((move) => move.resourceId), + ); + setResourceCardReferenceDrop(referenceDrop); + if (referenceDrop) { + setResourceCardDragPreview(null); + resourceDependencyOverlayRef.current?.clearDragPreview(); + return; + } // 位移只有一份:整张选择集按同一个世界坐标位移走,渲染时每张卡各自加上它(见 // `renderResourceBookCard`),落盘时再按同一尺度换成栏目内局部坐标。 const scaledDeltaX = deltaX / drag.startScale; @@ -5945,7 +6012,7 @@ export default function ProjectDevelopmentView({ y: (primary?.startY ?? 0) + scaledDeltaY, }); }, - [], + [resolveResourceCardReferenceDrop], ); const finishResourceCardPointerDrag = useCallback( @@ -5972,6 +6039,38 @@ export default function ProjectDevelopmentView({ deferSkippedResourceCardClickCleanup(drag.resourceId); } setResourceCardDragPreview(null); + /** + * 松手落在对话栏上:这次拖动的语义是「把素材 @ 进对话」,不是排版 —— 一条坐标都不写。 + * + * 不写盘的另一个理由是它本来就写不了:卡片画的是栏目内局部坐标,拖动位移换到对话栏那一段 + * 会得到一串跑到画布外的坐标。引用按 `drag.moves` 派发(多选时整批、单选时只有按住的那张), + * 与拖动位移的选择集合同源;一条也引用不了(素材都没登记)时给原因,不静默什么都不发生。 + */ + const referenceDrop = + commit && drag.changed + ? resolveResourceCardReferenceDrop( + event.clientX, + event.clientY, + drag.moves.map((move) => move.resourceId), + ) + : null; + setResourceCardReferenceDrop(null); + if (referenceDrop) { + const references = dedupeChatReferences( + resourceCardReferenceDropReferences( + drag.moves.map((move) => move.resourceId), + resources, + ), + ); + if (references.length > 0) { + dispatchResourceReferenceInsertMany(references); + } else { + setResourceWorkbenchNotice( + '选中的素材都还没登记为项目资源,暂时不能 @ 引用', + ); + } + return; + } // 取消手势(pointercancel / 捕获丢失 / 窗口失焦):拖动期间从未改写坐标,丢掉预览即 // 回到拖动前的位置;已选中的多选保持不动,不在这里清选择。 if (!commit || !drag.changed) { @@ -6014,7 +6113,9 @@ export default function ProjectDevelopmentView({ activeResourceLayout, clearSkippedResourceCardClick, deferSkippedResourceCardClickCleanup, + resolveResourceCardReferenceDrop, resourceCategoryScopeKey, + resources, setResourceCanvasHistory, ], ); @@ -10724,12 +10825,41 @@ export default function ProjectDevelopmentView({ {!uiEditorRoute ? ( -