diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 68071970e..5ef0c6397 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -1212,56 +1212,20 @@ } /* - * 「点选替换」提示条:点选态下贴在资源画布顶端居中浮出。 + * 「替换素材」面板的锚点:画布右上角、任务开关那一枚的下方。 * - * 与右下角缩放控件同一层语义(都在管理区之上,`position: absolute` 锚在 - * `.game-resource-book-manager` 上,而不是挂在会随视口缩放 / 平移的场景里), - * 配色沿用工作台提示条那一套(`.game-resource-live-notice`)。 + * 面板本体(`.image-canvas-editor__project-asset-picker--floating`)是**非模态浮层**: + * 面板开着的时候画布照常可点,在画布上点目标素材即落进面板的当前选择,写入只由面板「确认」发起。 + * 所以这里只负责锚定,壳样式(边框 / 底色 / 阴影 / 头部与滚动区)在共享样式表里,宿主不抄第二份。 * - * 它是**会话级**提示:会话期间常驻,不跟随某张卡,所以层级高于场景(20)与缩放(40); - * 场景根是 `pointer-events: none`,这里必须显式收回指针事件,「取消」才点得动。 + * 位置取舍:右上角是画布上唯一不被栏目工具栏(左下)与缩放 / 撤销 Dock(右下)占用的稳定空位; + * 顶部 3.5rem 起是任务开关,面板从 8.5rem 起排,两者同时开着也不重叠。 */ -.game-resource-canvas-pick-hint { +.game-resource-replacement-panel { position: absolute; - top: 14px; - left: 50%; - z-index: 50; - display: inline-flex; - align-items: center; - gap: 10px; - max-width: min(92vw, 34rem); - padding: 8px 8px 8px 12px; - border: 1px solid #edc7b5; - border-radius: 10px; - background: rgb(255 247 241 / 96%); - box-shadow: 0 6px 18px rgb(112 70 52 / 14%); - color: #8d5b45; - font-size: 11px; - line-height: 1.4; - transform: translateX(-50%); - pointer-events: auto; -} - -.game-resource-canvas-pick-hint-text { - flex: 1 1 auto; - min-width: 0; -} - -.game-resource-canvas-pick-hint-error { - flex: 0 1 auto; - min-width: 0; - color: #b3261e; - font-weight: 700; -} - -.game-resource-canvas-pick-hint button { - flex: 0 0 auto; - border: 1px solid #dc9b7d; - border-radius: 8px; - background: #fff; - color: #9b5537; - font-size: 11px; - cursor: pointer; + top: 8.5rem; + right: 0.85rem; + z-index: 45; } /* 栏目画布底部工具栏:栏目页(view=child)左下角的画布 chrome。 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 af73d293f..4e6602202 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 @@ -2272,13 +2272,21 @@ export default function ProjectDevelopmentView({ useState([]); const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false); /** - * 「点选替换」:关掉候选弹窗(它的全屏遮罩会盖住画布),直接在资源画布上点目标素材。 + * 在画布上点选出来的替换目标(manifest 资产 id)+ 这次点选的序号。 * - * 与弹窗互斥,但**不重置** `source` / `candidates`:点选时的合法性判据( - * `resolveResourceReplacementPick`)与写入载荷用的还是这一份冻结的源身份与权威候选。 + * 替换面板是**非模态**的:面板开着的时候画布照常可点,点中的合法候选落成这里的「本次目标」, + * 面板里随之选中它;真正的写入仍然只由面板的「确认」发起。所以它既不是「点了就换」, + * 也不是第二份候选——合法性判据(`resolveResourceReplacementPick`)与写入载荷用的还是 + * 那一份冻结的源身份与权威候选。 + * + * `revision` 是给面板的「初值换了」信号:面板只在打开那一帧读 `selectedAssetIds`, + * 而数组本身每次渲染都会重建、不能当信号(见组件里的注释)。连续点同一张卡时序号照增, + * 保证每次都真的同步一次。 */ - const [resourceReplacementPickMode, setResourceReplacementPickMode] = - useState(false); + const [resourceReplacementPick, setResourceReplacementPick] = useState<{ + assetId: string; + revision: number; + } | null>(null); const [resourceReplacementLoading, setResourceReplacementLoading] = useState(false); const [resourceReplacementError, setResourceReplacementError] = useState< @@ -5885,9 +5893,9 @@ export default function ProjectDevelopmentView({ if (event.button !== 0) { return; } - if (resourceReplacementPickMode) { - // 点选态下左键按下就是「选这个目标」:不起拖拽、不换选中,直接把这次点选交给 - // 替换链路(合法即提交,非法把原因留在提示条上)。 + if (resourceReplacementOpen) { + // 替换面板开着时左键按下就是「选这个目标」:不起拖拽、不换画布选中,直接把这次点选 + // 交给替换链路(合法即记为本次目标,非法把原因留在面板里)。 pickResourceReplacementTargetRef.current(resource); return; } @@ -5953,7 +5961,7 @@ export default function ProjectDevelopmentView({ resourceCategoryScopeKey, resourceBookOpensAllResources, resourceBookScenePlan, - resourceReplacementPickMode, + resourceReplacementOpen, selectedResourceIds, visibleResourceIds, ], @@ -6156,16 +6164,6 @@ export default function ProjectDevelopmentView({ ) { return; } - // 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡, - // 点空白既不退出点选,也不把用户正在替换的选中清掉。中键 / 空格平移照常, - // 滚轮平移与缩放走 manager 上那条与指针无关的通路,不受影响。 - if ( - resourceReplacementPickMode && - event.button === 0 && - !resourceCanvasSpacePanRef.current - ) { - return; - } resourceBookTransitionControllerRef.current.settle(); // 右键平移,保留中键/空格抓手;空白处左键继续框选。 if (isPan) { @@ -6201,7 +6199,6 @@ export default function ProjectDevelopmentView({ activePageCategory, clearResourceCanvasFocus, resourceBookOpensAllResources, - resourceReplacementPickMode, ], ); @@ -7020,8 +7017,8 @@ export default function ProjectDevelopmentView({ setResourceReplacementError(null); setResourceReplacementCandidates([]); setResourceReplacementSource(source); - // 从工具条重新进入口时先退出上一次的点选会话(点选态下工具条仍可点)。 - setResourceReplacementPickMode(false); + // 从工具条重新进入口时清掉上一次在画布上点出来的目标:面板里的选中必须与这次源素材一致。 + setResourceReplacementPick(null); setResourceReplacementLoading(true); try { const result = await readVersionResourceReplacementCandidates({ @@ -7043,29 +7040,21 @@ export default function ProjectDevelopmentView({ [activeVersionId, projectPath, projectVersions], ); /** - * 结束一次替换会话:弹窗、点选态与冻结的源身份 / 候选一起清干净。 + * 结束一次替换会话:面板、点选目标与冻结的源身份 / 候选一起清干净。 * - * 弹窗取消、点选态取消(Esc / 提示条「取消」)与替换成功都走这一处,避免出现 - * "弹窗关了但点选态还挂着"或"点选退出了但候选还留着"的半截状态。 + * 面板取消(× / Esc)、替换成功与切工作面都走这一处,避免出现「面板关了但目标还挂着」的 + * 半截状态。 */ const closeResourceVersionReplacement = useCallback(() => { setResourceReplacementOpen(false); - setResourceReplacementPickMode(false); + setResourceReplacementPick(null); setResourceReplacementSource(null); setResourceReplacementCandidates([]); setResourceReplacementError(null); - // 点选态给资源卡留的一次性点击抑制在这里收尾:不清干净的话,退出点选后下一次点 + // 点选给资源卡留的一次性点击抑制在这里收尾:不清干净的话,关掉面板后下一次点 // 同一张卡会被吞掉一次(与拖拽结束后的收尾同一口径)。 clearSkippedResourceCardClick(); }, [clearSkippedResourceCardClick]); - /** - * 进入「点选替换」:关掉候选弹窗(它的全屏遮罩会盖住画布),冻结的源身份与权威候选原样留着。 - */ - const enterResourceReplacementPickMode = useCallback(() => { - setResourceReplacementOpen(false); - setResourceReplacementError(null); - setResourceReplacementPickMode(true); - }, []); /** * 确认替换:直接改该版本的绑定(不建新版本),成功后重读 manifest。 * @@ -7076,7 +7065,10 @@ export default function ProjectDevelopmentView({ const confirmResourceVersionReplacement = useCallback( async (assetIds: string[]) => { const source = resourceReplacementSource; - const replacementResourceId = assetIds[0]; + // 点选出来的目标优先兜底:面板的当前选择来自 `initialSelectionRevision` 同步, + // 用户在点完画布那一帧立刻按确认(重渲染还没落地)时,不该给出「请选择一个替换素材」。 + const replacementResourceId = + assetIds[0] ?? resourceReplacementPick?.assetId ?? null; if (!source || !replacementResourceId) { setResourceReplacementError('请选择一个替换素材'); return; @@ -7130,6 +7122,7 @@ export default function ProjectDevelopmentView({ projectPath, reloadManifestAfterAssetCommand, resourceReplacementLoading, + resourceReplacementPick, resourceReplacementSource, ], ); @@ -7208,11 +7201,11 @@ export default function ProjectDevelopmentView({ }, [resourceReplacementOpen, sweepResourceReplacementPreviews]); /** - * 点选态:把画布上点到的那张资源卡落成一次替换提交,或把「为什么不能替换」留在提示条上。 + * 画布点选:把点到的那张资源卡落成**本次替换目标**,或把「为什么不能替换」留在面板里。 * * 卡片的单击不参与选中:这里先登记一次性抑制(与卡片拖拽后抑制误点同一机制),随后那次 * `click` 会在 `handleResourceSelect` 里被吃掉 —— 只是这一下被消费,不是把单击全局关掉。 - * 合法目标直接走既有 `confirmResourceVersionReplacement`,写入路径与弹窗确认**完全同一条**。 + * 合法目标只记进面板(不写盘):写入路径与面板确认**完全同一条**,由用户按下确认才算数。 */ const pickResourceReplacementTarget = useCallback( (resource: ProjectResource) => { @@ -7229,49 +7222,34 @@ export default function ProjectDevelopmentView({ return; } setResourceReplacementError(null); - void confirmResourceVersionReplacement([ - resolution.replacementResourceId, - ]); + setResourceReplacementPick((current) => ({ + assetId: resolution.replacementResourceId, + revision: (current?.revision ?? 0) + 1, + })); }, - [ - confirmResourceVersionReplacement, - resourceReplacementCandidates, - resourceReplacementSource, - ], + [resourceReplacementCandidates, resourceReplacementSource], ); pickResourceReplacementTargetRef.current = pickResourceReplacementTarget; /** - * 点选态的退出:Esc、提示条「取消」、替换成功(`closeResourceVersionReplacement`)。 + * 替换面板只在资源画布上成立:切运行态 / 进 UI 编辑器就结束这次会话。 * - * Esc 挂在 **document** 上并 `stopPropagation`:资源画布自己的 Esc 挂在 window 上(清画布 - * 焦点 = 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到"Esc 只退出 - * 点选、不连带清选中"(与画布浮层「浮层打开时 Escape 归浮层所有」同一口径)。 - * - * 点选会话只在资源画布可见时存在:切运行态 / 进 UI 编辑器就结束它,否则会留下一个既看不到 - * 提示条、也按不到 Esc 的会话。 + * 非模态面板与画布是同一屏的两半,离开这块工作面它既点不到目标素材、也说不清自己在替换谁; + * Esc / × 关闭由面板自己管(见共享弹窗组件的 `nonModal`)。 */ useEffect(() => { - if (!resourceReplacementPickMode) { + if (!resourceReplacementOpen) { return undefined; } if (mode !== 'resources' || uiEditorRoute) { closeResourceVersionReplacement(); return undefined; } - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape') { - return; - } - event.stopPropagation(); - closeResourceVersionReplacement(); - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + return undefined; }, [ closeResourceVersionReplacement, mode, - resourceReplacementPickMode, + resourceReplacementOpen, uiEditorRoute, ]); @@ -10331,27 +10309,6 @@ export default function ProjectDevelopmentView({ onPointerCancel={stopResourceCanvasPan} onLostPointerCapture={handleResourceCanvasLostPointerCapture} /> - {resourceReplacementPickMode ? ( -
- - 在画布上点选要替换成的素材;点击空白处不会退出 - - {resourceReplacementError ? ( - - {resourceReplacementError} - - ) : null} - -
- ) : null} {resourceCanvasBottomToolbarCategory ? ( + {/* + 版本级资源替换(直接替换):复用美术画布的参考图选择组件,但走**非模态浮层**—— + 面板挂在画布右上角(在任务开关下方),面板开着的时候画布照常可点:在画布上点目标 + 素材即落进面板的当前选择,写入仍然只由面板的「确认」发起。 + 候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),组件只负责把 + 管线给的 `sourceUrl` 画出来;读不到时退回类型占位,与资源卡占位语义一致。 + */} +
+ { + const resource = + resourceByManifestAssetId.get(asset.id) ?? null; + const previewIdentity = resource + ? (resourceCardPreviews.identityByResourceId.get( + resource.id, + ) ?? null) + : null; + return ( + + ); + }} + onCancel={closeResourceVersionReplacement} + onConfirm={(assetIds) => + void confirmResourceVersionReplacement(assetIds) + } + /> +
{!uiEditorRoute ? ( @@ -11068,47 +11076,12 @@ export default function ProjectDevelopmentView({ /> ) : null} {/* - 版本级资源替换(直接替换):复用美术画布的参考图弹窗(单选 + 禁用原因 + 提示 + 失败原因)。 - 候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),弹窗只负责把 + 版本级资源替换(直接替换):复用美术画布的参考图选择组件,但走**非模态浮层**—— + 面板挂在画布右上角,面板开着的时候画布照常可点:在画布上点目标素材即落进面板的当前选择, + 写入仍然只由面板的「确认」发起(`confirmResourceVersionReplacement` 与旧弹窗确认同一条)。 + 候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),组件只负责把 管线给的 `sourceUrl` 画出来;读不到时退回类型占位,与资源卡占位语义一致。 */} - { - const resource = resourceByManifestAssetId.get(asset.id) ?? null; - const previewIdentity = resource - ? (resourceCardPreviews.identityByResourceId.get(resource.id) ?? - null) - : null; - return ( - - ); - }} - onCancel={closeResourceVersionReplacement} - onPickFromCanvas={enterResourceReplacementPickMode} - onConfirm={(assetIds) => - void confirmResourceVersionReplacement(assetIds) - } - /> {resourceRecoveryPanelOpen ? (
{ expect(overlay!.className).toMatch(/platform-theme--(light|dark)/u); }); + it('nonModal 形态:只渲染浮层壳,不铺遮罩也不算模态面板', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '选择替换素材' }); + expect(dialog.className).toContain( + 'image-canvas-editor__project-asset-picker--floating', + ); + expect(dialog.className).not.toContain(PLATFORM_MODAL_SHELL.slice(1)); + // 非模态的核心判据:没有全屏遮罩——宿主画布必须保持可点(在画布上点选目标素材)。 + expect(document.querySelector(PLATFORM_OVERLAY)).toBeNull(); + // 关掉就是卸载:宿主把 open 置回 false 后不该在画布角落留半块面板。 + cleanup(); + render( + {}} + onConfirm={() => {}} + />, + ); + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(); + }); + + it('AGC 加载的样式表带非模态浮层的底色 / 边框 / 投影与滚动区', () => { + const shell = findClassRule( + loadedRules, + '.image-canvas-editor__project-asset-picker--floating', + ); + expect( + shell, + '非模态浮层壳不在 AGC 加载的样式表里:面板会变透明', + ).not.toBeNull(); + expect(shell!.declarations.get('background')).toBe( + 'var(--platform-modal-fill)', + ); + expect(shell!.declarations.get('border')).toBe( + '1px solid var(--platform-modal-border)', + ); + expect(shell!.declarations.get('box-shadow')).toBe( + 'var(--platform-panel-shadow)', + ); + + const header = findClassRule( + loadedRules, + '.image-canvas-editor__project-asset-picker-header', + ); + expect(header).not.toBeNull(); + // 浮层自己限高 + 内部滚动:候选再多也不把画布整块盖住。 + const scroll = findClassRule( + loadedRules, + '.image-canvas-editor__project-asset-picker-scroll', + ); + expect(scroll!.declarations.get('overflow-y')).toBe('auto'); + const actions = findClassRule( + loadedRules, + '.image-canvas-editor__project-asset-picker-actions', + ); + expect(actions).not.toBeNull(); + }); it('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => { /* 这里必须**先转绝对路径再判断存在**:`filter(existsSync)` 拿到的是仓库相对路径,`existsSync` diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 639ff14bd..1013d87cd 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -297,7 +297,8 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { } if (command === 'update_local_project_resource_classification') { const input = args?.input as - { assetId?: string; category?: string; tags?: string[] } | undefined; + | { assetId?: string; category?: string; tags?: string[] } + | undefined; return { asset: { id: input?.assetId, @@ -425,15 +426,10 @@ function replacementWrites(invoke: { mock: { calls: unknown[][] } }) { ); } -/** 点选态的提示条;不在点选态时为 `null`。 */ -function pickHint() { - return document.querySelector('.game-resource-canvas-pick-hint'); -} - /** - * 在点选态下点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。 + * 在替换面板开着时点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。 * - * 之所以把 `click` 也补上:点选态要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown + * 之所以把 `click` 也补上:点选要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown * 的那一次 click** 实现的 —— 不补这一下,用例就漏掉了抑制残留这条最可能的回归。 */ async function pickResourceCard(label: string) { @@ -1121,12 +1117,10 @@ describe('版本级资源替换', () => { }); /** - * 替换弹窗的「点选替换」入口(PRD §5.3):关掉弹窗、改在画布上点目标素材。 - * - * 进入点选必须**卸载弹窗**:弹窗外壳是全屏遮罩,留着它画布上的卡根本点不到。 - * 入口本身不改数据:候选读取是打开弹窗时的事,这里一次替换写入都不该有。 + * 替换面板是**非模态浮层**(PRD §5.3):面板开着的时候画布照常可点,在画布上点目标素材即落进 + * 面板里的当前选择;写入仍然只由面板的「确认」发起,所以点选本身一次写入都不该有。 */ - it('点选替换:入口关掉弹窗进入点选态,提示条写明退出方式且不写盘', async () => { + it('替换面板非模态:画布上点候选即落进面板的当前选择,零写入', async () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); @@ -1134,27 +1128,74 @@ describe('版本级资源替换', () => { const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); + // 非模态判据:没有全屏遮罩,面板自己是画布上的浮层(否则画布上的卡根本点不到)。 + expect(dialog.className).toContain( + 'image-canvas-editor__project-asset-picker--floating', + ); + expect(document.querySelector('.platform-overlay')).toBeNull(); - fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' })); + act(() => { + observer?.triggerVisible(); + }); + await pickResourceCard('final.png'); - expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(); - const hint = pickHint(); - if (!hint) throw new Error('点选提示条未渲染'); - expect(hint.textContent).toContain('在画布上点选要替换成的素材'); - // 空白处点击不退出这件事必须写在提示条上:用户按直觉点空白才不会以为点坏了。 - expect(hint.textContent).toContain('点击空白处不会退出'); - expect(within(hint).getByRole('button', { name: '取消' })).not.toBeNull(); + const option = within(dialog).getByRole('option', { + name: '选择替换素材final.png', + }); + await waitFor(() => + expect(option.getAttribute('aria-selected')).toBe('true'), + ); + // 点选只改面板里的当前选择:写入还是得等「确认」。 expect(replacementWrites(invoke)).toHaveLength(0); + // 点选那一下自带的 click 不得换画布选中:源素材仍是选中的那一个。 + expect( + getResourceSelectButton('legacy.png').getAttribute('aria-pressed'), + ).toBe('true'); }); /** - * 点选态下合法目标直接提交:写入路径与弹窗确认**完全同一条**(同一个 `confirm` 函数), - * 载荷必须逐字一致,且成功后自动退出点选态。 + * 在画布上点目标素材只同步「当前选择」这一项:面板里的搜索词与分类筛选不能被重置 —— 用户 + * 常常是先筛到一半、才想起目标就在画布上(这正是这次的入口动机)。同步点由宿主的显式序号给 + * (`initialSelectionRevision`),不是「props 变了就重置」。 + */ + it('画布点选同步当前选择,但不清掉面板里的搜索与分类', async () => { + renderReplacementWorkbench(); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(toolbarAction(toolbar, '替换素材')); + const dialog = await screen.findByRole('dialog', { + name: '选择替换素材', + }); + const search = within(dialog).getByLabelText('搜索参考图素材'); + fireEvent.change(search, { target: { value: 'final' } }); + expect((search as HTMLInputElement).value).toBe('final'); + + act(() => { + observer?.triggerVisible(); + }); + await pickResourceCard('final.png'); + + await waitFor(() => + expect( + within(dialog) + .getByRole('option', { name: '选择替换素材final.png' }) + .getAttribute('aria-selected'), + ).toBe('true'), + ); + // 搜索词还在:同步选择没有顺手把面板筛回「全部 / 空关键词」。 + expect( + (within(dialog).getByLabelText('搜索参考图素材') as HTMLInputElement) + .value, + ).toBe('final'); + }); + /** + * 面板里按下「确认」才写入:写入路径与旧的弹窗确认**完全同一条**(同一个 `confirm` 函数), + * 载荷必须逐字一致,成功后关闭面板并标注血缘。 * - * 同时钉住"一次性抑制"的收尾:退出点选后点**同一张**刚被点选过的卡,单击语义必须完好 + * 同时钉住"一次性抑制"的收尾:关闭面板后点**同一张**刚被点选过的卡,单击语义必须完好 * (换选中)。抑制残留就会在这一步被吞掉 —— 这是最容易被写错的一处。 */ - it('点选替换:点中合法候选提交一次且载荷一致,成功后自动退出点选态', async () => { + it('面板里确认才写入:载荷一致、成功后关闭面板并标注血缘', async () => { const { invoke, onManifestChange } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); @@ -1162,12 +1203,22 @@ describe('版本级资源替换', () => { const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); - fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' })); act(() => { observer?.triggerVisible(); }); await pickResourceCard('final.png'); + await waitFor(() => + expect( + within(dialog) + .getByRole('option', { name: '选择替换素材final.png' }) + .getAttribute('aria-selected'), + ).toBe('true'), + ); + + fireEvent.click( + within(dialog).getByRole('button', { name: '确认选择替换素材' }), + ); await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(1)); expect(replacementWrites(invoke)[0]?.[1]).toEqual({ @@ -1187,13 +1238,15 @@ describe('版本级资源替换', () => { expect.objectContaining({ revision: 6, source: 'asset-command' }), ), ); - // 点选那一下自带的 click 不得换选中:源素材仍是选中的那一个。 + // 写入成功即结束这次会话:面板收掉,画布选中不动。 + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(), + ); expect( getResourceSelectButton('legacy.png').getAttribute('aria-pressed'), ).toBe('true'); - await waitFor(() => expect(pickHint()).toBeNull()); - // 退出点选后单击语义不变:点同一张卡照常选中自己,抑制没有残留。 + // 会话结束后单击语义不变:点同一张卡照常选中自己,抑制没有残留。 const finalCard = (await findResourceSelectButton('final.png')).closest( '.game-resource-card', ); @@ -1210,13 +1263,13 @@ describe('版本级资源替换', () => { }); /** - * 点选态下点非法目标:说明原因、**留在**点选态、零写入,且点空白既不退出也不清选中。 + * 非法目标:说明原因、**留在**面板里、零写入。 * * 四种非法各点一次:源素材本身、不在权威候选里、分类不同的候选(跨栏目点)、 * manifest 里没有的身份(未登记附件)。判据全部来自同一条 `resolveResourceReplacementPick`, - * 文案与候选弹窗同源。 + * 文案与候选列表同源。 */ - it('点选替换:非法目标不提交、留在点选态并说明原因,点空白不退出', async () => { + it('非法目标:面板里说明原因、面板留着、零写入', async () => { const { invoke } = renderReplacementWorkbench({ attachments: [ { @@ -1233,17 +1286,16 @@ describe('版本级资源替换', () => { const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); - fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' })); + const reason = () => within(dialog).getByRole('alert').textContent; // ① 点的就是源素材本身。 await pickResourceCard('legacy.png'); - expect(screen.getByRole('alert').textContent).toBe('替换素材与源素材相同'); - expect(pickHint()).not.toBeNull(); + await waitFor(() => expect(reason()).toBe('替换素材与源素材相同')); expect( getResourceSelectButton('legacy.png').getAttribute('aria-pressed'), ).toBe('true'); - // 空白处左键:既不退出点选,也不清画布选中(清选中会连带收起选中工具条)。 + // 空白处左键:面板不关(非模态面板不该被画布点击吃掉),也不写入。 const canvas = screen.getByLabelText('资源依赖视图'); // 指针捕获在 jsdom 里没有实现;这里补桩是为了让"这条按下到底走到哪一步"只由行为断言 // 判定,而不是被一个缺失的 DOM API 提前打断。 @@ -1258,31 +1310,23 @@ describe('版本级资源替换', () => { clientX: 5, clientY: 5, }); - expect(pickHint()).not.toBeNull(); expect( - getResourceSelectButton('legacy.png').getAttribute('aria-pressed'), - ).toBe('true'); + screen.queryByRole('dialog', { name: '选择替换素材' }), + ).not.toBeNull(); // ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行 // (判据是"候选里有没有",不在前端另算一遍兼容性)。 await pickResourceCard('late.png'); - expect(screen.getByRole('alert').textContent).toBe( - '替换素材未登记或已被删除', - ); - expect(pickHint()).not.toBeNull(); + await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除')); - // ③ 分类不同的候选:目标在别的栏目,点选会话要跨栏目活着。 + // ③ 分类不同的候选:目标在别的栏目,替换会话要跨栏目活着。 await openResourceBookCategory('场景与环境'); - expect(pickHint()).not.toBeNull(); act(() => { observer?.triggerVisible(); }); await pickResourceCard('scene.png'); - expect(screen.getByRole('alert').textContent).toBe( - '替换素材不兼容:分类不同', - ); - expect(pickHint()).not.toBeNull(); - // 非法目标同样不换选中:点选态里卡片单击只用来点选。 + await waitFor(() => expect(reason()).toBe('替换素材不兼容:分类不同')); + // 非法目标同样不换选中:面板开着时卡片单击只用来点选。 expect( getResourceSelectButton('scene.png').getAttribute('aria-pressed'), ).toBe('false'); @@ -1293,31 +1337,23 @@ describe('版本级资源替换', () => { observer?.triggerVisible(); }); await pickResourceCard('草稿.png'); - expect(screen.getByRole('alert').textContent).toBe( - '替换素材未登记或已被删除', - ); - expect(pickHint()).not.toBeNull(); + await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除')); expect(replacementWrites(invoke)).toHaveLength(0); }); /** - * 点选态的 Esc 只退出点选:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 + - * 收浮层),提示条这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的 - * 选中一起丢。 + * 面板开着时的 Esc 只收面板:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 + 收浮层), + * 面板这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的选中一起丢。 * * 判据分两层:window 上的监听器收不到这次 Escape(截断生效),且选中工具条仍在。 * 变异验证:去掉 `event.stopPropagation()`,本用例必须失败。 */ - it('点选替换:Esc 退出点选态且不清画布选中', async () => { + it('替换面板:Esc 收面板且不清画布选中', async () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); fireEvent.click(toolbarAction(toolbar, '替换素材')); - const dialog = await screen.findByRole('dialog', { - name: '选择替换素材', - }); - fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' })); - expect(pickHint()).not.toBeNull(); + await screen.findByRole('dialog', { name: '选择替换素材' }); const windowEsc = vi.fn(); window.addEventListener('keydown', windowEsc); @@ -1328,7 +1364,9 @@ describe('版本级资源替换', () => { } expect(windowEsc).not.toHaveBeenCalled(); - expect(pickHint()).toBeNull(); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(), + ); // 选中没有跟着被清掉:源素材仍是选中的那一个。 expect( getResourceSelectButton('legacy.png').getAttribute('aria-pressed'), diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index d9eb5cac2..15c0850ed 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 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);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 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变) ## 1. 产品定位 @@ -448,7 +448,7 @@ type ProjectVersionResourceReplacement = { - **失败即拒绝**:硬门禁任一项不等、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` CAS 冲突,都必须在写入前拒绝并说明原因;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的版本卡或新的"当前使用"高亮。 - **落盘与留痕**:改绑定属于 `versions` 变化,写入后推进一次 `projectRevision`;并追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`)。**没有可回溯的替换历史**:替换前的身份只有这条审计与 manifest 的 `.previous` 恢复副本。 - **入口与可见效果**:入口在资源卡选中工具条,只对「manifest 资产 + 被当前版本绑定」的素材放行;候选弹窗列出全部候选,命中硬门禁的项渲染但禁用并给出原因,只有尺寸规格差异的项仍可选但带提示。成功后**不切换版本**(没有新版本可切),只重读 manifest;可见变化是资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。按 §7.4 的口径,改绑定不做运行时资源重映射,运行画面本身不会因改绑定而变化;也不自动重载 / 重启运行中的预览(§3.2 末条)。 -- **点选替换(2026-09-13 补充)**:候选弹窗 footer 提供「点选替换」,用于"目标就在画布上、不想在面板里筛"的场景。点它**必须关闭弹窗**(弹窗外壳是全屏遮罩,留着它画布上的卡点不到)并进入画布点选态,画布上出现提示条(「在画布上点选要替换成的素材;点击空白处不会退出」+「取消」)。点中合法候选直接走**同一条**写入链路(载荷与弹窗确认逐字一致,不产生第二套替换口径),成功后自动退出点选态;点中非法目标(分类 / 类型不同、不在后端给出的权威候选里、源素材本身、未登记资源)**不写入**,在提示条上说明原因(与候选弹窗同一条翻译口径)并**留在**点选态。退出只有三条路:Esc(在 `document` 阶段截断,画布全局 Esc 的清选中不随之触发)、提示条「取消」、替换成功;**空白点击不退出、也不清画布选中**。点选态下卡片左键只用于点选(不起拖拽、不换选中,靠一次性抑制而不是全局关掉单击),滚轮平移与 `Ctrl/Cmd` 缩放照常可用。 +- **替换面板在画布上点选目标(2026-09-21;2026-09-13 的「点选替换」由此取代)**:候选面板是画布右上角的**非模态浮层**(无全屏遮罩、不做焦点陷阱,面板自身限高 + 内部滚动),面板开着的时候画布照常可点——在画布上点目标素材即把它落成**面板里的当前选择**(面板随之高亮;用户在面板里已输入的搜索与分类筛选不被重置),写入仍然只由面板「确认」发起(载荷与旧弹窗确认逐字一致,不产生第二套替换口径)。点中非法目标(分类 / 类型不同、不在后端给出的权威候选里、源素材本身、未登记资源)**不写入**,在面板里说明原因(同一条翻译口径)并保留原选择。退出只有两条路:面板「取消」/ 头部 ×、Esc(在 `document` 阶段截断,画布全局 Esc 的清选中不随之触发);替换成功即关闭面板。面板开着时卡片左键只用于点选(不起拖拽、不换画布选中,靠一次性抑制而不是全局关掉单击),滚轮平移、`Ctrl/Cmd` 缩放与空白处框选照常可用。 - **替换血缘标注(2026-09-13 补充)**:替换成功后只改绑定、光环本来就会从源素材移到替换素材,光靠光环说明不了"谁换成了谁",所以在卡面补一层**关系标注**:源素材卡标「已被 <替换素材显示名> 替换」,替换素材卡标「替换自 <源素材显示名>」,两处分别给出稳定 DOM 判据 `data-resource-replaced-by` / `data-resource-replacement-of`,值是对面资源的 **manifest 资产 id**(不是显示名)。这份关系是**宿主会话内状态**:同一会话里再次替换**整条覆盖**上一条(只保留当前有效的一条 A→B,不做历史链,与 §7.8「只展示当前有效关系」同口径);**切换项目 / 关闭工作台 / 重新加载 manifest 都不保留**——客户端没有替换关系的读侧事实源(写入结果只回 `versionId` / `committedProjectRevision`,审计 `asset.version_binding.replace` 只有写侧),manifest 的 `.previous` 只是原子安装的崩溃兜底副本、安装成功即删,不能当数据源,所以本标注**只在本次会话有效,不假装持久**。 ### 5.4 游戏迭代版本(P1) @@ -657,7 +657,7 @@ type ProjectAgentMudPointAttribution = { 5. 硬门禁任一为 false、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` 冲突,都必须在写入前拒绝;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的"当前使用"高亮或版本卡变化。 6. 成功后重读 manifest 并**不切换版本**(没有新版本可切);**不**自动重载或重启运行中的预览(§3.2 末条),也不做运行时资源重映射。可见变化只有:资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。 7. 替换成功后追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`);**替换历史不可回溯**——替换前身份只有这条审计与 manifest 的 `.previous` 副本,不得声称能查到"某版本历史上换过什么"。 -8. 候选弹窗提供「点选替换」(2026-09-13):点它关闭弹窗进入画布点选态,并把「在画布上点选要替换成的素材;点击空白处不会退出」与「取消」放在画布提示条上;点中合法候选按第 3~7 条**同一条**链路提交并自动退出点选态,点中非法目标(分类 / 类型不同、不在权威候选里、源素材本身、未登记资源)零写入、留在点选态并在提示条上说明原因。退出只有 Esc(`document` 阶段截断,不清画布选中)/ 提示条「取消」/ 替换成功三条路,空白点击不退出;点选态下滚轮平移与缩放照常,卡片单击的既有语义在非点选态下不变。 +8. 替换面板在画布上点选目标(2026-09-21;取代 2026-09-13 的「点选替换」):候选面板是非模态浮层,面板开着时画布保持可点,点画布上的候选即落进面板的当前选择(搜索与分类筛选不被重置),写入仍由面板「确认」按第 3~7 条**同一条**链路发起;点中非法目标(分类 / 类型不同、不在权威候选里、源素材本身、未登记资源)零写入并在面板里说明原因。退出只有面板「取消」/ × 与 Esc(`document` 阶段截断,不清画布选中)两条路,成功后自动关闭;面板开着时滚轮平移、缩放与空白框选照常,卡片单击的既有语义在面板关闭后不变。 9. 替换血缘标注(2026-09-13):替换成功后,源素材卡带「已被 <替换素材显示名> 替换」、替换素材卡带「替换自 <源素材显示名>」,稳定 DOM 判据 `data-resource-replaced-by` / `data-resource-replacement-of` 的值为对面资源的 manifest 资产 id;未参与替换的资源卡两个属性都不出现。同一会话内再次替换时**只保留当前有效的一条 A→B**(旧标注从卡上消失,不做历史链)。这份标注**只在本次会话有效**:切换项目 / 关闭工作台 / 重新加载 manifest 都不保留,也不得声称可回溯(第 7 条仍成立)。 ### 7.9 P1 功能画布底部工具栏验收 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 61fd24859..41d1e063f 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 替换面板改为非模态浮层,支持在画布上点选目标 + +- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「在替换面板中支持画布点选目标」。2026-09-13 那版做的是「候选弹窗 footer 一个『点选替换』按钮 → 关掉弹窗 → 进画布点选态 → 点中即提交」:面板与画布互斥(弹窗外壳是全屏遮罩,留着它画布上的卡点不到),用户要么在面板里筛、要么离开面板。 +- 决策:候选面板改成**非模态浮层**(共享组件新增 opt-in `nonModal`:不铺遮罩、不做焦点陷阱、面板自身限高 + 内部滚动、Esc 在 `document` 阶段截断后取消;网页端美术画布不传,弹窗行为逐字不变)。AGC 把它锚在画布**右上角、任务开关下方**(`.game-resource-replacement-panel` → `top: 8.5rem; right: 0.85rem`,壳样式在共享样式表里,宿主只负责锚定)。**面板开着时画布照常可点**:点中合法候选即落成面板里的当前选择(面板里随之 `aria-selected`),写入仍然只由面板「确认」发起;点中非法目标在面板里说明原因、零写入。旧的「点选替换」入口、画布提示条 `.game-resource-canvas-pick-hint` 与 `resourceReplacementPickMode` 随之退役(同一个功能不留两条 UI 路径)。 +- 决策(同步口径):`selectedAssetIds` 的**数组引用不能当同步信号**(调用方每次渲染都会重建它,放进依赖会清掉用户在面板里的选择——组件里原本就为此写过一段注释),所以新增显式序号 `initialSelectionRevision`:只有宿主真的换了目标才重同步选择,搜索词与分类筛选保持原样。`confirmResourceVersionReplacement` 另外拿 `resourceReplacementPick` 兜底:点完画布当帧就按确认时不该报「请选择一个替换素材」。 +- 原因:面板与画布是同一屏的两半——目标本来就在画布上,「先把面板关掉再点」是弹窗外壳带来的妥协,不是产品意图。换成非模态之后,合法性判据仍是同一条 `resolveResourceReplacementPick`、写入仍是同一个 `confirm` 函数(载荷逐字一致),所以这是**外壳**的改动,不是第二套替换实现。 +- 已知取舍(相对旧口径的行为变化,均已随测试钉住):面板开着时空白处点击不再被吞——画布恢复正常的清焦点 / 框选 / 平移语义(源身份在打开面板时就已冻结,替换不依赖画布选中);「点中即提交」不再存在,提交一律由「确认」触发;关闭面板后卡片单击语义原样(一次性抑制照旧收尾)。 +- 验证:`resourceVersionReplacement.test.tsx` 19 passed(4 条旧点选用例改写为:非模态判据 + 点画布候选落进面板且零写入、面板确认才写入且载荷逐字一致+血缘、四类非法目标面板内报因零写入、Esc 只收面板不清选中;新增「画布点选不清掉面板搜索/分类」)、`projectAssetPickerDialogShellStyle.test.tsx` 7 passed(新增 nonModal 形态与浮层壳样式声明,含关掉即卸载)、`src/components/image-editor/ImageCanvasEditorView.test.tsx` 64 passed、`ImageCanvasEditorGenerationIntegration.test.tsx` 44 passed(网页端弹窗行为未变);连同 `appSurface.test.ts`、`projectResourceLiveIntegration.test.tsx` 共 6 个文件 694 passed / 17 skipped / 0 failed;`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。 +- 影响范围:`src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx`、`packages/shared/src/components/styles.css`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`.../features/resource-canvas/resourceCanvasChrome.css`、`apps/ai-game-creator-shell/tests/{resourceVersionReplacement.test.tsx,projectAssetPickerDialogShellStyle.test.tsx}`、PRD §5.3 / §7.8 第 8 条、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md` 的 S15a。未动 Rust、SpacetimeDB、external v1。 +- 已知未覆盖:真机观感(面板在画布右上角的落点、与任务开关同时打开时的间距)未在 Tauri 目视确认;面板里不显示「本次替换的是哪张源素材」,源身份只靠画布上那张卡的选中光环表达——点空白清掉选中后就只剩面板自己的候选列表。 ## 2026-09-21 AGC 资源卡拖到对话实现批量 @ 引用 - 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「聊天拖拽批量引用」。改前只有两条入口:聊天输入框里输入 `@` 或点 `@` 按钮开素材选择面板,以及资源卡选中工具条上那枚「引用」按钮——都要先把素材找出来再点,多选批量引用没有一次成型的路径。 diff --git a/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md b/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md index 9ec83f1d3..95c05b0fa 100644 --- a/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md +++ b/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md @@ -55,21 +55,29 @@ - 范围核对:48 个改动文件全部落在 AGC 前端、3 个共享画布组件与 3 份文档;`server-rs` / `spacetime` / `docs/openapi` / `src-tauri` **零改动**(后端与契约无红线涉及),无 `.env`、密钥、构建产物或日志进入改动。 - 合并后仍**未复验**的只有两类,且都不阻塞合并:① 真实客户端观感(本批动了动画面板样式、生图浮层紧凑排布与卡片基类 token——声明级与结构级都有守卫,真机要客户端验收确认);② 远程 CI(runner/镜像问题仍在处理,见 Issue #431;按用户要求本批不追)。 -### 2026-09-21 第四批(「生成任务」侧栏移到画布右上角,待客户端验收) +### 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)只有声明级断言。 +- **本批第二项(拖到对话批量引用)**:资源卡按住拖到右侧 Agent 对话栏、松手即按这次拖动真正参与位移的那批(多选整批、单选只引用按住的那张)插入 `@` 引用;落点铺虚线框 + 「松手即可 @ 引用 N 项素材」,拖回画布内松手仍是排版语义;引用口径与工具条「引用」按钮逐字一致(只认已登记素材、`source: resource-card`),派发走新增的批量事件一次事务插入。 +- **本批第三项(替换面板画布点选)**:替换候选面板从全屏弹窗改为**画布右上角的非模态浮层**(共享组件新增 opt-in `nonModal`,网页端弹窗行为不变),面板开着时画布照常可点——点中合法候选即落成面板里的当前选择,写入仍只由「确认」发起;非法目标在面板里报因、零写入。旧的「点选替换」按钮、画布提示条与点选态随之退役(同一个功能不留两条 UI 路径)。 +- 本机执行过(三项合计):`resourceCanvasAssetGenerationTasksPanel` / `…SidebarStyle` / `…SidebarDismiss` / `…BackgroundClose`、`resourceCardReferenceDropModel`、`resourceCanvasChatReferenceDrop`、`resourceReferenceInput`、`resourceVersionReplacement`、`projectAssetPickerDialogShellStyle`、`ImageCanvasEditorView`、`ImageCanvasEditorGenerationIntegration`、`projectResourceLiveIntegration`、`appSurface` 全绿(定向批次多项合计 694 passed / 17 skipped);`typecheck`、`check:encoding`、`git diff --check`、prettier / eslint 通过。 +- 仍未做:真实客户端目视(三项的真机手感与落点)与远程 CI 都没跑;窄屏(≤480px)只有声明级断言;「复制对话保留有效引用」未做。 + ## 后续需求 - 资源卡顶部独立名称区与全类型统一样式,确认 assetName/用户素材名的正式来源。 - ~~生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合。~~ **2026-09-21 已处理**:侧栏改挂画布右上角锚点(开关常驻、面板在开关左侧展开,形态照抄美术画布,样式仍走 AGC 平台 token),工具条上的重复入口删除;`placement` 分档让运行表现层让开右上角版本入口。见下「2026-09-21 第四批」。 -- 素材替换关系与画布点选替换;聊天拖拽批量引用、复制对话保留有效引用。 +- ~~素材替换关系与画布点选替换;聊天拖拽批量引用~~ **2026-09-21 已处理**:替换面板改为画布右上角的非模态浮层、可直接在画布上点选目标素材;资源卡可拖到对话栏批量 @ 引用。见下「2026-09-21 第四批」。「复制对话保留有效引用」仍未做(同一条需求里的另一半)。 ## 边界与验收 diff --git a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md index c6f826c48..1014fa076 100644 --- a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md +++ b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md @@ -55,7 +55,7 @@ | **S13** 重命名 | 工具条「重命名」→ 新文件名 → 确认 | 磁盘文件名与 manifest `localPath` 更新;**asset id 不变**;写盘失败回滚文件 | L362(资源身份稳定)、L515 | 弹窗 `ariaLabel="重命名素材"`;改名后「选中资源:…」按钮的无障碍名同步变化;引用 chip 与 @ 候选列表显示名刷新 | ⚠️ **不改游戏源码里对旧 `assets/` 的引用**,后果由用户自负 | | **S14** 下载 | 选中可下载卡 → 工具条「下载」 | 弹出**原生保存对话框**选目标路径 → 分块复制;取消即停止 | L507、飞书 §11.1.2 | 出现 `title="保存素材"` 的原生对话框;成功后状态提示含 `已保存 <路径>` | 语义是"另存为",不是静默直下;虚拟版本条目没有文件、不放行 | | **S15** 删除 | 工具条 / 标签面板「删除」→ 确认弹窗 | 三分支:① 无引用→直接删;② 被引用未勾选→只删素材、版本保留**悬空绑定**、界面不合成幽灵资源卡;③ 勾选→素材与该批版本在**同一次 manifest 写入**内一起删 | L413(版本只追加的唯一例外)、L532–533;#309 C5 | 弹窗 `ariaLabel="确认删除资源"`;被引用时出现 `被 N 个游戏版本使用` + 版本列表 + checkbox `把相关游戏版本一并删除`;无引用时该 body 整块不渲染 | **只摘登记、不删磁盘文件**;读引用命令精确名是 `read_local_project_asset_references` | -| **S15a** 替换素材(含点选替换) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 候选弹窗(弹窗内可筛可选);再点 footer「点选替换」→ 弹窗关闭、进入画布点选态 → 在画布上直接点目标素材 | 弹窗确认与点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;点选态下合法目标直接提交并自动退出点选态,非法目标零写入、**留在**点选态并在提示条上说明原因 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 点选态判据:候选弹窗 `role="dialog"` 已卸载;画布出现 `.game-resource-canvas-pick-hint`,文案含「在画布上点选要替换成的素材」与「点击空白处不会退出」,并有「取消」按钮;非法目标后提示条仍在且出现 `role="alert"`(「替换素材不兼容:分类不同」/「替换素材与源素材相同」/「替换素材未登记或已被删除」);点选期间卡片 `aria-pressed` 不变(单击只用于点选)、源素材选中与工具条不因点空白或点非法目标而丢;Esc 退出点选(画布全局 Esc 的清选中不随之触发);点选态下滚轮平移与缩放照常可用 | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(点选会话跨栏目存活,不因「收起资源」或切栏目结束);空白点击不退出、也不清画布选中 | +| **S15a** 替换素材(面板内选 + 画布点选目标) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 画布右上角出现非模态候选面板(面板内可筛可选)→ 直接在画布上点目标素材(或点面板里的候选)→ 面板「确认」 | 面板确认与画布点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;画布点选只把目标落成面板的当前选择,确认后才写入 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 面板判据:`role="dialog"` 名为「选择替换素材」、挂 `.image-canvas-editor__project-asset-picker--floating`、**没有** `.platform-overlay` 遮罩;点画布候选后面板里该候选 `aria-selected="true"`、面板里的搜索词与分类筛选保持原样、零写入;非法目标(源素材本身 / 不在权威候选里 / 分类不同 / 未登记资源)在面板里出现 `role="alert"`(「替换素材与源素材相同」/「替换素材未登记或已被删除」/「替换素材不兼容:分类不同」)且零写入;面板关闭 = 卸载;Esc 只收面板(画布全局 Esc 的清选中不随之触发) | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(替换会话跨栏目存活,不因「收起资源」或切栏目结束);面板开着时空白处仍可框选 / 平移,卡片单击语义的恢复发生在面板关闭之后 | | **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset`;生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar=""]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 | @@ -271,5 +271,5 @@ api-server 是否本次重启:□ 是 □ 否 ## 8. 验收边界 -- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生(含提示词内 @ 引用)、画布生成入口(浮层视频 + 栏目工具栏图片类与音频)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览 / 弹窗与画布点选两条入口同一条写入链路)**、聊天 @ 引用(两页签 + 功能分类 + 多标签筛选 + 搜索)与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。 +- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生(含提示词内 @ 引用)、画布生成入口(浮层视频 + 栏目工具栏图片类与音频)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览 / 非模态面板 + 画布点选目标同一条写入链路)**、聊天 @ 引用(两页签 + 功能分类 + 多标签筛选 + 搜索)与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。 - 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。资源替换的后端矩阵(版本绑定改写放行的六条不变式与两组互斥、绑定改写两条路径、硬门禁与提示、CAS、四条拒绝路径、读时自愈口径、审计留痕)见 `apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs` 与 `apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs`,前端矩阵见 `apps/ai-game-creator-shell/tests/resourceVersionReplacement*.test.ts(x)`。 diff --git a/packages/shared/src/components/styles.css b/packages/shared/src/components/styles.css index aa9301eec..5b4b208a5 100644 --- a/packages/shared/src/components/styles.css +++ b/packages/shared/src/components/styles.css @@ -1006,6 +1006,62 @@ textarea.genarrative-ui-text-field__control { background: var(--platform-overlay-fill); } +/* + * 参考图 / 替换素材选择组件的**非模态浮层**形态(`nonModal`)。 + * + * 与上面的 `platform-modal-shell` 同一处理由:一个组件两种外壳,壳样式放在这里,凡是引入本文件 + * 的宿主一起命中;写进任一宿主的业务样式就会变成平行拷贝。落差只有外壳——底色 / 边框 / 阴影 / + * 头部 / 滚动区 + 一列动作条,**不铺遮罩、不抢焦点**,宿主画布保持可点(AGC 的「替换素材」用它 + * 在画布上点选目标素材)。锚定位置由宿主给(AGC 是画布右上角),这里不写 `position`。 + * + * 依赖 `theme.css` 的 token,由最近的主题祖先提供(AGC 的锚点容器自带 + * `.platform-theme--light`)。 + */ +.image-canvas-editor__project-asset-picker--floating { + display: flex; + width: min(22rem, 34vw); + max-height: min(30rem, 70vh); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--platform-modal-border); + border-radius: 0.75rem; + background: var(--platform-modal-fill); + box-shadow: var(--platform-panel-shadow); + color: var(--platform-text-strong); + backdrop-filter: blur(10px); +} + +.image-canvas-editor__project-asset-picker-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-bottom: 1px solid var(--platform-line-soft); + padding: 0.68rem 0.78rem; +} + +.image-canvas-editor__project-asset-picker-header strong { + font-size: 0.82rem; + font-weight: 850; +} + +.image-canvas-editor__project-asset-picker-scroll { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.75rem 0.8rem; + scrollbar-width: thin; +} + +.image-canvas-editor__project-asset-picker-actions { + display: flex; + align-items: center; + gap: 0.45rem; + border-top: 1px solid var(--platform-line-soft); + padding: 0.6rem 0.7rem; +} + .genarrative-ui-empty-state { display: grid; min-height: 10rem; diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx index e7bdca356..4320bd6c8 100644 --- a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx @@ -1,9 +1,16 @@ import { Check, ImageIcon, Music, Search, Video } from 'lucide-react'; -import { type ReactNode, useLayoutEffect, useMemo, useState } from 'react'; +import { + type ReactNode, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from 'react'; import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton'; import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar'; import { PlatformStatusMessage } from '../../../packages/shared/src/components/PlatformStatusMessage'; +import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { UnifiedModal } from '../common/UnifiedModal'; import type { EditorAsset } from './ImageCanvasEditorTypes'; import { @@ -59,13 +66,24 @@ type ImageCanvasProjectAssetPickerDialogProps = { */ errorMessage?: string | null; /** - * 「点选替换」:由宿主关闭本弹窗,改在画布上直接点选目标素材。 + * 非模态浮层:不铺全屏遮罩、不做焦点陷阱,宿主画布保持可点。 * - * 默认 `undefined` → **不渲染该入口**,弹窗行为与现在逐字不变(网页端美术画布的参考图 - * 选择不传它,多选与确认流程原样保留)。传了才多出一个按钮,点它不做选择、不回传 id —— - * 弹窗只负责把"用户要走点选"这件事告诉宿主,进入与退出点选态都由宿主自己的状态机管。 + * AGC 的「替换素材」用它:面板开着的时候直接在资源画布上点目标素材,点中的候选落进面板的 + * 当前选择,写入仍然只由面板的「确认」发起。默认 `false` → 网页端美术画布的弹窗行为逐字不变。 + * + * 打开期间 Esc 仍等于「取消」(document 阶段截断,宿主画布的全局 Esc 不随之触发); + * 点外部不关闭——非模态面板与画布是同一屏的两半,点画布是要选目标,不是要关面板。 */ - onPickFromCanvas?: () => void; + nonModal?: boolean; + /** + * 「初值换了」的信号:宿主在面板**开着**的时候又给了新的 `selectedAssetIds`(AGC 里是在 + * 画布上点选目标素材),序号一变就按新初值重同步当前选择。 + * + * 不能用 `selectedAssetIds` 的引用当信号:调用方每次渲染都会重建那个数组,把它放进依赖会 + * 让「父级任何一次重渲染」都清掉用户的选择(组件里原本就是这么写的)。所以同步点交给这个 + * 显式序号:只有宿主真的换了目标才变化。缺省 `0`(网页端美术画布不传,行为逐字不变)。 + */ + initialSelectionRevision?: number; }; function assetIcon(category: ProjectAssetPickerCategory) { @@ -121,7 +139,8 @@ export function ImageCanvasProjectAssetPickerDialog({ renderAssetMedia, selectionNoun = '参考图', errorMessage, - onPickFromCanvas, + nonModal = false, + initialSelectionRevision = 0, }: ImageCanvasProjectAssetPickerDialogProps) { const [query, setQuery] = useState(''); const [category, setCategory] = useState('all'); @@ -143,6 +162,18 @@ export function ImageCanvasProjectAssetPickerDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps -- 只在打开的那一帧重置 }, [open]); + /** + * 面板开着时宿主换了初值(AGC 在画布上点选目标素材):只同步这一项,别的不动。 + * + * 搜索词与分类保持原样——用户在面板里筛到一半、又去画布上点一张,回来不该被重置成「全部」。 + * 选择直接落成新初值(单选场景就是那一项)。 + */ + useLayoutEffect(() => { + if (!open) return; + setSelection([...selectedAssetIds]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- 只认显式序号,不认数组引用 + }, [initialSelectionRevision, open]); + const visibleAssets = useMemo( () => assets.filter((asset) => @@ -171,59 +202,63 @@ export function ImageCanvasProjectAssetPickerDialog({ }); } - return ( - - - 已选 {selection.length} 个 - - setSelection([])} - > - 清空 - - {onPickFromCanvas ? ( - - 点选替换 - - ) : null} - - 取消 - - onConfirm(selection)} - > - 确认 - - + /** + * 非模态下的 Esc = 取消。 + * + * 挂在 **document** 并 `stopPropagation`:宿主画布的全局 Esc 挂在 window 上(清画布焦点 = + * 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到「Esc 只收替换面板、 + * 不连带清画布选中」(与「浮层打开时 Escape 归浮层所有」同一口径)。 + */ + useEffect(() => { + if (!open || !nonModal) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; } - > + event.stopPropagation(); + onCancel(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [nonModal, onCancel, open]); + + const dialogLabel = `选择${selectionNoun}`; + const pickerFooter = ( + <> + + 已选 {selection.length} 个 + + setSelection([])} + > + 清空 + + + 取消 + + onConfirm(selection)} + > + 确认 + + + ); + const pickerBody = ( + <> {errorMessage ? ( ) : null} + + ); + + /** + * 非模态浮层:不铺遮罩、不抢焦点,宿主画布保持可点(AGC 的「替换素材」用它——面板开着时 + * 直接在画布上点目标素材,点中的候选落进这里的当前选择,写入仍然只由「确认」发起)。 + */ + if (nonModal) { + // 关掉就是卸载(模态那条路由 `UnifiedModal` 自己按 `open` 返回空):与弹窗同一判据, + // 否则宿主把 `open` 置回 false 之后画布右上角还留着半块面板。 + if (!open) { + return null; + } + return ( +
+
+ {dialogLabel} + +
+
+ {pickerBody} +
+
+ {pickerFooter} +
+
+ ); + } + + return ( + + {pickerBody} ); }