From f5886560bd839fa8eca1853f834076ee61828472 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 3 Oct 2026 19:45:45 +0800 Subject: [PATCH 01/53] =?UTF-8?q?AGC=20=E7=94=A8=E6=88=B7=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E5=8C=BA=E6=8C=89=E4=B8=89=E5=B1=82=E9=87=8D=E6=8E=92?= =?UTF-8?q?=E5=B8=83=E5=B1=80=EF=BC=8C=E4=BF=AE=E6=8E=89=E6=8E=A7=E4=BB=B6?= =?UTF-8?q?=E5=8F=A0=E5=8E=8B=EF=BC=88#600=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 输入盒 `.project-chat-composer.is-direct-codex` 改单列网格 + `gap: 8px`,把输入内容区 / 工具栏 / 状态提示区三层用行间距分开,不再靠绝对定位互相压 - 「AI 润色 / 恢复原文」操作排从 `position: absolute; right: 0; bottom: 0` 回到输入区第二行网格(编辑器横跨全宽),不再压在编辑器文字上 - 模型控件的错误 / 状态提示改成控件排内独占一行(`position: static`),不再浮在输入内容区上方盖住文字 - 工具栏右侧组由 `flex: 0 0 auto` 改为可收缩 + 外层可换行;模型钮封顶 160px、名字走省略号,推理档不参与收缩 - 状态提示区(待发队列 chip、提示段落)与输入盒、工具栏共用同一档行间距(去掉重复的 margin / padding-top) - 同步两处 CSS 契约断言:composer 几何(`tests/appSurface/project-development.suite.ts`)与输入盒最高高度算式 + 操作排网格行(`tests/chatDialogFrameLayout.test.ts`) - pitfalls 记录本次叠压的实测尺寸、成因与现行口径 --- apps/ai-game-creator-shell/src/styles.css | 234 ++++++++++++++++-- .../appSurface/project-development.suite.ts | 6 +- .../tests/chatDialogFrameLayout.test.ts | 62 +++-- docs/project-memory/shared-memory/pitfalls.md | 8 + 4 files changed, 275 insertions(+), 35 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 74d8e5e41..74202d063 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11230,8 +11230,9 @@ button.design-workspace-tree__entry:hover, .project-chat-composer.is-direct-codex { display: grid; grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto auto; - gap: 0; + /* 三层(输入内容区 / 工具栏 / 状态提示区)之间靠行间距分开,任何一层都不再用 + 绝对定位去压另一层;`gap` 同时给队列 chip 与输入盒之间留出同一档间距。 */ + gap: 8px; align-items: stretch; position: relative; right: 0; @@ -11301,11 +11302,11 @@ button.design-workspace-tree__entry:hover, .project-chat-composer.is-direct-codex .project-chat-composer-controls { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 8px; + gap: 6px 8px; min-width: 0; - padding-top: 2px; pointer-events: auto; } @@ -11323,16 +11324,31 @@ button.design-workspace-tree__entry:hover, .project-chat-composer-controls-right { display: flex; align-items: center; - gap: 8px; + gap: 6px 8px; min-width: 0; } +/* 左侧动作组(`@` 引用 / `+` 附件)允许换行:窄面板下它和右侧控件组各占一行。 */ +.game-workbench-layout--design .project-chat-composer-controls-left, +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls-left { + flex-wrap: wrap; +} + +/* 右侧那一组是**不可再压的控件**(推理档 / 模型 / 语音 / 发送):它原来写死 + `flex: 0 0 auto`,宽度不够时既不收缩也不换行,整组向右溢出输入盒、和左侧的 + `@` `+` 叠在一起(窄面板下 `推理档` 直接压在 `添加文件` 上)。改成可收缩的一行: + 组内按 `flex-shrink` 让模型钮先让位(模型名自带省略号),一组控件永远保持单行、 + 整组在容器放不下时由外层换到第二行右对齐。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-controls-right { - flex: 0 0 auto; + flex: 1 1 auto; margin-left: auto; + justify-content: flex-end; } /* 左侧引用按钮保留输入盒的内边距,点击区域不向外偏移。 */ @@ -12598,16 +12614,50 @@ button.design-workspace-tree__entry:hover, `.resource-reference-input` / `.resource-reference-input-actions` 规则决定, 同特异性下只有靠后写的才能盖回去。 - 输入区是两列网格(文本列 + 操作列),右侧那枚「AI 润色」按钮常年占掉 36px, - 于是文字右边缘比盒内右边缘少 36px——左右留白与上下留白看着就不一致。 - 这里把输入区改成单块并把操作按钮浮到右下角,文字可用满整宽。 */ + 输入区自己就是「文字 + 区内操作」的两行网格: + 第一行是编辑器(横跨整宽,文字用满盒内宽度); + 第二行左边是润色状态(`.resource-reference-input-status`)、右边是 + 「AI 润色 / 恢复原文」操作排(`.resource-reference-input-actions`)。 + 操作排**回到网格行里**,不再绝对定位浮到文本区右下角:它原来 `right: 0; + bottom: 0` 地压在编辑器最后一行文字上(实测按钮 28×24px 与编辑器相交;文字长到 + 贴近右下角时被盖住),并且和下方工具栏里的发送钮同一条竖线,看起来像"叠了两个按钮"。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .resource-reference-input { - display: block; - position: relative; - min-height: 96px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto; + align-content: start; + gap: 6px 8px; + min-height: 0; + padding: 0; +} + +/* 编辑器横跨两列:文字不再被右侧 28px 的操作列长期挤窄。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .resource-reference-input + .resource-reference-input-editor { + grid-row: 1; + grid-column: 1 / -1; + min-height: 72px; + max-height: 160px; +} + +/* 润色状态与操作排同一行、分列:状态出现/消失都不改变输入盒高度,两者也不会互相压。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .resource-reference-input + .resource-reference-input-status { + grid-row: 2; + grid-column: 1; + align-self: center; + min-width: 0; + margin-top: 0; + overflow-wrap: anywhere; } .game-workbench-chat @@ -12615,12 +12665,13 @@ button.design-workspace-tree__entry:hover, .project-chat-composer.is-direct-codex .resource-reference-input .resource-reference-input-actions { - position: absolute; - right: 0; - bottom: 0; + grid-row: 2; + grid-column: 2; + position: static; display: flex; - align-items: flex-end; + align-items: center; justify-content: flex-end; + min-width: 0; } /* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。 @@ -13046,3 +13097,154 @@ details.design-agent-reasoning[open] text-align: center; white-space: pre-wrap; } + +/* ============================================================ + 输入区工具栏与状态提示(2026-10 · issue #600) + ============================================================ + 三组规则,输入区三层布局的收尾: + ① 工具栏里的下拉控件(推理档 / 模型)要能收缩、模型名要能省略,宽度不够时靠换行 + 让位,而不是把整组推到输入盒外面去和左边的 `@` `+` 重叠; + ② 模型控件自己的状态提示(`ConversationModelSelect` 的 `[role='alert']` / + `[role='status']`)从"浮在输入区上方"改成"控件排内独占一行":它原来的 + `bottom: 36px` 正好把错误文案画在输入内容区第 3~4 行文字上(实测 70×36px + 与编辑器相交); + ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距。 + + 这一组必须放在文件最后:`.conversation-model-select` 的定位与宽度由前面几条同 + 选择器(或更高权重)的规则决定,同特异性下只有靠后写的才能盖回去。 */ + +/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号,不再把触发钮撑到 180px。 */ +.game-workbench-layout--design + .project-chat-composer-controls + .conversation-model-select, +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .conversation-model-select { + flex-wrap: wrap; + justify-content: flex-end; +} + +.game-workbench-layout--design + .project-chat-composer-controls + .conversation-model-trigger-label, +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .conversation-model-trigger-label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* 推理档的读写失败提示同样回到控件排内,整行右对齐。 */ +.game-workbench-layout--design + .project-chat-composer-controls + .project-chat-reasoning-effort, +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .project-chat-reasoning-effort { + /* 档位文案很短(默认 / 高 / 中 / 低),不参与收缩——收缩会把它截成半个字。 + 要让位时由模型钮(名字走省略号)承担。 */ + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.game-workbench-layout--design + .project-chat-composer-controls + .project-chat-reasoning-effort + .project-chat-composer-notice, +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .project-chat-reasoning-effort + .project-chat-composer-notice { + flex: 1 1 100%; + margin: 0; + text-align: right; +} + +/* ② 模型控件的错误 / 状态提示:从绝对定位浮层改为占一行,跟随控件换行。 */ +.game-workbench-layout--design + .project-chat-composer-controls + .conversation-model-select + [role='alert'], +.game-workbench-layout--design + .project-chat-composer-controls + .conversation-model-select + [role='status'], +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .conversation-model-select + [role='alert'], +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .conversation-model-select + [role='status'] { + position: static; + order: 1; + flex: 1 1 100%; + min-width: 0; + max-width: none; + margin-top: 2px; + padding: 0; + background: transparent; + font-size: 12px; + line-height: 1.4; + text-align: right; + overflow-wrap: anywhere; +} + +/* 模型控件的宽度上限与触发钮同档(160px):模型名、错误提示都在这一档里用省略号 / + 折行收住,这样「@ + · 推理档 · 模型 · 语音 · 发送」在 400px 以上的面板里始终是一行, + 更窄的面板由外层换行让位(左侧 `@` `+` 与右侧控件组各占一行)。 */ +.game-workbench-layout--design + .project-chat-composer-controls + .conversation-model-select, +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .conversation-model-select { + max-width: 160px; +} + +.game-workbench-layout--design + .project-chat-composer-controls + .conversation-model-trigger, +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .conversation-model-trigger { + max-width: 160px; + /* 下限只是「别缩到看不见」的兜底:最窄的 280px 面板下模型钮仍有 ~74px。 */ + min-width: 64px; +} + +/* ③ 状态提示区:队列 chip 与提示段落跟输入盒、工具栏共用同一档行间距。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-queue { + margin: 0; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + > .project-chat-composer-notice { + margin: 0; + max-height: 72px; + overflow: auto; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index b66ab4c34..aa9dc20c1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5880,14 +5880,14 @@ export function registerProjectWorkbenchFoundationTests() { expect(styles).toMatch( /\.game-workbench-chat \.project-runtime-summary\s*\{[^}]*position:\s*sticky[^}]*top:\s*-10px/s, ); - // 输入盒是两行网格(文本区 / 控制排)的文档流块,不再是贴在列表下边的 - // 绝对定位浮层,也不再与 82px 的发送钮列共用网格。 + // 输入盒是单列网格的文档流块(三层:输入内容区 / 工具栏 / 状态提示区),不再是贴在 + // 列表下边的绝对定位浮层,也不再与 82px 的发送钮列共用网格;层与层之间用行间距分开。 const composerRule = styles.match( /\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\.is-direct-codex\s*\{([^}]*)\}/s, ); expect(composerRule?.[1]).not.toBeUndefined(); expect(composerRule?.[1]).toContain('position: relative;'); - expect(composerRule?.[1]).toContain('grid-template-rows: auto auto;'); + expect(composerRule?.[1]).toContain('gap: 8px;'); expect(composerRule?.[1]).toContain('z-index: 1;'); // 四边留白统一 16px、盒内内边距四边同为 12px(文字左内缩必须等于上内缩); // 不允许再出现 `8px 12px 10px` / `10px 12px` 这类「左右一个值、上下另一个值」的写法。 diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index bcb9b3135..17e632124 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -118,6 +118,22 @@ function marginBox(declarations: Map) { return { top: value(0), right: value(1), bottom: value(2), left: value(3) }; } +/** + * 展开 `gap` 简写,只关心主/交叉两轴。 + * + * 一值写四个方向同一个数;两值先 `row-gap` 后 `column-gap`(与 CSS 的 `gap: ` + * 一致)。三层输入区里两条行间距分别写在输入盒与输入盒自己身上,读值时必须分得清是哪一条。 + */ +function gapBox(declarations: Map) { + const shorthand = declarations.get('gap') ?? '0'; + const parts = shorthand.split(' ').filter(Boolean); + const value = (index: number) => { + const part = parts[index] ?? parts[parts.length - 1] ?? parts[0]; + return lengthPx(part!, `gap 简写 ${shorthand}`); + }; + return { row: value(0), column: value(1) }; +} + /* ============================================================ 选择器身份 ============================================================ */ @@ -132,10 +148,12 @@ const COMPOSER_PLAIN = `${CHAT} .project-chat-composer`; const CONVERSATION_COMPOSER = `${CONVERSATION} .project-chat-composer.is-direct-codex`; const COMPOSER_INPUT = `${COMPOSER} .resource-reference-input`; const COMPOSER_INPUT_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input`; -const COMPOSER_EDITOR = `${COMPOSER_PLAIN} .resource-reference-input-editor`; +const COMPOSER_EDITOR = `${COMPOSER_INPUT} .resource-reference-input-editor`; +const COMPOSER_EDITOR_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input-editor`; const COMPOSER_CONTROLS = `${COMPOSER} .project-chat-composer-controls`; const COMPOSER_SUBMIT = `${COMPOSER_CONTROLS} .project-chat-submit-button`; -const INPUT_ACTIONS = `${COMPOSER} .resource-reference-input-actions`; +const INPUT_ACTIONS = `${COMPOSER_INPUT} .resource-reference-input-actions`; +const INPUT_ACTIONS_PLAIN = `${COMPOSER} .resource-reference-input-actions`; const TOPBAR = `${CHAT} .project-chat-topbar`; /** 列表实际会命中的全部选择器:direct-codex 直连与基础规则。 */ const LIST_SELECTORS = [MESSAGE_LIST, MESSAGE_LIST_PLAIN]; @@ -169,12 +187,10 @@ function composerMaxHeight() { ); const editor = desktopDeclarations( COMPOSER_EDITOR, + COMPOSER_EDITOR_PLAIN, '.resource-reference-input-editor', ); - const actions = desktopDeclarations( - INPUT_ACTIONS, - '.resource-reference-input-actions', - ); + const actions = desktopDeclarations(INPUT_ACTIONS, INPUT_ACTIONS_PLAIN); const polishButton = desktopDeclarations( `${INPUT_ACTIONS} .resource-reference-input-polish`, '.resource-reference-input-polish', @@ -184,32 +200,45 @@ function composerMaxHeight() { const editorMaxHeight = pixelValue(editor, 'max-height'); const actionsRowHeight = pixelValue(polishButton, 'height'); - const inputRowGap = pixelValue(input, 'gap'); + // 输入盒与输入盒外各有一条行间距:前者是「文字区 / 操作排」之间,后者是 + // 「输入内容区 / 工具栏」之间(三层布局,见 styles.css 文件末尾的输入区区块)。 + const inputRowGap = gapBox(input).row; + const composerRowGap = gapBox(composer).row; const inputPaddingBottom = paddingBox(input).bottom; const composerPaddingTop = paddingBox(composer).top; const composerPaddingBottom = paddingBox(composer).bottom; - const controlsRowHeight = - pixelValue(controls, 'padding-top') + pixelValue(submit, 'height'); + // 工具栏自己的高度就是那颗 28px 方钮:与输入区之间的间距归输入盒的 row-gap, + // 工具栏不再自带 padding-top(否则就是同一段间距写两遍)。 + expect( + controls.has('padding-top'), + '工具栏与输入区之间的间距由输入盒的 row-gap 提供,不再用 padding-top', + ).toBe(false); + const controlsRowHeight = pixelValue(submit, 'height'); expect( pixelValue(editor, 'min-height'), '编辑器最小高度是输入盒高度的下界,改了要同步改这里的算式', - ).toBe(96); + ).toBe(72); // 操作排的高度就是那三只 28px 方钮自己撑起来的:它自己不设 height / min-height, // 一旦设了,网格行高会被顶起来、编辑器的 min-height 也跟着变,算式随之作废。 expect(actions.has('height')).toBe(false); expect(actions.has('min-height')).toBe(false); + // 操作排必须是网格里的第二行,不能再绝对定位浮到文字上(issue #600 的回归点)。 + expect(declaration(actions, 'position')).toBe('static'); + expect(declaration(actions, 'grid-row')).toBe('2'); + expect(declaration(actions, 'grid-column')).toBe('2'); return { actionsRowHeight, total: + composerPaddingTop + editorMaxHeight + inputRowGap + actionsRowHeight + inputPaddingBottom + - composerPaddingTop + - composerPaddingBottom + - controlsRowHeight, + composerRowGap + + controlsRowHeight + + composerPaddingBottom, }; } @@ -270,11 +299,12 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 const list = desktopDeclarations(...LIST_SELECTORS); const { total, actionsRowHeight } = composerMaxHeight(); - // 输入盒最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4 - // + 输入盒内边距 24(四边同为 12:文字左内缩必须等于上内缩)+ 操作条(2 + 28 方钮)= 234。 + // 输入盒最高高度由真实声明算出来(三层布局,自上而下): + // 盒内上内边距 12 + 编辑器 160 + 输入区行距 6 + 操作排 28 + 输入区下内边距 0 + // + 输入盒与工具栏的行距 8 + 工具栏 28 + 盒内下内边距 12 = 254。 expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28); expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28); - expect(total).toBe(234); + expect(total).toBe(254); const paddingBottom = paddingBox(list).bottom; // 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index beca4a1a9..076863623 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 AGC 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位 + +- **现象**:#600 截图里用户输入区域「元素堆叠」——空草稿时「AI 润色」按钮就压在输入内容区右下角(实测与编辑器相交 28×24px),模型控件的失败提示「本地模型服务不可用」直接画在第 3~4 行文字上(70×36px,长草稿时把整段文字盖住);面板越窄越糟:280px 面板下推理档整颗压在「添加文件」上(28×28px),「插入素材引用」与推理档也相交(20×28px)。 +- **原因**:两条「靠绝对定位把控件挪出流」的旧写法叠在一起:① `.resource-reference-input-actions` 被写成 `position: absolute; right: 0; bottom: 0`,浮到编辑器右下角(同一份样式早前其实是网格第二行,见文件里 11893 行那条注释,被后写的绝对定位规则盖掉);② `ConversationModelSelect` 的 `[role='alert'] / [role='status']` 是 `bottom: 36px` 的浮层,锚点就在输入盒正下方,提示必然落在文字上。另外 `.project-chat-composer-controls-right` 写死 `flex: 0 0 auto`,宽度不够时既不收缩也不换行,整组向右溢出输入盒(360px 面板下溢出 34px、280px 下 114px)。 +- **处理(现行口径)**:`.project-chat-composer.is-direct-codex` 是单列网格(`gap: 8px`),三层自上而下——输入内容区 / 工具栏 / 状态提示区;输入内容区自己是两行网格(编辑器横跨两列,第二行左放润色状态、右放「AI 润色 / 恢复原文」操作排),操作排 `position: static` 回到行里;模型提示改成控件排内独占一行(`position: static; flex: 1 1 100%`),不再浮在输入区上方;工具栏允许换行、右侧组改 `flex: 1 1 auto`,模型钮封顶 160px 且名字走省略号(收缩由它承担,推理档 `flex: 0 0 auto` 不参与收缩,避免把「默认」截成半个字)。 +- **判据/取证**:`apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts` 用层叠求值钉住三层几何与操作排 `grid-row: 2 / position: static`、输入盒最高高度算式(12+160+6+28+0+8+28+12 = 254);`tests/appSurface/project-development.suite.ts` 钉住 composer 的 `gap: 8px`。真实渲染对照见 #600 PR:同一夹具在 495/438/360/296/280px 面板 × 空 / 长多行 / 队列+提示 四态下,改前 2~4 处相交(含文字被提示盖住),改后 0 处相交、0 处溢出。 +- **关联**:`apps/ai-game-creator-shell/src/styles.css`(文件末尾「输入区工具栏与状态提示」区块)、`docs/【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md`。 + ## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 - **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 From ac2df9f123fab2d1bd459a9274444dceaf45d689 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:12:38 +0800 Subject: [PATCH 02/53] =?UTF-8?q?=E8=87=AA=E5=AE=A1=E6=94=B6=E5=8F=A3=20#6?= =?UTF-8?q?00=EF=BC=9A=E5=AE=BD=E5=BA=A6=E4=B8=8A=E9=99=90=E4=B8=8E?= =?UTF-8?q?=E9=97=B4=E8=B7=9D=E5=86=99=E5=9B=9E=E5=90=84=E8=87=AA=E8=A7=84?= =?UTF-8?q?=E5=88=99=EF=BC=8C=E5=8E=BB=E6=8E=89=E9=87=8D=E5=A4=8D=E5=A3=B0?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模型选择器 160px 上限、触发钮 160px 上限 / 64px 下限写回 `.conversation-model-select` 与 `.conversation-model-trigger` 的原有规则(单点声明,不再依赖文件末尾的源码顺序覆盖) - 队列 chip 的 `margin: 0` 写回 `.project-chat-composer-queue` 原有规则;提示段落 `margin: 0` 与 96px 高度上限并入 `.project-chat-composer-notice` 原有规则(覆盖两个宿主,删掉新造的 72px) - 推理档 `flex: 0 0 auto` 拆成 Direct 侧单条(策划面已有同值声明),末尾区块只留前文没有的新属性 - 更新末尾区块头注释:说明唯一的覆盖靠更长的选择器链而非源码顺序 --- apps/ai-game-creator-shell/src/styles.css | 99 +++++++++-------------- 1 file changed, 38 insertions(+), 61 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 74202d063..3d52b4139 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11177,7 +11177,9 @@ button.design-workspace-tree__entry:hover, .project-chat-composer .conversation-model-trigger { min-width: 0; - max-width: 180px; + /* 与下面控制排里那条同档(160px):模型名走省略号,宽度不够时由外层换行让位, + 不再把整组控件推出输入盒。 */ + max-width: 160px; height: 30px; } .project-chat-surface.is-direct-codex @@ -11381,7 +11383,9 @@ button.design-workspace-tree__entry:hover, position: relative !important; right: auto !important; bottom: auto !important; - max-width: 180px; + /* 上限与触发钮同档(160px):模型名、错误提示都在这一档里用省略号 / 折行收住, + 「@ + · 推理档 · 模型 · 语音 · 发送」在 400px 以上的面板里保持一行。 */ + max-width: 160px; } .game-workbench-chat @@ -11390,8 +11394,9 @@ button.design-workspace-tree__entry:hover, .project-chat-composer-controls .conversation-model-trigger { flex: 0 1 auto; - min-width: 0; - max-width: 180px; + /* 下限只是「别缩到看不见」的兜底:最窄的 280px 面板下模型钮仍有 ~74px。 */ + min-width: 64px; + max-width: 160px; color: var(--platform-text-base) !important; } @@ -11684,7 +11689,8 @@ button.design-workspace-tree__entry:hover, flex-wrap: wrap; align-items: center; gap: 6px; - margin: 0 0 6px; + /* 与输入盒之间的间距归 composer 的行间距(`gap: 8px`),这里不再自带 6px。 */ + margin: 0; padding: 0; list-style: none; } @@ -11739,7 +11745,13 @@ button.design-workspace-tree__entry:hover, font-variant-numeric: tabular-nums; } -.game-workbench-layout--design .project-chat-composer-notice { +.game-workbench-layout--design .project-chat-composer-notice, +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-notice { + /* 两个宿主的提示区同一档:超过就自己滚,不把输入盒顶高(直接子元素由下面的 + 共享规则清零 margin,间距交给各自 composer 的 gap)。 */ max-height: 96px; overflow: auto; overflow-wrap: anywhere; @@ -11750,7 +11762,7 @@ button.design-workspace-tree__entry:hover, .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-notice { - margin: 4px 0 0; + margin: 0; color: var(--platform-text-soft); font-size: 12px; } @@ -13101,19 +13113,20 @@ details.design-agent-reasoning[open] /* ============================================================ 输入区工具栏与状态提示(2026-10 · issue #600) ============================================================ - 三组规则,输入区三层布局的收尾: + 三组规则,输入区三层布局的收尾。这里只补前文没有的属性:模型钮的 160px 上限 / + 64px 下限、提示区 96px 上限、队列 chip 与提示段落的 `margin` 都写回前文各自那条 + 规则(`.conversation-model-select` / `.conversation-model-trigger` / + `.project-chat-composer-notice` / `.project-chat-composer-queue`),不在这里重复声明。 ① 工具栏里的下拉控件(推理档 / 模型)要能收缩、模型名要能省略,宽度不够时靠换行 让位,而不是把整组推到输入盒外面去和左边的 `@` `+` 重叠; ② 模型控件自己的状态提示(`ConversationModelSelect` 的 `[role='alert']` / `[role='status']`)从"浮在输入区上方"改成"控件排内独占一行":它原来的 `bottom: 36px` 正好把错误文案画在输入内容区第 3~4 行文字上(实测 70×36px - 与编辑器相交); - ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距。 + 与编辑器相交)——这条覆盖的是全局规则,靠更长的选择器链(多带 + `.project-chat-composer-controls`)取胜,不依赖源码顺序; + ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距。 */ - 这一组必须放在文件最后:`.conversation-model-select` 的定位与宽度由前面几条同 - 选择器(或更高权重)的规则决定,同特异性下只有靠后写的才能盖回去。 */ - -/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号,不再把触发钮撑到 180px。 */ +/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号(上限已写回前文那条规则)。 */ .game-workbench-layout--design .project-chat-composer-controls .conversation-model-select, @@ -13148,13 +13161,21 @@ details.design-agent-reasoning[open] .project-chat-composer.is-direct-codex .project-chat-composer-controls .project-chat-reasoning-effort { - /* 档位文案很短(默认 / 高 / 中 / 低),不参与收缩——收缩会把它截成半个字。 - 要让位时由模型钮(名字走省略号)承担。 */ - flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; } +/* 档位文案很短(默认 / 高 / 中 / 低),不参与收缩——收缩会把它截成半个字。 + 要让位时由模型钮(名字走省略号)承担。(策划面已经在 `.game-workbench-layout--design + .project-chat-reasoning-effort` 那条里写了 `flex: 0 0 auto`,这里只补 Direct 侧。) */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .project-chat-reasoning-effort { + flex: 0 0 auto; +} + .game-workbench-layout--design .project-chat-composer-controls .project-chat-reasoning-effort @@ -13204,47 +13225,3 @@ details.design-agent-reasoning[open] text-align: right; overflow-wrap: anywhere; } - -/* 模型控件的宽度上限与触发钮同档(160px):模型名、错误提示都在这一档里用省略号 / - 折行收住,这样「@ + · 推理档 · 模型 · 语音 · 发送」在 400px 以上的面板里始终是一行, - 更窄的面板由外层换行让位(左侧 `@` `+` 与右侧控件组各占一行)。 */ -.game-workbench-layout--design - .project-chat-composer-controls - .conversation-model-select, -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer.is-direct-codex - .project-chat-composer-controls - .conversation-model-select { - max-width: 160px; -} - -.game-workbench-layout--design - .project-chat-composer-controls - .conversation-model-trigger, -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer.is-direct-codex - .project-chat-composer-controls - .conversation-model-trigger { - max-width: 160px; - /* 下限只是「别缩到看不见」的兜底:最窄的 280px 面板下模型钮仍有 ~74px。 */ - min-width: 64px; -} - -/* ③ 状态提示区:队列 chip 与提示段落跟输入盒、工具栏共用同一档行间距。 */ -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer.is-direct-codex - .project-chat-composer-queue { - margin: 0; -} - -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer.is-direct-codex - > .project-chat-composer-notice { - margin: 0; - max-height: 72px; - overflow: auto; -} From ef10d9527b7208ca32490e9945c346dc35b48030 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:27:02 +0800 Subject: [PATCH 03/53] =?UTF-8?q?=E7=AD=96=E5=88=92=E9=9D=A2=E9=9B=B6?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E5=8F=98=E5=8C=96=EF=BC=9A#600=20=E7=9A=84?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=E6=94=B6=E5=8F=A3=E5=88=B0=20Direct=20?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 控件排几何按宿主拆成两条规则:策划面恢复改动前的 `gap: 8px` / 不换行 / `padding-top: 2px`,Direct 侧保留 `flex-wrap: wrap` + `gap: 6px 8px` - 控件排两侧分组同样拆开:策划面恢复 `gap: 8px`,Direct 侧保留 `gap: 6px 8px`;左侧动作组换行只作用于 Direct - 模型选择器上限:共享规则恢复 180px,Direct 侧新增一层真实祖先(`.project-chat-composer-controls-right`)把上限收到 160px,不依赖源码顺序 - 提示段落:策划面恢复 `margin: 4px 0 0`(改动前的值),Direct 侧新增 `margin: 0`(多带 `.project-chat-conversation` 祖先提高特异性);96px 上限与 `overflow-wrap: anywhere` 只新增选择器,不改策划面的值 - 文件末尾区块的选择器全部收窄为 Direct 链,并注明策划输入区不在本 issue 范围 - 校验:临时审计脚本用层叠求值逐元素比对策划输入区子树(1440/1024/390px),origin/master 零差异、对改判前提交 ac2df9f12 报 62 处差异 --- apps/ai-game-creator-shell/src/styles.css | 108 ++++++++++++---------- 1 file changed, 59 insertions(+), 49 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 3d52b4139..d08a30305 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11298,7 +11298,18 @@ button.design-workspace-tree__entry:hover, color: var(--platform-text-soft); } -.game-workbench-layout--design .project-chat-composer-controls, +/* 策划控件排:保持改动前的几何(不改策划面的间距与表现)。 */ +.game-workbench-layout--design .project-chat-composer-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding-top: 2px; + pointer-events: auto; +} + +/* Direct 控件排:三层布局下允许换行,两轴间距分开写(行 6 / 列 8)。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -11312,8 +11323,15 @@ button.design-workspace-tree__entry:hover, pointer-events: auto; } +/* 策划控件排两侧分组:同样保持改动前的几何。 */ .game-workbench-layout--design .project-chat-composer-controls-left, -.game-workbench-layout--design .project-chat-composer-controls-right, +.game-workbench-layout--design .project-chat-composer-controls-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -11330,8 +11348,7 @@ button.design-workspace-tree__entry:hover, min-width: 0; } -/* 左侧动作组(`@` 引用 / `+` 附件)允许换行:窄面板下它和右侧控件组各占一行。 */ -.game-workbench-layout--design .project-chat-composer-controls-left, +/* Direct 左侧动作组(`@` 引用 / `+` 附件)允许换行:窄面板下它和右侧控件组各占一行。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -11383,8 +11400,19 @@ button.design-workspace-tree__entry:hover, position: relative !important; right: auto !important; bottom: auto !important; - /* 上限与触发钮同档(160px):模型名、错误提示都在这一档里用省略号 / 折行收住, - 「@ + · 推理档 · 模型 · 语音 · 发送」在 400px 以上的面板里保持一行。 */ + max-width: 180px; +} + +/* Direct 侧把模型控件收到 160px(与触发钮同档):模型名、错误提示都在这一档里用 + 省略号 / 折行收住,「@ + · 推理档 · 模型 · 语音 · 发送」在 400px 以上的面板里 + 保持一行。多带一层 `.project-chat-composer-controls-right`(真实父元素)是为了 + 严格高于上面那条共享规则的特异性,不依赖源码顺序;策划面仍走上一条的 180px。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .project-chat-composer-controls-right + .conversation-model-select { max-width: 160px; } @@ -11745,23 +11773,33 @@ button.design-workspace-tree__entry:hover, font-variant-numeric: tabular-nums; } +/* 提示段落的高度上限与折行:策划面保持改动前的 96px 一档,Direct 侧一并复用 + (原来它没有这条,长提示会把输入盒顶高)。 */ .game-workbench-layout--design .project-chat-composer-notice, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-notice { - /* 两个宿主的提示区同一档:超过就自己滚,不把输入盒顶高(直接子元素由下面的 - 共享规则清零 margin,间距交给各自 composer 的 gap)。 */ max-height: 96px; overflow: auto; overflow-wrap: anywhere; } -.game-workbench-layout--design .project-chat-composer-notice, +/* 策划面:提示段落自带 4px 上间距(改动前的值,不动)。 */ +.game-workbench-layout--design .project-chat-composer-notice { + margin: 4px 0 0; + color: var(--platform-text-soft); + font-size: 12px; +} + +/* Direct 侧:提示段落的上间距交给 composer 的 `gap: 8px`,段落自身归零。 + 多带一层 `.project-chat-conversation`(DirectProjectChatView 里的真实祖先)是为了 + 严格高于上面那条共享规则的特异性,不依赖源码顺序。 */ .game-workbench-chat .project-chat-surface.is-direct-codex + .project-chat-conversation .project-chat-composer.is-direct-codex - .project-chat-composer-notice { + > .project-chat-composer-notice { margin: 0; color: var(--platform-text-soft); font-size: 12px; @@ -13113,10 +13151,12 @@ details.design-agent-reasoning[open] /* ============================================================ 输入区工具栏与状态提示(2026-10 · issue #600) ============================================================ - 三组规则,输入区三层布局的收尾。这里只补前文没有的属性:模型钮的 160px 上限 / - 64px 下限、提示区 96px 上限、队列 chip 与提示段落的 `margin` 都写回前文各自那条 - 规则(`.conversation-model-select` / `.conversation-model-trigger` / - `.project-chat-composer-notice` / `.project-chat-composer-queue`),不在这里重复声明。 + **只作用于工作台 Direct 输入区**(`.game-workbench-chat .project-chat-surface + .is-direct-codex .project-chat-composer.is-direct-codex`)。策划输入区 + (`PlanningChatView` / `.game-workbench-layout--design`)不在本 issue 范围内, + 本次一行都不动:它的控件排几何、下拉宽度、提示段落 margin 全部保持改动前的值。 + 这一组只补前文没有的属性;模型钮的 160px 上限 / 64px 下限、提示段落 margin 均 + 写在前文各自那条规则里(Direct 侧用多一层真实祖先提高特异性,不依赖源码顺序)。 ① 工具栏里的下拉控件(推理档 / 模型)要能收缩、模型名要能省略,宽度不够时靠换行 让位,而不是把整组推到输入盒外面去和左边的 `@` `+` 重叠; ② 模型控件自己的状态提示(`ConversationModelSelect` 的 `[role='alert']` / @@ -13126,10 +13166,7 @@ details.design-agent-reasoning[open] `.project-chat-composer-controls`)取胜,不依赖源码顺序; ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距。 */ -/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号(上限已写回前文那条规则)。 */ -.game-workbench-layout--design - .project-chat-composer-controls - .conversation-model-select, +/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -13139,9 +13176,6 @@ details.design-agent-reasoning[open] justify-content: flex-end; } -.game-workbench-layout--design - .project-chat-composer-controls - .conversation-model-trigger-label, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -13152,34 +13186,18 @@ details.design-agent-reasoning[open] text-overflow: ellipsis; } -/* 推理档的读写失败提示同样回到控件排内,整行右对齐。 */ -.game-workbench-layout--design - .project-chat-composer-controls - .project-chat-reasoning-effort, -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer.is-direct-codex - .project-chat-composer-controls - .project-chat-reasoning-effort { - flex-wrap: wrap; - justify-content: flex-end; -} - -/* 档位文案很短(默认 / 高 / 中 / 低),不参与收缩——收缩会把它截成半个字。 - 要让位时由模型钮(名字走省略号)承担。(策划面已经在 `.game-workbench-layout--design - .project-chat-reasoning-effort` 那条里写了 `flex: 0 0 auto`,这里只补 Direct 侧。) */ +/* 推理档:档位文案很短(默认 / 高 / 中 / 低),不参与收缩——收缩会把它截成半个字, + 要让位时由模型钮(名字走省略号)承担;读写失败提示整行右对齐。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-controls .project-chat-reasoning-effort { flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; } -.game-workbench-layout--design - .project-chat-composer-controls - .project-chat-reasoning-effort - .project-chat-composer-notice, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -13192,14 +13210,6 @@ details.design-agent-reasoning[open] } /* ② 模型控件的错误 / 状态提示:从绝对定位浮层改为占一行,跟随控件换行。 */ -.game-workbench-layout--design - .project-chat-composer-controls - .conversation-model-select - [role='alert'], -.game-workbench-layout--design - .project-chat-composer-controls - .conversation-model-select - [role='status'], .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex From 25346d50378a5abee1fc12bb20f4546c45a84720 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:47:31 +0800 Subject: [PATCH 04/53] =?UTF-8?q?=E4=BF=AE=20#600=20=E8=AF=84=E5=AE=A1=20P?= =?UTF-8?q?2=EF=BC=9A=E8=A1=8C=E9=AB=98=E7=AE=97=E5=BC=8F=E5=8F=96?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E5=86=B3=E5=AE=9A=E5=80=BC=E3=80=81=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E6=A1=A3=20flex=20=E5=86=99=E5=9B=9E=E6=89=80?= =?UTF-8?q?=E6=9C=89=E8=80=85=E3=80=81=E5=88=A0=E6=AD=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 测试:`composerMaxHeight()` 的工具栏行高改取该排最高控件的 `Math.max`(模型触发钮 30px > 发送钮 28px),输入盒最高高度 254 → 256,并新增「触发钮高度 = 30」断言,不再写死发送钮 - `appSurface` 的编辑器高度断言改钉生效规则(六类选择器那条 72 / 160),原五类规则的 96 / 140 已随死重删除 - Direct 推理档的 `flex: 0 0 auto` 写回其所有者规则(原 `flex: 0 1 auto` 那条),末尾区块不再重复声明同属性;区块头注明本区块不依赖源码顺序 - 删死重(均恒被更高特异性规则覆盖):≤1000px 媒体块里五类的 `.resource-reference-input { padding: 10px 12px 6px }`;五类输入规则的 `display` / `width` / `padding` / `border-color` / `border-radius` / `background`(保留仍生效的 `color` 与 `pointer-events`);五类 `:focus-within` 整条(被六类 `border: 0; outline: none; box-shadow: none` 恒覆盖);五类编辑器规则的 `min-height` / `max-height` - 校验:`chatDialogFrameLayout.test.ts` 12/12、`appSurface.test.ts` 196 passed / 9 skipped、`npm run agc:typecheck` 通过、prettier 通过 --- apps/ai-game-creator-shell/src/styles.css | 50 +++++++------------ .../appSurface/project-development.suite.ts | 11 ++-- .../tests/chatDialogFrameLayout.test.ts | 24 +++++++-- 3 files changed, 45 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d08a30305..fb352ba32 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -10287,14 +10287,9 @@ iframe.preview-frame { flex: 0 0 auto; } - /* 窄屏:输入框左右留白收紧,操作排(`@` / AI 润色 / 恢复原文)保持单行不换行, - 编辑器靠 `minmax(0, 1fr)` 吸收宽度,不会挤出横向滚动条。 */ - .game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer - .resource-reference-input { - padding: 10px 12px 6px; - } + /* 窄屏输入框的内边距由六类选择器的 + `.… .project-chat-composer.is-direct-codex .resource-reference-input` 统一给(`padding: 0`), + 这里原有一条五类的 `padding: 10px 12px 6px` 恒不生效,已按「删死重」删除。 */ .game-workbench-chat .project-chat-surface, .game-workbench-chat .project-chat-conversation { @@ -11703,7 +11698,9 @@ button.design-workspace-tree__entry:hover, .project-chat-reasoning-effort { display: inline-flex; align-items: center; - flex: 0 1 auto; + /* 档位文案很短(默认 / 高 / 中 / 低),不参与收缩——收缩会把它截成半个字; + 要让位时由模型钮(名字走省略号)承担。 */ + flex: 0 0 auto; min-width: 0; gap: 6px; } @@ -11905,29 +11902,19 @@ button.design-workspace-tree__entry:hover, max-height: 24px; } +/* 输入区仍然生效的两条声明:文字色与可交互性。几何(`display` / `width` / `padding`)、 + 描边与底色全部由更高特异性的 + `.game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex + .resource-reference-input` 那两条规则决定(六类 > 五类),这里的同属性声明恒不生效, + 已按「删死重」删除。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer .resource-reference-input { - display: grid; - width: 100%; - padding: 12px 15px 6px; - border-color: var(--platform-surface-border); - border-radius: 14px; - background: var(--platform-input-fill); color: var(--platform-text-base); pointer-events: auto; } -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer - .resource-reference-input:focus-within { - border-color: var(--platform-surface-hover-border); - outline: none; - box-shadow: 0 0 0 3px var(--platform-input-focus-ring); -} - .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer @@ -11940,12 +11927,13 @@ button.design-workspace-tree__entry:hover, line-height: 1.6; } +/* 编辑器的行高与滚动:`min-height` / `max-height` 由更高特异性的 + `.… .project-chat-composer.is-direct-codex .resource-reference-input .resource-reference-input-editor` + 那条决定(六类 + 额外一层 `.resource-reference-input`),这里的旧值恒不生效,已删。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer .resource-reference-input-editor { - min-height: 96px; - max-height: 140px; overflow-y: auto; padding: 0; font-size: 13px; @@ -13155,8 +13143,10 @@ details.design-agent-reasoning[open] .is-direct-codex .project-chat-composer.is-direct-codex`)。策划输入区 (`PlanningChatView` / `.game-workbench-layout--design`)不在本 issue 范围内, 本次一行都不动:它的控件排几何、下拉宽度、提示段落 margin 全部保持改动前的值。 - 这一组只补前文没有的属性;模型钮的 160px 上限 / 64px 下限、提示段落 margin 均 - 写在前文各自那条规则里(Direct 侧用多一层真实祖先提高特异性,不依赖源码顺序)。 + 这一组只补前文没有的属性;模型钮的 160px 上限 / 64px 下限、推理档的 + `flex: 0 0 auto`、提示段落 margin 均写在前文各自那条规则里(Direct 侧用多一层 + 真实祖先提高特异性)。**本区块不依赖源码顺序**:这里要么是前文从未声明过的属性, + 要么用更长的选择器链严格盖过前文,把整块挪到文件中间结果也不变。 ① 工具栏里的下拉控件(推理档 / 模型)要能收缩、模型名要能省略,宽度不够时靠换行 让位,而不是把整组推到输入盒外面去和左边的 `@` `+` 重叠; ② 模型控件自己的状态提示(`ConversationModelSelect` 的 `[role='alert']` / @@ -13186,14 +13176,12 @@ details.design-agent-reasoning[open] text-overflow: ellipsis; } -/* 推理档:档位文案很短(默认 / 高 / 中 / 低),不参与收缩——收缩会把它截成半个字, - 要让位时由模型钮(名字走省略号)承担;读写失败提示整行右对齐。 */ +/* 推理档的读写失败提示整行右对齐(`flex: 0 0 auto` 已写回上面那条推理档规则)。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-controls .project-chat-reasoning-effort { - flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index b628aab6d..a68908fca 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -6369,13 +6369,16 @@ export function registerProjectWorkbenchFoundationTests() { expect(messageListRule).toContain('padding-bottom: 0;'); expect(messageListRule).toContain('flex: 1 1 auto;'); - // 编辑器高度上限仍是输入盒高度的来源之一。 + // 编辑器的 min/max 高度是输入盒高度的来源之一。生效声明在六类选择器那条 + // (比旧五类规则多一层 `.resource-reference-input`,严格更高),旧规则的 96 / 140 + // 恒不生效、已按「删死重」删除;层叠生效值与输入盒最高高度算式由 + // tests/chatDialogFrameLayout.test.ts 验证。 const editorRule = styles.match( - /\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\s+\.resource-reference-input-editor\s*\{([^}]*)\}/s, + /\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\.is-direct-codex\s+\.resource-reference-input\s+\.resource-reference-input-editor\s*\{([^}]*)\}/s, )?.[1] ?? ''; - expect(styleNumber(editorRule, 'max-height')).toBe(140); - expect(styleNumber(editorRule, 'min-height')).toBe(96); + expect(styleNumber(editorRule, 'max-height')).toBe(160); + expect(styleNumber(editorRule, 'min-height')).toBe(72); }); it('keeps workbench chat bubbles aligned and the process card inset with them', () => { diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index 17e632124..f50c687aa 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -152,6 +152,11 @@ const COMPOSER_EDITOR = `${COMPOSER_INPUT} .resource-reference-input-editor`; const COMPOSER_EDITOR_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input-editor`; const COMPOSER_CONTROLS = `${COMPOSER} .project-chat-composer-controls`; const COMPOSER_SUBMIT = `${COMPOSER_CONTROLS} .project-chat-submit-button`; +/** 模型触发钮:工具栏那一排里最高的一颗(30px),行高由它决定而不是发送钮(28px)。 */ +const COMPOSER_TRIGGER = `${COMPOSER_CONTROLS} .conversation-model-trigger`; +/** 触发钮的基础几何规则挂在 `.project-chat-composer`(不带 `.is-direct-codex`)上,同样命中。 */ +const COMPOSER_TRIGGER_BASE = + '.project-chat-surface.is-direct-codex .project-chat-composer .conversation-model-trigger'; const INPUT_ACTIONS = `${COMPOSER_INPUT} .resource-reference-input-actions`; const INPUT_ACTIONS_PLAIN = `${COMPOSER} .resource-reference-input-actions`; const TOPBAR = `${CHAT} .project-chat-topbar`; @@ -197,6 +202,7 @@ function composerMaxHeight() { ); const controls = desktopDeclarations(COMPOSER_CONTROLS); const submit = desktopDeclarations(COMPOSER_SUBMIT); + const trigger = desktopDeclarations(COMPOSER_TRIGGER, COMPOSER_TRIGGER_BASE); const editorMaxHeight = pixelValue(editor, 'max-height'); const actionsRowHeight = pixelValue(polishButton, 'height'); @@ -207,13 +213,21 @@ function composerMaxHeight() { const inputPaddingBottom = paddingBox(input).bottom; const composerPaddingTop = paddingBox(composer).top; const composerPaddingBottom = paddingBox(composer).bottom; - // 工具栏自己的高度就是那颗 28px 方钮:与输入区之间的间距归输入盒的 row-gap, - // 工具栏不再自带 padding-top(否则就是同一段间距写两遍)。 + // 工具栏行高由这一排里**最高的一颗控件**决定:模型触发钮 30px 高于发送 / 语音的 + // 28px(`align-items: center` 只做居中,不会把行高压回 28)。所以这里取两者最大值, + // 而不是写死某一颗钮的高度——改任一颗的尺寸都会自动反映到算式里。 expect( controls.has('padding-top'), '工具栏与输入区之间的间距由输入盒的 row-gap 提供,不再用 padding-top', ).toBe(false); - const controlsRowHeight = pixelValue(submit, 'height'); + expect( + pixelValue(trigger, 'height'), + '工具栏行高由模型触发钮决定,改了要重新核对这条算式', + ).toBe(30); + const controlsRowHeight = Math.max( + pixelValue(submit, 'height'), + pixelValue(trigger, 'height'), + ); expect( pixelValue(editor, 'min-height'), @@ -301,10 +315,10 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 // 输入盒最高高度由真实声明算出来(三层布局,自上而下): // 盒内上内边距 12 + 编辑器 160 + 输入区行距 6 + 操作排 28 + 输入区下内边距 0 - // + 输入盒与工具栏的行距 8 + 工具栏 28 + 盒内下内边距 12 = 254。 + // + 输入盒与工具栏的行距 8 + 工具栏 30(模型触发钮撑起来的行高)+ 盒内下内边距 12 = 256。 expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28); expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28); - expect(total).toBe(254); + expect(total).toBe(256); const paddingBottom = paddingBox(list).bottom; // 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式, From 0cbf9521ee5114d78915b0c857df9f3e6651a717 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 15:08:22 +0800 Subject: [PATCH 05/53] =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=80=9D=E7=BB=B4=E5=BC=BA=E5=BA=A6?= =?UTF-8?q?=E6=94=B9=20popover=20=E6=BB=91=E5=9D=97=EF=BC=8C=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=B8=8D=E5=8F=AF=E7=94=A8=E6=94=B9=20toast=EF=BC=88#?= =?UTF-8?q?600=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 思维强度:`ComposerReasoningEffortSelect` 新增 `presentation: 'dropdown' | 'slider'`(默认 dropdown,策划输入盒原样不动),Direct 输入盒走 slider —— 触发钮固定文案「选择强度」(当前档位在 `aria-label`/`title` 与浮层里的「当前:最高」),档位选择收进 popover 里的原生 ``:自带 `role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End,`aria-valuetext` 报中文档位名,点轨道/拖拽按 step 吸附;Esc 与点击外部关闭,改档后浮层保持打开 - 档位枚举、默认值、持久化通道(`read_game_creator_app_config` / `select_game_creator_reasoning_effort`)与旧下拉完全一致,只换表现形式 - 模型不可用:`ConversationModelSelect` 新增 `errorPresentation: 'inline' | 'toast'`(默认 inline,首页/策划/画布不变)与 `onUnavailable`,toast 模式下不再渲染常驻 `[role='alert']`;Direct 输入盒由 `ComposerModelNoticeToast` 复用外壳同一枚 `PlatformRuntimeStatusToast`(bottom-center portal、6s)提示,失败消息新出现时一次、同条去重、清空后可再报 - 文案:`llmModelCatalog.ts` / `clientApi.ts` 的「本地模型服务不可用」改为「模型服务不可用」;不可用态照旧禁用发送,但不再留文字行 - 错位根因与改法:上一轮把模型提示收回控件排内使整颗控件变两行高、在 `align-items: center` 的工具条里把触发钮顶到行线上方;现在该元素不再渲染,`styles.css` 里对应的 composer 作用域 `[role='alert']` 规则按「删死重」删除 - `styles.css`:新增强度 popover / 滑块 / 档位点样式(几何沿用 `.project-chat-attachment-menu` 约定,颜色只取 `--platform-*`) - 测试:`tests/appSurface/chat-composer.suite.ts` 的档位用例改为滑块(打开、aria 值域、持久化、Esc/点外部关闭),新增 toast 用例与「只报一次」用例;`tests/chatDialogFrameLayout.test.ts` 新增 popover 几何断言;策划面差分审计复跑 0 处声明变化(对收口前提交仍报 62 处,证明检查不空转) - pitfalls 补两条:输入区不留常驻状态行(用 toast)、档位选择用 popover + 滑块 --- .../ConversationModelSelect.tsx | 52 +++- .../src/services/clientApi.ts | 2 +- .../src/services/llmModelCatalog.ts | 2 +- apps/ai-game-creator-shell/src/styles.css | 234 +++++++++++++-- .../ComposerControls.tsx | 280 +++++++++++++++--- .../DirectProjectComposer.tsx | 35 ++- .../tests/appSurface/chat-composer.suite.ts | 156 +++++++++- .../tests/chatDialogFrameLayout.test.ts | 25 ++ docs/project-memory/shared-memory/pitfalls.md | 16 + 9 files changed, 715 insertions(+), 87 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 2bc9df75c..3d69a9dc9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -65,12 +65,28 @@ export function ConversationModelSelect({ onReady, projectPath, ref, + errorPresentation = 'inline', + onUnavailable, + onModelLabelChange, }: { className?: string; disabled: boolean; onReady?: (ready: boolean) => void; projectPath?: string; ref?: Ref; + /** + * 目录 / 选择失败的呈现方式。 + * + * `inline`(默认)把文案渲染在控件里,供首页、策划输入盒、画布等宿主沿用; + * `toast` 不渲染常驻文案,只把消息交给宿主的 `onUnavailable`——输入盒那一排控件 + * 容不下第二行文字:一旦在控件里排一行提示,整颗控件会变成两行高,在 + * `align-items: center` 的工具条里把触发钮顶到行线上方(#600 复核实测的错位)。 + */ + errorPresentation?: 'inline' | 'toast'; + /** `errorPresentation: 'toast'` 时,对**新出现**的失败消息回调一次(同一条不重复)。 */ + onUnavailable?: (message: string) => void; + /** 当前选中模型的显示名(目录没读完时是空串);供宿主在别处只读展示。 */ + onModelLabelChange?: (label: string) => void; }) { const initialCatalog = cachedLlmModelCatalog(); const [models, setModels] = useState( @@ -101,6 +117,36 @@ export function ConversationModelSelect({ onReadyRef.current = onReady; }, [onReady]); + const onUnavailableRef = useRef(onUnavailable); + const onModelLabelChangeRef = useRef(onModelLabelChange); + useEffect(() => { + onUnavailableRef.current = onUnavailable; + onModelLabelChangeRef.current = onModelLabelChange; + }, [onModelLabelChange, onUnavailable]); + + /** 已经报出去的失败消息:同一条只在「新出现」时报一次,失败清空后可以再报一次。 */ + const reportedErrorRef = useRef(''); + useEffect(() => { + if (errorPresentation !== 'toast') { + return; + } + if (error === '') { + reportedErrorRef.current = ''; + return; + } + if (reportedErrorRef.current === error) { + return; + } + reportedErrorRef.current = error; + onUnavailableRef.current?.(error); + }, [error, errorPresentation]); + + const selectedModelLabel = + models.find((model) => model.id === selected)?.displayName ?? ''; + useEffect(() => { + onModelLabelChangeRef.current?.(selectedModelLabel); + }, [selectedModelLabel]); + useEffect(() => { mountedRef.current = true; return () => { @@ -396,7 +442,11 @@ export function ConversationModelSelect({ : 'conversation-model-select' } > - {error ? {error} : null} + {/* `toast` 呈现方式下这里不渲染任何常驻文案:控件排容不下第二行, + 失败由宿主的浮层提示承担(见 `errorPresentation` 的说明)。 */} + {error && errorPresentation === 'inline' ? ( + {error} + ) : null} {notice ? {notice} : null} - {open ? ( -
- {options.map((option) => ( - + className={index === activeIndex ? 'is-active' : undefined} + /> ))} -
- ) : null} - + + + {`当前:${activeLabel}`} + + + ) : null} + {/* 触发钮用固定文案(不显示当前档位):档位在 popover 里看,控件保持一颗的宽度。 */} + + + ); + + return ( + + {presentation === 'slider' ? sliderSelect : dropdownSelect} {notice ? ( {notice} @@ -473,3 +598,66 @@ export function ComposerStopButton({ ); } + +/** 模型服务不可用这类输入区失败的停留时长:与外壳的错误提示同一档(6s)。 */ +const COMPOSER_MODEL_NOTICE_MILLIS = 6000; + +export type ComposerModelNotice = { + /** 每次提示自增:同一条文案再出现时也会重新弹一次。 */ + id: number; + message: string; +}; + +/** + * 输入区自己的浮层提示(模型服务不可用等)。 + * + * 输入区**不留常驻状态行**:那一排控件容不下第二行文字,一旦在控件里排一行提示, + * 整颗控件会变成两行高、把触发钮顶到行线上方(#600 复核实测的错位)。所以失败只走 + * 浮层——复用外壳同一枚 `PlatformRuntimeStatusToast` 与同一套定位/时长约定 + * (见 `LauncherNoticeToast`、`TemplateLibraryToast`)。 + */ +export function ComposerModelNoticeToast({ + notice, + onDismiss, +}: { + notice: ComposerModelNotice | null; + onDismiss: () => void; +}) { + /** 收起回调只从 ref 读:宿主每次渲染都换箭头函数,写进依赖会让计时器反复重起。 */ + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; + useEffect(() => { + if (!notice) { + return; + } + const timer = window.setTimeout( + () => onDismissRef.current(), + COMPOSER_MODEL_NOTICE_MILLIS, + ); + return () => { + window.clearTimeout(timer); + }; + }, [notice]); + + if (!notice) { + return null; + } + return createPortal( +
+ + {notice.message} + +
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index 4092a9939..eb68cedac 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -27,10 +27,12 @@ import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import type { UserContentPart } from '../../generated/UserContentPart'; import { ComposerAttachmentMenu, + ComposerModelNoticeToast, ComposerReasoningEffortSelect, ComposerStopButton, ComposerTurnQueue, ComposerVoiceButton, + type ComposerModelNotice, } from './ComposerControls'; /** 草稿里已有的附件芯片数:附件上限按它算,不再另存一份待发列表。 */ @@ -114,6 +116,16 @@ export function DirectProjectComposer({ const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); const [voiceNotice, setVoiceNotice] = useState(''); + /* + 模型服务的失败只走浮层提示:输入区那一排控件不留常驻状态行(会把它顶成两行高)。 + 同一条文案再次出现时也能再弹一次,所以按自增 id 记这一条。 + */ + const [modelNotice, setModelNotice] = useState( + null, + ); + const modelNoticeIdRef = useRef(0); + /** 当前选中模型的显示名(目录没读完时是空串):只在强度浮层里只读展示。 */ + const [modelLabel, setModelLabel] = useState(''); const [modelReady, setModelReady] = useState(false); const [modelValidating, setModelValidating] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); @@ -166,6 +178,12 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); + /** 记一条浮层提示:id 自增,保证重复文案也会重新弹一次并重起计时。 */ + const pushModelNotice = useCallback((message: string) => { + modelNoticeIdRef.current += 1; + setModelNotice({ id: modelNoticeIdRef.current, message }); + }, []); + // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( @@ -242,12 +260,19 @@ export function DirectProjectComposer({ />
- + + {/* + 输入区自己的浮层提示:模型服务不可用这类失败不留常驻文字行(那一排控件容不下 + 第二行,会把触发钮顶到行线上方)。组件内部 portal 到 body,与外壳的 toast 同一套。 + */} + setModelNotice(null)} + /> ); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 3d875e617..e471cc645 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -1,5 +1,10 @@ import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences'; +import { ConversationModelSelect } from '../../src/features/project-workspace/ConversationModelSelect'; import * as platformSession from '../../src/services/platformSession'; +import { + LlmModelCatalogConfigError, + resetLlmModelCatalogCacheForTest, +} from '../../src/services/llmModelCatalog'; import { appendDictationText, resolveSpeechRecognitionCtor, @@ -1033,7 +1038,7 @@ export function registerChatComposerControlTests() { ).not.toBeNull(); }); - it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { + it('puts the reasoning strength in a popover slider and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ select_game_creator_reasoning_effort: (args) => { @@ -1042,37 +1047,168 @@ export function registerChatComposerControlTests() { }, }); - const select = within(surface).getByRole('button', { name: '推理档' }); + // 触发钮固定文案「选择强度」:当前档位只在 aria-label / title 与浮层里出现。 + const trigger = within(surface).getByRole('button', { name: /选择强度/ }); + expect(trigger.textContent).toContain('选择强度'); await waitFor(() => { - expect(select.textContent).toContain('高'); + expect(trigger.getAttribute('aria-label')).toContain('当前 高'); }); - // 档位就在模型选择器这一排(同一控制排容器里)。 + expect(trigger.getAttribute('title')).toContain('当前:高'); + // 触发钮就在模型选择器这一排(同一控制排容器里),关闭态没有滑块。 expect( within(surface).getByRole('button', { name: '对话模型' }), ).not.toBeNull(); - expect(select.closest('.project-chat-composer-controls')).not.toBeNull(); + expect(trigger.closest('.project-chat-composer-controls')).not.toBeNull(); + expect( + within(surface).queryByRole('slider', { name: '思维强度' }), + ).toBeNull(); - fireEvent.click(select); - fireEvent.click(within(surface).getByRole('option', { name: '低' })); + // 点开 popover:滑块报出 role/值域与中文档位名。 + fireEvent.click(trigger); + const slider = within(surface).getByRole('slider', { + name: '思维强度', + }) as HTMLInputElement; + expect(slider.getAttribute('aria-valuemin')).toBe('0'); + expect(slider.getAttribute('aria-valuemax')).toBe('4'); + expect(slider.getAttribute('aria-valuenow')).toBe('3'); + expect(slider.getAttribute('aria-valuetext')).toBe('高'); + expect(within(surface).getByText('当前:高')).not.toBeNull(); + + // 拖动 / 点档位走原生 range 的 change,落到与旧下拉同一条持久化通道与取值。 + fireEvent.change(slider, { target: { value: '1' } }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( 'select_game_creator_reasoning_effort', { effort: 'low' }, ); }); - // 以落盘后的回读值为准。 + // 以落盘后的回读值为准;改档后浮层保持打开(连续调节不用重开)。 await waitFor(() => { expect( - within(surface).getByRole('button', { name: '推理档' }).textContent, - ).toContain('低'); + within(surface) + .getByRole('button', { name: /选择强度/ }) + .getAttribute('aria-label'), + ).toContain('当前 低'); }); expect(stored).toBe('low'); + expect( + within(surface).getByRole('slider', { name: '思维强度' }), + ).not.toBeNull(); // 只影响后续回合:当前没有发出任何新一轮直接对话。 expect( invoke.mock.calls.some( ([command]) => command === 'enqueue_direct_codex_turn', ), ).toBe(false); + + // Esc 关闭;再点开、点外部也关闭。 + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => { + expect( + within(surface).queryByRole('slider', { name: '思维强度' }), + ).toBeNull(); + }); + fireEvent.click(within(surface).getByRole('button', { name: /选择强度/ })); + expect( + within(surface).getByRole('slider', { name: '思维强度' }), + ).not.toBeNull(); + fireEvent.mouseDown(document.body); + await waitFor(() => { + expect( + within(surface).queryByRole('slider', { name: '思维强度' }), + ).toBeNull(); + }); + }); + + it('shows model service unavailability as a toast and leaves no persistent line', async () => { + const { surface } = await openDirectCodexSurface({ + load_game_creator_llm_models: () => { + throw new LlmModelCatalogConfigError('模型服务不可用'); + }, + }); + + const toast = await waitFor(() => { + const element = document.querySelector( + '[data-composer-model-toast="true"]', + ); + expect(element).not.toBeNull(); + return element as HTMLElement; + }); + expect(toast.textContent).toContain('模型服务不可用'); + // 输入区里不留常驻文案、也没有 [role='alert']:失败只走浮层。 + const composer = surface.querySelector( + '.project-chat-composer.is-direct-codex', + ); + expect(composer).not.toBeNull(); + expect(composer?.querySelector('[role="alert"]')).toBeNull(); + expect(composer?.textContent).not.toContain('模型服务不可用'); + expect(surface.textContent).not.toContain('本地模型服务不可用'); + // 不可用态照旧挡住发送,但不靠一行说明文字表达。 + expect( + ( + within(surface).getByRole('button', { + name: '发送', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it('reports one newly appeared model failure and never repeats the same message', async () => { + const onUnavailable = vi.fn(); + let failing = true; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return gameCreatorConfigView('high'); + } + if (command === 'load_game_creator_llm_models') { + if (failing) { + throw new LlmModelCatalogConfigError('模型服务不可用'); + } + return { + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量', agentMode: 'codex' }, + ], + revision: 7, + }; + } + return null; + }); + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); + render( + React.createElement(ConversationModelSelect, { + disabled: false, + errorPresentation: 'toast', + onUnavailable, + }), + ); + await waitFor(() => { + expect(onUnavailable).toHaveBeenCalledTimes(1); + }); + expect(onUnavailable).toHaveBeenCalledWith('模型服务不可用'); + + // 同一条失败再次同步目录(打开下拉会触发一次)不重复报。 + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await waitFor(() => { + expect(onUnavailable).toHaveBeenCalledTimes(1); + }); + + // 失败清空后再出现,可以再报一次。 + resetLlmModelCatalogCacheForTest(); + failing = false; + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await waitFor(() => { + expect(onUnavailable).toHaveBeenCalledTimes(1); + }); + resetLlmModelCatalogCacheForTest(); + failing = true; + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await waitFor(() => { + expect(onUnavailable).toHaveBeenCalledTimes(2); + }); }); it('appends the dictated transcript after the draft typed into the composer', async () => { diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index f50c687aa..436d1ce3d 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -331,6 +331,31 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 expect(paddingBottom).toBeLessThan(total); }); + it('思维强度:触发钮留在工具条同一排,档位收进 popover 的滑块(宽屏)', () => { + const trigger = desktopDeclarations( + `${COMPOSER} .project-chat-composer-strength-trigger`, + COMPOSER_TRIGGER, + COMPOSER_TRIGGER_BASE, + ); + const anchor = desktopDeclarations( + `${COMPOSER} .project-chat-composer-strength`, + ); + const popover = desktopDeclarations( + `${COMPOSER} .project-chat-composer-strength-popover`, + ); + const range = desktopDeclarations( + `${COMPOSER} .project-chat-composer-strength-range`, + ); + + // 触发钮沿用工具条那颗 30px 控件的高度和类:工具条行高仍是 30(见上面的算式)。 + expect(pixelValue(trigger, 'height')).toBe(30); + // 浮层锚在强度控件上、从工具条上方展开,不占工具条宽度;滑块占满浮层宽度。 + expect(declaration(anchor, 'position')).toBe('relative'); + expect(declaration(popover, 'position')).toBe('absolute'); + expect(declaration(popover, 'bottom')).toBe('calc(100% + 8px)'); + expect(declaration(range, 'width')).toBe('100%'); + }); + it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => { const conversation = mobileDeclarations(CONVERSATION); const list = mobileDeclarations(...LIST_SELECTORS); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d5bacb57b..a57ec0757 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,22 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-04 输入区不放常驻状态行:那一排控件容不下第二行文字 + +- **现象**:AGC 工作台输入区里模型失败提示常驻一行红字,工具条上还浮着一个半透明的「模型 ⌄」压在 `@ + 默认 ⌄` 那一排上;用户复核口径是"完全没对齐",并要求改成 toast。 +- **成因**:`ConversationModelSelect` 的 `[role='alert']` 曾被从 `bottom: 36px` 浮层收回控件排内(`flex: 1 1 100%` 独占一行),整颗模型控件因此变成两行高;工具条是 `align-items: center` 的 flex 行、行高由最高项决定,于是触发钮被顶到行线上方、提示落到行线下方——**不是"某个元素绝对定位压过来",是行内多了一行文字**。 +- **处理(现行口径)**:输入区**不留常驻状态行**,与输入区同一排的失败提示一律走浮层。`ConversationModelSelect` 新增 `errorPresentation: 'inline' | 'toast'`(默认 `inline`,首页 / 策划 / 画布原样不动),Direct 输入盒传 `'toast'`,由 `ComposerModelNoticeToast` 复用外壳同一枚 `PlatformRuntimeStatusToast`(bottom-center portal、6s 自动收起);触发时机是失败消息**新出现**时一次(同条去重,清空后可再报),不做每次渲染都弹;不可用态照旧挡住发送,但不靠文字行表达。文案同步去掉误解性的「本地」前缀(`llmModelCatalog.ts` / `clientApi.ts` → 「模型服务不可用」)。 +- **判据/取证**:`tests/appSurface/chat-composer.suite.ts`——不可用时产出 toast,且输入区子树里既没有 `[role='alert']` 也没有旧文案;模型失败只报一次、清空后再出现才再报。 +- **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。 + +## 2026-10-04 输入盒的档位选择用 popover + 滑块,触发钮只占一颗控件宽 + +- 触发钮固定文案「选择强度」,当前档位只出现在 `aria-label` / `title` 与浮层里的「当前:最高」;滑块直接用原生 ``:`role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End 都是原生行为,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 +- 档位枚举、默认值、持久化通道(读 `read_game_creator_app_config`、写 `select_game_creator_reasoning_effort`)与旧下拉**完全一致**,只换表现形式;`ComposerReasoningEffortSelect` 的 `presentation` 默认 `'dropdown'`,策划输入盒继续走下拉(零语义变化,差分审计仍为 0 处声明变化)。 +- 点档位后浮层**保持打开**(连续调节不用重开),关闭走 Esc 与点击外部;浮层几何沿用 `.project-chat-attachment-menu` 同一套约定,颜色只取 `--platform-*`。 +- **判据/取证**:`tests/appSurface/chat-composer.suite.ts` 的滑块用例(打开 / 取值 / 持久化 / 关闭路径)+ `tests/chatDialogFrameLayout.test.ts` 的浮层几何断言。 +- **关联**:`view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx`、`src/styles.css`。 + ## 2026-10-03 AGC 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位 - **现象**:#600 截图里用户输入区域「元素堆叠」——空草稿时「AI 润色」按钮就压在输入内容区右下角(实测与编辑器相交 28×24px),模型控件的失败提示「本地模型服务不可用」直接画在第 3~4 行文字上(70×36px,长草稿时把整段文字盖住);面板越窄越糟:280px 面板下推理档整颗压在「添加文件」上(28×28px),「插入素材引用」与推理档也相交(20×28px)。 From 530d166a6543a49765faab5e4fef2392d00e85de Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 17:56:41 +0800 Subject: [PATCH 06/53] =?UTF-8?q?=E4=BF=AE=20CI=20=E7=9A=84=20import=20?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E9=97=A8=E7=A6=81=EF=BC=9A=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=E6=96=87=E4=BB=B6=E7=9A=84=20import=20?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repository checks 的 eslint 报 simple-import-sort/imports 三处(ComposerControls.tsx、DirectProjectComposer.tsx、chat-composer.suite.ts),全部是 import 排序,没有逻辑改动 - 用 npx eslint --fix 自动修(+4/−5),随后 prettier --check、npm run agc:typecheck、appSurface 用例复跑全部通过 --- .../components/DirectProjectComposer/ComposerControls.tsx | 3 +-- .../DirectProjectComposer/DirectProjectComposer.tsx | 2 +- .../tests/appSurface/chat-composer.suite.ts | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index 301b0bd70..44c79d209 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -5,6 +5,7 @@ * 这些组件只承载表现与交互;回合附件由宿主导入后插成正文芯片(正文是唯一事实源), * 待发消息队列由宿主持有(这里只是一份事件投影,文案派生见 `pendingTurnChipLabel.ts`)。 */ +import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; import { Check, ChevronDown, @@ -20,8 +21,6 @@ import type { RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; - import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../../../../app/tauri'; import type { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index 259fbc8a4..5d39a4024 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -38,12 +38,12 @@ import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import type { UserContentPart } from '../../generated/UserContentPart'; import { ComposerAttachmentMenu, + type ComposerModelNotice, ComposerModelNoticeToast, ComposerReasoningEffortSelect, ComposerStopButton, ComposerTurnQueue, ComposerVoiceButton, - type ComposerModelNotice, } from './ComposerControls'; /** 草稿里已有的附件芯片数:附件上限按它算,不再另存一份待发列表。 */ diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index e471cc645..89b3bbe32 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -1,10 +1,10 @@ -import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { ConversationModelSelect } from '../../src/features/project-workspace/ConversationModelSelect'; -import * as platformSession from '../../src/services/platformSession'; +import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { LlmModelCatalogConfigError, resetLlmModelCatalogCacheForTest, } from '../../src/services/llmModelCatalog'; +import * as platformSession from '../../src/services/platformSession'; import { appendDictationText, resolveSpeechRecognitionCtor, From c197edb203136f328a844d94d8282a2b4f89f578 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 00:13:37 +0800 Subject: [PATCH 07/53] =?UTF-8?q?=E5=BC=BA=E5=BA=A6=E6=BB=91=E5=9D=97?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E6=A8=A1=E5=9E=8B=E8=8F=9C=E5=8D=95=EF=BC=9A?= =?UTF-8?q?=E5=BA=95=E8=A1=8C=E5=8F=AA=E5=89=A9=E4=B8=80=E9=A2=97=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=EF=BC=88=E5=A4=8D=E6=A0=B8=E5=8F=8D=E9=A6=88=202?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ConversationModelSelect` 新增 `strengthSection`:传了它就把弹层包一层 `.conversation-model-menu-shell`(强度区在上、`role="listbox"` 模型列表在下,共用同一张卡);不传时渲染与改动前逐字一致(首页 / 策划 / 画布不变) - 强度区不再自带触发钮与浮层:`ComposerReasoningEffortSelect` 的 `presentation` 由 `'slider'` 改为 `'section'`(只渲染「思维强度」标题 + 原生 range + 5 档位点 + 「当前:最高」),开合 / Esc / 点外部交给模型菜单既有行为;Direct 输入盒把它作为 `strengthSection` 注入,底行元素 2 → 1 - 删掉上一轮的独立触发钮与其专用 CSS(`project-chat-composer-strength-trigger` / `-strength-popover` / `-strength-model` 与 `-strength` 锚点);同时删掉不再使用的 `onModelLabelChange` 回调——强度区不再复述模型名(触发钮与列表已处处显示模型,重复信息优先去掉以省空间) - 新增外壳 CSS:定位 / 朝向沿用原模型菜单(贴触发钮右缘、向上展开、不盖输入框),里层菜单回到文档流只负责滚动,超长模型名改走省略号 - 测试:`chat-composer.suite.ts` 改为「打开模型菜单 → 滑块在顶部 → 改档落同一条持久化通道 → Esc/点外部关闭」,并断言底行不再有第二颗控件、滑块不在 listbox 子树里;`chatDialogFrameLayout.test.ts` 断言外壳向上展开 / 强度区网格 / 底行高度不增;`design-agent.suite.ts` 断言策划菜单里没有滑块;`project-development.suite.ts` 断言旧触发钮/浮层 CSS 已删 - pitfalls 同步改写该条(含「滑块不能进 listbox」与「强度区随菜单挂载,打开时先显示默认再回读」的取舍) - 校验:4 个 vitest 文件 224 passed / 9 skipped、typecheck、eslint、prettier、encoding、spacetime-schema、diff-check 全通过 --- .../ConversationModelSelect.tsx | 127 +++++++------- apps/ai-game-creator-shell/src/styles.css | 115 ++++++++----- .../ComposerControls.tsx | 155 ++++++------------ .../DirectProjectComposer.tsx | 15 +- .../tests/appSurface/chat-composer.suite.ts | 51 +++--- .../tests/appSurface/design-agent.suite.ts | 3 + .../appSurface/project-development.suite.ts | 7 + .../tests/chatDialogFrameLayout.test.ts | 33 ++-- docs/project-memory/shared-memory/pitfalls.md | 14 +- 9 files changed, 272 insertions(+), 248 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 3d69a9dc9..266eef271 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -1,5 +1,5 @@ import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; -import type { Ref } from 'react'; +import type { ReactNode, Ref } from 'react'; import { useCallback, useEffect, @@ -67,7 +67,7 @@ export function ConversationModelSelect({ ref, errorPresentation = 'inline', onUnavailable, - onModelLabelChange, + strengthSection, }: { className?: string; disabled: boolean; @@ -85,8 +85,15 @@ export function ConversationModelSelect({ errorPresentation?: 'inline' | 'toast'; /** `errorPresentation: 'toast'` 时,对**新出现**的失败消息回调一次(同一条不重复)。 */ onUnavailable?: (message: string) => void; - /** 当前选中模型的显示名(目录没读完时是空串);供宿主在别处只读展示。 */ - onModelLabelChange?: (label: string) => void; + /** + * 菜单顶部的附加区(Direct 输入盒用来放「思维强度」滑块)。 + * + * 传了它才会把弹层包一层 `.conversation-model-menu-shell`:外壳负责定位 / 描边 / 底色, + * 强度区在上、模型列表(`role="listbox"`)在下,滑块**不进** listbox 子树 + * (参数控件放进菜单角色里会让读屏进菜单模式后读不到滑块)。 + * 不传时渲染与改动前完全一致,策划输入盒、首页、画布都不受影响。 + */ + strengthSection?: ReactNode; }) { const initialCatalog = cachedLlmModelCatalog(); const [models, setModels] = useState( @@ -118,11 +125,9 @@ export function ConversationModelSelect({ }, [onReady]); const onUnavailableRef = useRef(onUnavailable); - const onModelLabelChangeRef = useRef(onModelLabelChange); useEffect(() => { onUnavailableRef.current = onUnavailable; - onModelLabelChangeRef.current = onModelLabelChange; - }, [onModelLabelChange, onUnavailable]); + }, [onUnavailable]); /** 已经报出去的失败消息:同一条只在「新出现」时报一次,失败清空后可以再报一次。 */ const reportedErrorRef = useRef(''); @@ -141,12 +146,6 @@ export function ConversationModelSelect({ onUnavailableRef.current?.(error); }, [error, errorPresentation]); - const selectedModelLabel = - models.find((model) => model.id === selected)?.displayName ?? ''; - useEffect(() => { - onModelLabelChangeRef.current?.(selectedModelLabel); - }, [selectedModelLabel]); - useEffect(() => { mountedRef.current = true; return () => { @@ -433,6 +432,53 @@ export function ConversationModelSelect({ } } + const modelMenu = ( +
+ {busy && models.length === 0 ? ( + 正在读取模型 + ) : null} + {models.map((model) => ( + + ))} +
+ +
+ ); + return (
- + } /> { + it('puts the reasoning strength inside the model menu and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ select_game_creator_reasoning_effort: (args) => { @@ -1047,32 +1047,43 @@ export function registerChatComposerControlTests() { }, }); - // 触发钮固定文案「选择强度」:当前档位只在 aria-label / title 与浮层里出现。 - const trigger = within(surface).getByRole('button', { name: /选择强度/ }); - expect(trigger.textContent).toContain('选择强度'); - await waitFor(() => { - expect(trigger.getAttribute('aria-label')).toContain('当前 高'); - }); - expect(trigger.getAttribute('title')).toContain('当前:高'); - // 触发钮就在模型选择器这一排(同一控制排容器里),关闭态没有滑块。 + // 底行只留一颗控件:模型触发钮;独立的「选择强度 / 推理档」触发钮不再存在。 + const row = surface.querySelector( + '.project-chat-composer-controls-right', + ) as HTMLElement | null; + expect(row).not.toBeNull(); expect( - within(surface).getByRole('button', { name: '对话模型' }), - ).not.toBeNull(); + within(row as HTMLElement).queryAllByRole('button', { + name: /选择强度|推理档/, + }), + ).toHaveLength(0); + const trigger = within(row as HTMLElement).getByRole('button', { + name: '对话模型', + }); expect(trigger.closest('.project-chat-composer-controls')).not.toBeNull(); + // 关闭态既没有滑块,也没有菜单。 expect( within(surface).queryByRole('slider', { name: '思维强度' }), ).toBeNull(); - // 点开 popover:滑块报出 role/值域与中文档位名。 + // 打开模型菜单:强度区在顶部,同一个菜单里还有模型列表。 fireEvent.click(trigger); const slider = within(surface).getByRole('slider', { name: '思维强度', }) as HTMLInputElement; + // 强度区随菜单挂载后才去读一次配置(`read_game_creator_app_config`),等回读到位。 + await waitFor(() => { + expect(slider.getAttribute('aria-valuetext')).toBe('高'); + }); expect(slider.getAttribute('aria-valuemin')).toBe('0'); expect(slider.getAttribute('aria-valuemax')).toBe('4'); expect(slider.getAttribute('aria-valuenow')).toBe('3'); - expect(slider.getAttribute('aria-valuetext')).toBe('高'); expect(within(surface).getByText('当前:高')).not.toBeNull(); + const listbox = within(surface).getByRole('listbox', { + name: '对话模型', + }); + // 滑块不能落进 listbox 子树:参数控件混进菜单角色会让读屏进菜单模式后读不到它。 + expect(listbox.contains(slider)).toBe(false); // 拖动 / 点档位走原生 range 的 change,落到与旧下拉同一条持久化通道与取值。 fireEvent.change(slider, { target: { value: '1' } }); @@ -1082,13 +1093,9 @@ export function registerChatComposerControlTests() { { effort: 'low' }, ); }); - // 以落盘后的回读值为准;改档后浮层保持打开(连续调节不用重开)。 + // 以落盘后的回读值为准;改档后菜单保持打开(连续调节不用重开)。 await waitFor(() => { - expect( - within(surface) - .getByRole('button', { name: /选择强度/ }) - .getAttribute('aria-label'), - ).toContain('当前 低'); + expect(within(surface).getByText('当前:低')).not.toBeNull(); }); expect(stored).toBe('low'); expect( @@ -1101,14 +1108,16 @@ export function registerChatComposerControlTests() { ), ).toBe(false); - // Esc 关闭;再点开、点外部也关闭。 + // Esc 关掉整个菜单;再打开、点外部也关。 fireEvent.keyDown(document, { key: 'Escape' }); await waitFor(() => { expect( within(surface).queryByRole('slider', { name: '思维强度' }), ).toBeNull(); }); - fireEvent.click(within(surface).getByRole('button', { name: /选择强度/ })); + fireEvent.click( + within(row as HTMLElement).getByRole('button', { name: '对话模型' }), + ); expect( within(surface).getByRole('slider', { name: '思维强度' }), ).not.toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index f19d826ae..508999d0c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -309,6 +309,9 @@ export function registerDesignAgentSurfaceTests() { ).toContain('低'); }); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + // 策划面菜单保持原样:只有模型列表,没有 Direct 侧专属的强度滑块。 + expect(screen.getByRole('listbox', { name: '对话模型' })).not.toBeNull(); + expect(screen.queryByRole('slider', { name: '思维强度' })).toBeNull(); fireEvent.click(await screen.findByRole('option', { name: '快速' })); await waitFor(() => { expect(harness.invoke).toHaveBeenCalledWith('select_game_creator_model', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index b3d761ea9..8004a2abf 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5979,6 +5979,13 @@ export function registerProjectWorkbenchFoundationTests() { 'background: var(--platform-input-fill);', ); expect(composerRule?.[1]).not.toContain('border-top:'); + // 强度并入模型菜单后,底行不再有第二颗控件:独立触发钮与它自己的浮层整段删除, + // 换成模型菜单外壳(外壳定位 / 朝向与原来的模型菜单一致:贴触发钮右缘、向上展开)。 + expect(styles).not.toContain('project-chat-composer-strength-trigger'); + expect(styles).not.toContain('project-chat-composer-strength-popover'); + expect(styles).toMatch( + /\.conversation-model-menu-shell\s*\{[^}]*position:\s*absolute[^}]*bottom:\s*calc\(100% \+ 8px\)/s, + ); expect(styles).toMatch( /\.game-workbench-chat \.pending-command-actions button\s*\{[^}]*min-width:\s*52px[^}]*flex:\s*0 0 auto/s, ); diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index 436d1ce3d..31668b9ad 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -331,29 +331,38 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 expect(paddingBottom).toBeLessThan(total); }); - it('思维强度:触发钮留在工具条同一排,档位收进 popover 的滑块(宽屏)', () => { + it('思维强度并入模型菜单:外壳向上展开、强度区在菜单顶部(宽屏)', () => { const trigger = desktopDeclarations( - `${COMPOSER} .project-chat-composer-strength-trigger`, COMPOSER_TRIGGER, COMPOSER_TRIGGER_BASE, ); - const anchor = desktopDeclarations( - `${COMPOSER} .project-chat-composer-strength`, + const select = desktopDeclarations( + `${COMPOSER_CONTROLS} .conversation-model-select`, ); - const popover = desktopDeclarations( - `${COMPOSER} .project-chat-composer-strength-popover`, + const shell = desktopDeclarations( + `${COMPOSER} .conversation-model-menu-shell`, + ); + const menuInShell = desktopDeclarations( + `${COMPOSER} .conversation-model-menu-shell .conversation-model-menu`, + ); + const section = desktopDeclarations( + `${COMPOSER} .project-chat-reasoning-effort.is-section`, ); const range = desktopDeclarations( `${COMPOSER} .project-chat-composer-strength-range`, ); - // 触发钮沿用工具条那颗 30px 控件的高度和类:工具条行高仍是 30(见上面的算式)。 - expect(pixelValue(trigger, 'height')).toBe(30); - // 浮层锚在强度控件上、从工具条上方展开,不占工具条宽度;滑块占满浮层宽度。 - expect(declaration(anchor, 'position')).toBe('relative'); - expect(declaration(popover, 'position')).toBe('absolute'); - expect(declaration(popover, 'bottom')).toBe('calc(100% + 8px)'); + // 弹层锚在模型控件上、从工具条上方展开(与原来的模型菜单同一朝向,不盖输入框)。 + expect(declaration(select, 'position')).toContain('relative'); + expect(declaration(shell, 'position')).toBe('absolute'); + expect(declaration(shell, 'bottom')).toBe('calc(100% + 8px)'); + expect(declaration(shell, 'right')).toBe('0'); + // 里层菜单回到文档流(位置 / 描边 / 底色归外壳),强度区是菜单顶部的一块网格,滑块占满宽度。 + expect(declaration(menuInShell, 'position')).toBe('static'); + expect(declaration(section, 'display')).toBe('grid'); expect(declaration(range, 'width')).toBe('100%'); + // 底行没有第二颗强度控件:那一排的高度仍由模型触发钮(30px)决定,见上面的算式。 + expect(pixelValue(trigger, 'height')).toBe(30); }); it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => { diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6c17a0e97..1bcc9dbbb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -10,13 +10,15 @@ - **判据/取证**:`tests/appSurface/chat-composer.suite.ts`——不可用时产出 toast,且输入区子树里既没有 `[role='alert']` 也没有旧文案;模型失败只报一次、清空后再出现才再报。 - **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。 -## 2026-10-04 输入盒的档位选择用 popover + 滑块,触发钮只占一颗控件宽 +## 2026-10-04 输入盒的强度选择并入模型菜单,底行只留一颗控件 -- 触发钮固定文案「选择强度」,当前档位只出现在 `aria-label` / `title` 与浮层里的「当前:最高」;滑块直接用原生 ``:`role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End 都是原生行为,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 -- 档位枚举、默认值、持久化通道(读 `read_game_creator_app_config`、写 `select_game_creator_reasoning_effort`)与旧下拉**完全一致**,只换表现形式;`ComposerReasoningEffortSelect` 的 `presentation` 默认 `'dropdown'`,策划输入盒继续走下拉(零语义变化,差分审计仍为 0 处声明变化)。 -- 点档位后浮层**保持打开**(连续调节不用重开),关闭走 Esc 与点击外部;浮层几何沿用 `.project-chat-attachment-menu` 同一套约定,颜色只取 `--platform-*`。 -- **判据/取证**:`tests/appSurface/chat-composer.suite.ts` 的滑块用例(打开 / 取值 / 持久化 / 关闭路径)+ `tests/chatDialogFrameLayout.test.ts` 的浮层几何断言。 -- **关联**:`view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx`、`src/styles.css`。 +- 目标口径(用户复核):**减少底部空间占用** —— 底行只能有模型触发钮一颗控件。思维强度不再单独占位,而是作为**强度区嵌在模型菜单顶部**(标题「思维强度」+「当前:最高」+ 原生 `` + 5 个档位点),与模型列表共用同一张卡(`.conversation-model-menu-shell`,向上展开、贴触发钮右缘,里层 `.conversation-model-menu` 回到文档流只负责滚动)。 +- **滑块不能放进 `role="listbox"` 子树**:参数控件混进菜单角色会让读屏进菜单模式后读不到它,所以外壳是普通容器(强度区 + listbox 并列),只能靠 `strengthSection` 这个显式 prop 由宿主注入。 +- 档位枚举、默认值、持久化通道(读 `read_game_creator_app_config`、写 `select_game_creator_reasoning_effort`)与旧下拉**完全一致**;`ComposerReasoningEffortSelect` 的 `presentation` 默认 `'dropdown'`(策划输入盒继续用,零语义变化),Direct 用 `'section'`;`ConversationModelSelect` 不传 `strengthSection` 时渲染与改动前逐字一致(首页 / 策划 / 画布不受影响)。 +- 关掉旧的独立触发钮(底行元素 2 → 1);浮层的开合、Esc 与点击外部关闭都由模型菜单既有行为负责,强度区不再自带浮层。 +- **取舍**:强度区随菜单挂载,打开时先显示「默认」再回读成落盘档位(一次 IPC 往返),测试里要 `waitFor`;换掉这一点需要把档位状态提到宿主,暂不做。 +- **判据/取证**:`tests/appSurface/chat-composer.suite.ts`(底行不再有第二颗控件、菜单里改档落同一条持久化通道、Esc/点外部关闭、滑块不在 listbox 内)+ `tests/chatDialogFrameLayout.test.ts`(外壳向上展开 / 强度区网格 / 底行高度不增)+ `tests/appSurface/design-agent.suite.ts`(策划菜单里没有滑块)。 +- **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。 ## 2026-10-03 AGC 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位 From 3383c13bba9014144fdc5340df9f2d1d5b70c215 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 00:34:59 +0800 Subject: [PATCH 08/53] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E8=A7=86=E8=A7=89=E9=87=8D=E5=81=9A=EF=BC=9A=E7=85=A7=20Codex?= =?UTF-8?q?=20=E8=A7=82=E6=84=9F=20+=20=E6=88=91=E4=BB=AC=E7=9A=84?= =?UTF-8?q?=E6=9A=96=E8=89=B2=20token=EF=BC=88=E5=A4=8D=E6=A0=B8=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=202=20=E4=B9=8B=E4=BA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 卡片:圆角 12 → 16px、内边距 12px、描边换成极淡 `--platform-line-soft`、投影改暖色 `rgb(112 57 30 / …)`(不用纯黑);去掉横贯分隔线,强度区与列表之间只用留白分开 - 强度区:标题 `--platform-text-strong` 12/600、右侧「当前:X」`--platform-text-soft` 11px;单列铺满(`minmax(0,1fr)`,否则 `auto` 轨道只按 max-content 定尺、滑块被压回 129px);滑块与档位点包一层相对定位容器,点改绝对定位叠在轨道中线 - 滑块:轨道 6px 圆头,已选段 `--platform-accent`、未选段 `--platform-subpanel-border`(进度由组件写进来的 `--strength-progress` 驱动双色渐变,不写死颜色);圆钮 18 → 20px(`--platform-input-highlight` 底 + `--platform-accent` 2px 描边 + 柔影),hover/active/focus-visible 与 Firefox 伪元素同步 - 档位点:5px 淡点 → 7px 且落在轨道中线上(带输入区底色的 1.5px 描边提对比),`pointer-events: none` 不挡命中 - 模型列表:行高 34px、行圆角 9px、hover `--platform-nav-item-icon-fill`、选中 `--platform-warm-bg` 轻量 pill(覆盖前文那几条 `!important` 同属性规则);`max-height: 170px`(=5×34,不出现半行);滚动条 6px、平时透明,悬停卡片才显形 - 校验:typecheck、4 个 vitest 文件 212 passed / 9 skipped、eslint、prettier、encoding、spacetime-schema、diff-check 全通过;无头 Chromium 夹具(447px / 900px)截图为证(见 PR 正文) --- apps/ai-game-creator-shell/src/styles.css | 201 ++++++++++++++---- .../ComposerControls.tsx | 70 +++--- docs/project-memory/shared-memory/pitfalls.md | 3 + 3 files changed, 203 insertions(+), 71 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index fdbfe9e32..31fc74cd2 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -13471,11 +13471,11 @@ details.design-agent-reasoning[open] 队列 chip 与提示段落的 `margin` 都写回前文各自那条规则。 */ /* ④ 思维强度**并入模型菜单**:底行只留模型触发钮一颗控件,强度区嵌在菜单顶部。 - 菜单外壳(`.conversation-model-menu-shell`)沿用原来模型菜单的定位 / 描边 / 底色 / - 阴影(向上展开、贴触发钮右缘),强度和模型列表共用同一张卡;强度区里是原生 - ``,自带 `role="slider"`、`aria-valuemin/max/now` 与 - ←/→/Home/End,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 - 颜色全部走 `--platform-*`(不引入新色值)。 */ + 菜单外壳(`.conversation-model-menu-shell`)沿用原来模型菜单的锚点与朝向(贴触发钮 + 右缘、向上展开、距输入区 8px),观感按 Codex 参考图重做:大圆角、松内边距、柔和阴影 + + 极淡描边,颜色全部取 `--platform-*` 暖色 token(不引入新色值)。 + 强度区里是原生 ``,自带 `role="slider"`、`aria-valuemin/max/now` + 与 ←/→/Home/End,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -13484,20 +13484,24 @@ details.design-agent-reasoning[open] right: 0; bottom: calc(100% + 8px); z-index: 20; - display: grid; + display: flex; + flex-direction: column; + gap: 6px; width: max-content; - min-width: 216px; + min-width: 260px; max-width: min(320px, calc(100vw - 24px)); - border: 1px solid var(--platform-surface-border); - border-radius: 12px; + padding: 12px; + border: 1px solid var(--platform-line-soft); + border-radius: 16px; background: var(--platform-input-fill); box-shadow: - 0 14px 30px rgb(31 41 55 / 13%), - 0 2px 6px rgb(31 41 55 / 5%); + 0 18px 40px rgb(112 57 30 / 14%), + 0 2px 8px rgb(112 57 30 / 6%); pointer-events: auto; } -/* 里层菜单回到文档流:外壳已经有位置 / 描边 / 底色,列表只负责滚动与截断。 */ +/* 里层菜单回到文档流:外壳已经有位置 / 描边 / 底色,列表只负责滚动与截断。 + 行高更舒展(34px)、行圆角 9px、滚动条细且平时透明(悬停卡片才显形)。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -13507,12 +13511,90 @@ details.design-agent-reasoning[open] width: 100%; min-width: 0; max-width: none; - max-height: 260px; + /* 170 = 5 行 × 34px:不会停在半行上;再长就滚动(细滚动条,悬停显形)。 */ + max-height: 170px; overflow: auto; + padding: 0; border: 0; - border-radius: 0; + border-radius: 12px; background: transparent; box-shadow: none; + scrollbar-width: thin; + scrollbar-color: var(--platform-subpanel-border) transparent; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .conversation-model-menu-shell + .conversation-model-menu::-webkit-scrollbar { + width: 6px; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .conversation-model-menu-shell + .conversation-model-menu::-webkit-scrollbar-track { + background: transparent; +} + +/* 静止时不画滚动条,鼠标进卡片才显形("细 + 自动隐藏")。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .conversation-model-menu-shell + .conversation-model-menu::-webkit-scrollbar-thumb { + border-radius: 999px; + background: transparent; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .conversation-model-menu-shell:hover + .conversation-model-menu::-webkit-scrollbar-thumb { + background: var(--platform-subpanel-border); +} + +/* 行:34px 高、9px 圆角、hover 极淡暖底,选中是轻量 pill(不做重色块)。 + 前文那几条 `!important` 的同属性规则在这里按同一档 `!important` + 更高特异性盖回。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .conversation-model-menu-shell + .conversation-model-menu + button { + min-height: 34px !important; + padding: 0 10px !important; + border-radius: 9px !important; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .conversation-model-menu-shell + .conversation-model-menu + button:hover:not(:disabled), +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .conversation-model-menu-shell + .conversation-model-menu + button:focus-visible { + background: var(--platform-nav-item-icon-fill) !important; + color: var(--platform-text-strong) !important; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .conversation-model-menu-shell + .conversation-model-menu + button[aria-selected='true'] { + background: var(--platform-warm-bg) !important; + color: var(--platform-text-strong) !important; + font-weight: 600 !important; } /* 列表宽度由外壳限定,超长模型名走省略号(原来靠 `width: max-content` 不截断)。 */ @@ -13536,16 +13618,30 @@ details.design-agent-reasoning[open] white-space: nowrap; } -/* 强度区:菜单顶部、与模型列表用一条分隔线分开(分隔线是最后一个子属性的 - `border-bottom`,列表首项自带 padding,不会显得挤)。 */ +/* 强度区:菜单顶部,与列表之间只用留白分开(不画横贯分隔线),标题左右层级分明。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-reasoning-effort.is-section { display: grid; - gap: 6px; - padding: 9px 9px 7px; - border-bottom: 1px solid var(--platform-line-soft); + /* 单列铺满:`auto` 轨道在这里只按 max-content 定尺,滑块会被压回固有宽度。 */ + grid-template-columns: minmax(0, 1fr); + justify-items: stretch; + width: 100%; + align-self: stretch; + gap: 10px; + padding: 4px 6px 2px; +} + +/* 滑块与档位点是一个整体:点绝对定位叠在轨道中线上(不受字号 / 行高变化影响)。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-strength-slider { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + width: 100%; } .game-workbench-chat @@ -13553,7 +13649,7 @@ details.design-agent-reasoning[open] .project-chat-composer.is-direct-codex .project-chat-composer-strength-head { display: flex; - align-items: center; + align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; @@ -13563,9 +13659,9 @@ details.design-agent-reasoning[open] .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-strength-title { - color: var(--platform-text-base); + color: var(--platform-text-strong); font-size: 12px; - font-weight: 700; + font-weight: 600; } .game-workbench-chat @@ -13573,7 +13669,7 @@ details.design-agent-reasoning[open] .project-chat-composer.is-direct-codex .project-chat-composer-strength-range { width: 100%; - height: 24px; + height: 26px; margin: 0; appearance: none; border: 0; @@ -13587,21 +13683,26 @@ details.design-agent-reasoning[open] .project-chat-composer-strength-range::-webkit-slider-runnable-track { height: 6px; border-radius: 999px; - background: var(--platform-button-ghost-fill); + /* 已选段用主色、未选段用暖灰:进度由组件写进来的 `--strength-progress` 决定。 */ + background: linear-gradient( + to right, + var(--platform-accent) 0 var(--strength-progress, 0%), + var(--platform-subpanel-border) var(--strength-progress, 0%) 100% + ); } .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-strength-range::-webkit-slider-thumb { - width: 18px; - height: 18px; - margin-top: -6px; + width: 20px; + height: 20px; + margin-top: -7px; appearance: none; - border: 2px solid var(--platform-button-primary-fill); + border: 2px solid var(--platform-accent); border-radius: 999px; - background: var(--platform-input-fill); - box-shadow: 0 1px 2px rgb(0 0 0 / 18%); + background: var(--platform-input-highlight); + box-shadow: 0 2px 6px rgb(112 57 30 / 22%); } .game-workbench-chat @@ -13651,19 +13752,28 @@ details.design-agent-reasoning[open] .project-chat-composer-strength-range::-moz-range-track { height: 6px; border-radius: 999px; - background: var(--platform-button-ghost-fill); + background: var(--platform-subpanel-border); +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-strength-range::-moz-range-progress { + height: 6px; + border-radius: 999px; + background: var(--platform-accent); } .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-strength-range::-moz-range-thumb { - width: 18px; - height: 18px; - border: 2px solid var(--platform-button-primary-fill); + width: 20px; + height: 20px; + border: 2px solid var(--platform-accent); border-radius: 999px; - background: var(--platform-input-fill); - box-shadow: 0 1px 2px rgb(0 0 0 / 18%); + background: var(--platform-input-highlight); + box-shadow: 0 2px 6px rgb(112 57 30 / 22%); } /* 档位点:纯装饰(命中与吸附由原生 range 负责),个数跟着档位契约走, @@ -13672,11 +13782,17 @@ details.design-agent-reasoning[open] .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-strength-stops { + position: absolute; + top: 50%; + right: 0; + left: 0; display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; + align-items: center; justify-items: center; - margin-top: -12px; + height: 6px; + transform: translateY(-50%); pointer-events: none; } @@ -13685,10 +13801,11 @@ details.design-agent-reasoning[open] .project-chat-composer.is-direct-codex .project-chat-composer-strength-stops span { - width: 5px; - height: 5px; + width: 7px; + height: 7px; + border: 1.5px solid var(--platform-input-highlight); border-radius: 999px; - background: var(--platform-surface-border); + background: var(--platform-text-muted); } .game-workbench-chat @@ -13696,7 +13813,7 @@ details.design-agent-reasoning[open] .project-chat-composer.is-direct-codex .project-chat-composer-strength-stops span.is-active { - background: var(--platform-button-primary-fill); + background: var(--platform-accent); } .game-workbench-chat @@ -13704,6 +13821,6 @@ details.design-agent-reasoning[open] .project-chat-composer.is-direct-codex .project-chat-composer-strength-value { color: var(--platform-text-soft); - font-size: 12px; + font-size: 11px; text-align: right; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index ef71def98..31dcecd5a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -17,7 +17,7 @@ import { Square, X, } from 'lucide-react'; -import type { RefObject } from 'react'; +import type { CSSProperties, RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -476,35 +476,47 @@ export function ComposerReasoningEffortSelect({ {`当前:${activeLabel}`} - {/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是 - 中文档位名,读屏念的是「最高」而不是「4」。 */} - { - const next = options[Number(event.currentTarget.value)]; - if (next && next.value !== effort) { - selectEffort(next.value); + + {/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是 + 中文档位名,读屏念的是「最高」而不是「4」。 */} + -
+ {formatPriceMudPoints(entry.priceMudPoints)} {formatAdminDateTime(entry.createdAt)} @@ -629,6 +636,10 @@ function AdminGameDistributionReviewDetailView({ versionLabel={`v${detail.version.versionNumber}`} infoCards={[ { label: '状态', value: detail.version.status }, + { + label: '价格', + value: formatPriceMudPoints(detail.game.priceMudPoints), + }, { label: '提交时间', value: formatAdminDateTime(detail.version.createdAt), diff --git a/apps/admin-web/src/pages/adminGameDistributionReview.css b/apps/admin-web/src/pages/adminGameDistributionReview.css index 2eed66619..28e6ef8cd 100644 --- a/apps/admin-web/src/pages/adminGameDistributionReview.css +++ b/apps/admin-web/src/pages/adminGameDistributionReview.css @@ -27,14 +27,21 @@ table.admin-game-review-table td:nth-child(3) { width: 124px; } +/* 价格列:只展示「免费」或「N 泥点」,窄列即可。 */ table.admin-game-review-table th:nth-child(4), table.admin-game-review-table td:nth-child(4) { - width: 172px; + width: 96px; white-space: nowrap; } table.admin-game-review-table th:nth-child(5), table.admin-game-review-table td:nth-child(5) { + width: 172px; + white-space: nowrap; +} + +table.admin-game-review-table th:nth-child(6), +table.admin-game-review-table td:nth-child(6) { width: 240px; } @@ -100,7 +107,9 @@ table.admin-game-review-table td .admin-muted-text { table.admin-game-review-table th:nth-child(4), table.admin-game-review-table td:nth-child(4), table.admin-game-review-table th:nth-child(5), - table.admin-game-review-table td:nth-child(5) { + table.admin-game-review-table td:nth-child(5), + table.admin-game-review-table th:nth-child(6), + table.admin-game-review-table td:nth-child(6) { width: auto; } } From d27e204f21c7094f4d946f0a2be8ccfa820f7265 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 16:49:52 +0800 Subject: [PATCH 17/53] =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E6=94=B9=E7=94=A8=E5=BE=85=E5=AE=A1=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=86=BB=E7=BB=93=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - applyFrozenGameMetadata 叠加冻结资料中的数值型 priceMudPoints:价格随版本冻结,审核员要看到待审版本将要生效的价格,而不是游戏行当前已公开价格 - 冻结资料缺 priceMudPoints 或为旧响应时保留 game.priceMudPoints,详情资料区仍渲染 detail.game.priceMudPoints - 新增两条详情用例:游戏行 0 泥点但冻结 30 泥点时展示「30 泥点」;旧版本冻结资料无价格时回落「免费」且不出现 NaN --- .../AdminGameDistributionReviewPage.test.tsx | 54 +++++++++++++++++++ .../pages/AdminGameDistributionReviewPage.tsx | 5 ++ 2 files changed, 59 insertions(+) diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index b3d0be77c..dda1e4022 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -17,6 +17,7 @@ import { reviewAdminGameDistributionVersion, } from '../api/adminApiClient'; import type { + AdminGameDistributionGameDetail, AdminGameDistributionGameListResponse, AdminGameDistributionReviewEntry, AdminGameDistributionVersionDetailResponse, @@ -365,3 +366,56 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async ), ); }); + +test('详情优先展示待审版本冻结价格,而不是游戏行当前价格', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({ + // 游戏行当前是免费,待审版本冻结为 30 泥点:审核员要看到将要生效的价格。 + game: { ...detailResponse.game, priceMudPoints: 0 }, + version: { + ...detailResponse.version, + frozenMetadata: { title: '测试游戏', priceMudPoints: 30 }, + }, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + fireEvent.click(screen.getByRole('button', { name: '详情' })); + expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0); + + expect(screen.getByText('价格')).toBeTruthy(); + expect(screen.getByText('30 泥点')).toBeTruthy(); + expect(screen.queryByText('免费')).toBeNull(); +}); + +test('旧版本冻结资料没有价格时按免费回落', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({ + game: { + ...detailResponse.game, + priceMudPoints: undefined, + } as unknown as AdminGameDistributionGameDetail, + version: { + ...detailResponse.version, + frozenMetadata: { title: '测试游戏' }, + }, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + fireEvent.click(screen.getByRole('button', { name: '详情' })); + expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0); + + expect(screen.getByText('免费')).toBeTruthy(); + expect(screen.queryByText(/NaN/u)).toBeNull(); +}); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 4fc7782b8..46fb377cb 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -98,6 +98,11 @@ function applyFrozenGameMetadata( typeof metadata.orientation === 'string' ? metadata.orientation : game.orientation, + // 价格随版本冻结:审核页要展示待审版本将要生效的价格,而不是游戏行当前已公开价格。 + priceMudPoints: + typeof metadata.priceMudPoints === 'number' + ? metadata.priceMudPoints + : game.priceMudPoints, }; } From 7b38ef9623586f8ace8818f2cab7b35ac4d82803 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 16:59:45 +0800 Subject: [PATCH 18/53] =?UTF-8?q?=E7=BD=91=E9=A1=B5=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=B8=B8=E6=88=8F=E4=B9=B0=E6=96=AD=E5=88=B6=E6=B3=A5?= =?UTF-8?q?=E7=82=B9=E4=BB=98=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/shared 游戏契约补买断价与购买态:GameDistributionGame 增 priceMudPoints/purchased,版本摘要 entryUrl 可为 null - packages/shared 版本请求增 priceMudPoints,私有版本回读增同名字段;新增购买请求、购买记录、购买响应与播放会话 DTO - packages/shared 钱包流水来源类型增 game_purchase,网页与共享账单文案增「购买游戏」标签 - 游戏分发客户端新增 purchaseGame(Idempotency-Key + 期望价格)与 createGamePlaySession - 发布页新增「付费方式」免费/买断制表单,定价校验与服务端 0..=1000000 口径一致,价格随版本请求提交 - 详情页展示定价、付费未购买时主动作改为「N 泥点购买」,复用泥点确认弹窗展示余额,成功后刷新钱包并回到可玩状态,余额不足给出充值入口 - 游玩页付费作品先签发播放会话再挂载 iframe,免费作品链路不变;会话被拒时不挂载 iframe 也不上报游玩 - 发行入口守卫接受播放会话前缀路径,拒绝其它非发行路径 - 定向契约测试登记 4 组购买/播放会话 DTO,补发布定价、未购买不可玩、购买确认与播放会话挂载用例 --- .../PlatformProfileWalletLedgerModal/model.ts | 1 + .../shared/src/contracts/gameDistribution.ts | 50 ++- packages/shared/src/contracts/runtime.ts | 3 +- .../check-game-distribution-dto-parity.mjs | 4 + .../game-distribution/GameDetailPage.tsx | 297 +++++++++++++++--- .../GameDistributionPages.test.tsx | 201 ++++++++++++ .../game-distribution/GamePlayPage.tsx | 103 +++++- .../GamePublishPage.test.tsx | 162 +++++++++- .../game-distribution/GamePublishPage.tsx | 76 ++++- .../gameDistributionGuards.test.ts | 24 ++ .../gameDistributionGuards.ts | 12 +- .../game-distribution/gamePublishMetadata.ts | 46 +++ .../PlatformEntryActiveFlowShell.tsx | 1 + .../platformProfileFundsModel.ts | 1 + src/services/gameDistributionClient.ts | 48 ++- 15 files changed, 972 insertions(+), 57 deletions(-) diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index a18adce79..514c40987 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -20,6 +20,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = { redeem_code_reward: '兑换码奖励', puzzle_author_incentive_claim: '拼图作者奖励', daily_task_reward: '每日任务奖励', + game_purchase: '购买游戏', } satisfies Record; export type ProfileWalletLedgerEntryPresentation = { diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index f7803ebdb..be4f27506 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -104,8 +104,10 @@ export type GameDistributionVersionSummary = { /** * 发行入口:平台同源路径 `/games//`,客户端按当前 origin 解析后再交给 iframe。 * 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。 + * + * 买断制作品在未购买时不下发公开入口,这一项为 `null`;游玩入口改由播放会话接口签发。 */ - entryUrl: string; + entryUrl: string | null; sha256: string; publishedAt: string; controls: string[]; @@ -141,6 +143,10 @@ export type GameDistributionGame = { currentVersion?: GameDistributionVersionSummary | null; /** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */ ratingSummary?: GameDistributionRatingSummary; + /** 买断价(整数泥点,`0` 表示免费)。旧响应可省略,读取方按 `0`(免费)兜底。 */ + priceMudPoints: number; + /** 当前查看者是否已拥有;匿名与未登录恒为 `false`。 */ + purchased: boolean; playCount: number; createdAt: string; }; @@ -176,6 +182,13 @@ export type GameDistributionCreateVersionRequest = { * 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。 */ versionNumber?: number | null; + /** + * 作者提交的买断价(整数泥点,`0` 表示免费)。 + * + * 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。 + * 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。 + */ + priceMudPoints?: number; packageSha256: string; packageBytes: number; packageFileCount: number; @@ -214,6 +227,8 @@ export type GameDistributionPrivateVersion = { status: GameDistributionVersionStatus; /** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */ publicationRevision: number; + /** 该版本冻结的买断价(整数泥点,`0` 表示免费);无价格的历史版本按免费口径处理。 */ + priceMudPoints?: number; reviewReason?: string | null; /** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */ entryUrl: string | null; @@ -276,3 +291,36 @@ export type GameDistributionCancelVersionResponse = { version: GameDistributionPrivateVersion; replayed: boolean; }; + +/** + * 买断制购买请求体。 + * + * 客户端带上自己看到的价格:与服务端当前价格不一致时返回 409,不会按旧价扣费。 + */ +export type GameDistributionPurchaseRequest = { + expectedPriceMudPoints: number; +}; + +/** 购买记录快照:每账号每作品最多一条,成交价随购买冻结,之后调价不影响既有所有权。 */ +export type GameDistributionPurchase = { + purchaseId: string; + gameId: string; + priceMudPoints: number; + createdAt: string; +}; + +/** 购买响应:`walletBalance` 是扣费后的泥点余额,`replayed` 表示服务端按同一幂等键重放。 */ +export type GameDistributionPurchaseResponse = { + purchase: GameDistributionPurchase; + walletBalance: number; + replayed: boolean; +}; + +/** + * 播放会话响应:`playUrl` 是绑定「`gameId` + 用户 + 短时效」的发行入口。 + * 结算入口地址后交给 iframe,会话过期或作品下架后不可继续播放。 + */ +export type GameDistributionPlaySessionResponse = { + playUrl: string; + expiresAt: string; +}; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index eee7978ed..033d5b171 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -82,7 +82,8 @@ export type ProfileWalletLedgerEntry = { | 'recharge_refund_recovery' | 'redeem_code_reward' | 'puzzle_author_incentive_claim' - | 'daily_task_reward'; + | 'daily_task_reward' + | 'game_purchase'; createdAt: string; reason?: string; }; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index a149af079..0a05d36c5 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -54,6 +54,10 @@ const PAIRS = [ 'GameDistributionUpdateGameMetadataRequest', ], ['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'], + ['GameDistributionPurchaseRequest', 'GameDistributionPurchaseRequest'], + ['GameDistributionPurchase', 'GameDistributionPurchase'], + ['GameDistributionPurchaseResponse', 'GameDistributionPurchaseResponse'], + ['GameDistributionPlaySessionResponse', 'GameDistributionPlaySessionResponse'], ['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'], ['AdminGameReviewGame', 'AdminGameReviewGame'], ['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'], diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 1fee3520d..3efa3373b 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -12,6 +12,7 @@ import type { GameDistributionVersionSummary, } from '../../../packages/shared/src/contracts/gameDistribution'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { ApiClientError } from '../../services/apiClient'; import { type GameDistributionGame, type GameDistributionInputMode, @@ -20,9 +21,12 @@ import { getGame, getMyGame, loadVersionRecoveryActions, + purchaseGame, } from '../../services/gameDistributionClient'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { normalizeGameEntryUrl, @@ -45,6 +49,11 @@ type GameDetailPageProps = { gameId: string | null; onBack: () => void; onPlay: (gameId: string) => void; + /** + * 打开平台充值面板;余额不足以购买付费作品时由宿主提供入口。 + * 宿主没有充值能力时(例如独立嵌入)传入空值,页面只保留余额提示。 + */ + onOpenRecharge?: () => void; /** * 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。 * @@ -55,6 +64,24 @@ type GameDetailPageProps = { ownerView?: boolean; }; +/** 购买后的页面级反馈:成功、余额不足(带充值入口)与失败共用一套展示。 */ +type GamePurchaseNotice = { + tone: 'info' | 'error'; + message: string; + canRecharge: boolean; +}; + +let purchaseKeySeed = 0; +/** 一次购买的幂等键:双击、响应丢失重试复用同一个键,服务端据此只扣一次。 */ +function createPurchaseKey() { + purchaseKeySeed += 1; + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${purchaseKeySeed}`; + return `web-game-purchase-${random}`; +} + /** * 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`), * 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。 @@ -105,6 +132,7 @@ export function GameDetailPage({ gameId, onBack, onPlay, + onOpenRecharge, ownerView = false, }: GameDetailPageProps) { const authUi = useAuthUi(); @@ -119,8 +147,34 @@ export function GameDetailPage({ >({}); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); + const [isPurchaseDialogOpen, setIsPurchaseDialogOpen] = useState(false); + const [isPurchasing, setIsPurchasing] = useState(false); + const [purchaseNotice, setPurchaseNotice] = + useState(null); const requestSerialRef = useRef(0); + const purchaseInFlightRef = useRef(false); + const purchaseKeyRef = useRef(null); const isMobileViewport = useIsMobileViewport(); + const mudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const legacyWalletBalance = usePlatformWalletStore( + (state) => state.legacyWalletBalance, + ); + const onWalletBalanceMayHaveChanged = usePlatformWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); + const walletBalance = + mudPointBalance?.totalPoints ?? legacyWalletBalance ?? null; + const balanceOwnerMatchesCurrentUser = usePlatformWalletStore( + (state) => state.ownerUserId === (authUi?.user?.id ?? null), + ); + // 换账号或换作品时立刻丢弃上一份购买反馈与在途幂等键,避免把结论带到别的作品上。 + useEffect(() => { + setPurchaseNotice(null); + setIsPurchaseDialogOpen(false); + purchaseKeyRef.current = null; + }, [gameId, ownerView, authUi?.user?.id]); const loadPublicGame = useCallback(() => { const requestSerial = ++requestSerialRef.current; @@ -136,20 +190,34 @@ export function GameDetailPage({ void getGame(normalizedGameId) .then((value) => { if (requestSerial !== requestSerialRef.current) return; - if (value?.currentVersion) { - const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl); - if (!entryUrl) { - setError('游戏入口地址无效'); - return; - } + if (!value) { + setPublicGame(null); + setError('找不到这款游戏'); + return; + } + const currentVersion = value.currentVersion ?? null; + if (!currentVersion) { + setPublicGame(value); + return; + } + const rawEntryUrl = currentVersion.entryUrl; + // 买断制作品在未购买时服务端不下发公开入口:保留空入口,主行动作改由购买接手。 + if (!rawEntryUrl) { setPublicGame({ ...value, - currentVersion: { ...value.currentVersion, entryUrl }, + currentVersion: { ...currentVersion, entryUrl: null }, }); return; } - setPublicGame(value); - if (!value) setError('找不到这款游戏'); + const entryUrl = normalizeGameEntryUrl(rawEntryUrl); + if (!entryUrl) { + setError('游戏入口地址无效'); + return; + } + setPublicGame({ + ...value, + currentVersion: { ...currentVersion, entryUrl }, + }); }) .catch((loadError: unknown) => { if (requestSerial !== requestSerialRef.current) return; @@ -221,6 +289,80 @@ export function GameDetailPage({ const resolvedGame = ownerView ? ownerGame : publicGame; const reload = ownerView ? loadOwnerGame : loadPublicGame; + /** + * 打开购买确认。未登录先走平台登录;幂等键在打开弹窗时生成, + * 同一次购买(含失败后重试)复用同一个键,避免重复扣费。 + */ + function requestPurchase() { + if (!authUi?.user) { + authUi?.openLoginModal(); + return; + } + purchaseKeyRef.current = createPurchaseKey(); + setPurchaseNotice(null); + setIsPurchaseDialogOpen(true); + } + + async function handleConfirmPurchase() { + if (!resolvedGame || purchaseInFlightRef.current) return; + const priceMudPoints = resolvedGame.priceMudPoints ?? 0; + if (priceMudPoints <= 0) return; + purchaseInFlightRef.current = true; + setIsPurchasing(true); + try { + if (!purchaseKeyRef.current) purchaseKeyRef.current = createPurchaseKey(); + await purchaseGame( + resolvedGame.id, + { expectedPriceMudPoints: priceMudPoints }, + purchaseKeyRef.current, + ); + purchaseKeyRef.current = null; + setIsPurchaseDialogOpen(false); + // 扣费成功先刷新平台钱包 store,再回读详情拿 `purchased` 与可播放入口。 + await onWalletBalanceMayHaveChanged(); + setPurchaseNotice({ + tone: 'info', + message: '购买成功,现在可以直接开始游玩。', + canRecharge: false, + }); + reload(); + } catch (purchaseError: unknown) { + purchaseKeyRef.current = null; + setIsPurchaseDialogOpen(false); + const insufficient = + purchaseError instanceof ApiClientError && + purchaseError.code === 'INSUFFICIENT_MUD_POINTS'; + const priceChanged = + purchaseError instanceof ApiClientError && purchaseError.status === 409; + if (insufficient) { + setPurchaseNotice({ + tone: 'error', + message: '泥点不足,充值后即可购买这款游戏。', + canRecharge: true, + }); + } else if (priceChanged) { + setPurchaseNotice({ + tone: 'error', + message: '价格已更新,已为你刷新最新价格,请重新确认购买。', + canRecharge: false, + }); + reload(); + } else { + setPurchaseNotice({ + tone: 'error', + message: + purchaseError instanceof Error && purchaseError.message.trim() + ? purchaseError.message + : '购买失败,请稍后重试。', + canRecharge: false, + }); + } + } finally { + purchaseInFlightRef.current = false; + setIsPurchasing(false); + } + } + if ( isLoading || (resolvedGame && resolvedGame.id !== normalizeGameId(gameId)) @@ -250,19 +392,45 @@ export function GameDetailPage({ ); const canReview = Boolean(authUi?.user && authUi.canAccessProtectedData); + const purchasePriceMudPoints = resolvedGame.priceMudPoints ?? 0; return ( - authUi?.openLoginModal()} - onBack={onBack} - onPlay={onPlay} - /> + <> + authUi?.openLoginModal()} + onBack={onBack} + onPlay={onPlay} + /> + { + if (isPurchasing) return; + setIsPurchaseDialogOpen(false); + }} + onConfirm={() => void handleConfirmPurchase()} + > +

+ 当前泥点余额: + {balanceOwnerMatchesCurrentUser && walletBalance !== null + ? `${walletBalance} 泥点` + : '读取中…'} +

+
+ ); } @@ -273,6 +441,10 @@ function GameDetailContent({ recoveryActions, isMobileViewport, canReview, + isPurchasing, + purchaseNotice, + onOpenPurchase, + onOpenRecharge, onLogin, onBack, onPlay, @@ -283,6 +455,10 @@ function GameDetailContent({ recoveryActions: Record; isMobileViewport: boolean; canReview: boolean; + isPurchasing: boolean; + purchaseNotice: GamePurchaseNotice | null; + onOpenPurchase: () => void; + onOpenRecharge?: () => void; onLogin: () => void; onBack: () => void; onPlay: (gameId: string) => void; @@ -317,7 +493,30 @@ function GameDetailContent({ : resolveNonPublicCoverLabel(game.status, latestVersion?.status); const latestReviewReason = latestVersion?.reviewReason?.trim() ?? ''; const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile; - const playbackBlocked = !version || mobilePlaybackBlocked; + /** + * 买断价与购买态:缺字段按免费/未购买兜底,付费且未购买时后端不下发可播放入口, + * 前端也不再展示「立即玩」,改由购买动作接手。 + */ + const priceMudPoints = game.priceMudPoints ?? 0; + const isPaidLocked = + !ownerView && priceMudPoints > 0 && game.purchased !== true; + const hasPlayableEntry = Boolean(version?.entryUrl); + /** + * 公开视角按「后端是否下发入口」判定可否开玩:付费未购买时入口为空。 + * 作者视角的版本条目不带公开入口,沿用「有已公开版本即可开玩」的既有口径。 + */ + const playbackBlocked = ownerView + ? !version || mobilePlaybackBlocked + : !hasPlayableEntry || mobilePlaybackBlocked; + const priceInfoCards = + typeof game.priceMudPoints === 'number' + ? [ + { + label: '定价', + value: game.priceMudPoints > 0 ? `${game.priceMudPoints} 泥点` : '免费', + }, + ] + : []; return (
+ ) : null} + + ) : null} {ownerView && ownerGame ? (
diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 4ecd4ebc3..b9d426f60 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -12,6 +12,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution'; +import { ApiClientError } from '../../services/apiClient'; import type { GameDistributionGame, GameDistributionMyGame, @@ -30,6 +31,8 @@ const recordGamePlayMock = vi.hoisted(() => vi.fn()); const resolvedAssetUrlMock = vi.hoisted(() => vi.fn()); const listGameReviewsMock = vi.hoisted(() => vi.fn()); const getMyGameReviewMock = vi.hoisted(() => vi.fn()); +const purchaseGameMock = vi.hoisted(() => vi.fn()); +const createGamePlaySessionMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/gameDistributionClient', () => ({ listGames: listGamesMock, @@ -39,6 +42,21 @@ vi.mock('../../services/gameDistributionClient', () => ({ recordGamePlay: recordGamePlayMock, listGameReviews: listGameReviewsMock, getMyGameReview: getMyGameReviewMock, + purchaseGame: purchaseGameMock, + createGamePlaySession: createGamePlaySessionMock, +})); + +// 详情页购买后要刷新平台钱包;这里只提供选择器需要的状态与刷新钩子,避免真实取余额请求。 +const walletMocks = vi.hoisted(() => ({ + ownerUserId: 'author-1', + mudPointBalance: { totalPoints: 500, permanentPoints: 500, limitedPoints: 0 }, + legacyWalletBalance: 500, + onWalletBalanceMayHaveChanged: vi.fn(async () => {}), +})); + +vi.mock('../../stores/usePlatformWalletStore', () => ({ + usePlatformWalletStore: (selector: (state: typeof walletMocks) => T) => + selector(walletMocks), })); // 作者视角详情页要按登录身份渲染评价区,这里固定一个可访问受保护数据的用户。 @@ -101,6 +119,8 @@ function createGame( }, playCount: 12, createdAt: '2026-09-18T00:00:00.000Z', + priceMudPoints: 0, + purchased: true, ...overrides, }; } @@ -143,6 +163,9 @@ beforeEach(() => { }); getMyGameReviewMock.mockResolvedValue({ review: null }); recordGamePlayMock.mockResolvedValue(undefined); + purchaseGameMock.mockReset(); + createGamePlaySessionMock.mockReset(); + walletMocks.onWalletBalanceMayHaveChanged.mockClear(); resolvedAssetUrlMock.mockImplementation((objectKey: string) => objectKey ? `https://assets.example.test/${objectKey}` : '', ); @@ -246,6 +269,120 @@ describe('GameDetailPage', () => { expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy(); }); + + function createLockedPaidGame(): GameDistributionGame { + return createGame({ + priceMudPoints: 120, + purchased: false, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }); + } + + it('付费未购买时展示价格与购买主动作,不再给出游玩入口', async () => { + getGameMock.mockResolvedValueOnce(createLockedPaidGame()); + render( + , + ); + + const buyButton = await screen.findByRole('button', { + name: /120 泥点购买/u, + }); + expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull(); + // 定价资料卡展示价格;入口为空是服务端口径,不是「入口地址无效」。 + expect(screen.getByText('120 泥点')).toBeTruthy(); + expect(screen.queryByText('游戏入口地址无效')).toBeNull(); + expect((buyButton as HTMLButtonElement).disabled).toBe(false); + }); + + it('购买走二次确认并展示余额,成功后刷新钱包并回到可玩状态', async () => { + getGameMock + .mockResolvedValueOnce(createLockedPaidGame()) + .mockResolvedValueOnce( + createGame({ priceMudPoints: 120, purchased: true }), + ); + purchaseGameMock.mockResolvedValue({ + purchase: { + purchaseId: 'gdpurchase_1', + gameId: 'game-1', + priceMudPoints: 120, + createdAt: '2026-10-05T00:00:00Z', + }, + walletBalance: 380, + replayed: false, + }); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: /120 泥点购买/u }), + ); + const dialog = await screen.findByRole('dialog', { + name: '确认消耗泥点', + }); + expect(within(dialog).getByText('消耗 120 泥点')).toBeTruthy(); + expect(within(dialog).getByText('当前泥点余额:500 泥点')).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '确认购买' })); + + await waitFor(() => expect(purchaseGameMock).toHaveBeenCalledTimes(1)); + expect(purchaseGameMock.mock.calls[0]?.[0]).toBe('game-1'); + expect(purchaseGameMock.mock.calls[0]?.[1]).toEqual({ + expectedPriceMudPoints: 120, + }); + // 幂等键必须非空,双击与重试才会被服务端按同一键重放。 + expect(String(purchaseGameMock.mock.calls[0]?.[2] ?? '')).not.toBe(''); + await waitFor(() => + expect(walletMocks.onWalletBalanceMayHaveChanged).toHaveBeenCalled(), + ); + expect( + await screen.findByText('购买成功,现在可以直接开始游玩。'), + ).toBeTruthy(); + expect( + await screen.findByRole('button', { name: /立即玩/u }), + ).toBeTruthy(); + }); + + it('余额不足时失败关闭并给出充值入口', async () => { + const onOpenRecharge = vi.fn(); + getGameMock.mockResolvedValue(createLockedPaidGame()); + purchaseGameMock.mockRejectedValueOnce( + new ApiClientError({ + message: '泥点余额不足', + status: 400, + code: 'INSUFFICIENT_MUD_POINTS', + }), + ); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: /120 泥点购买/u }), + ); + fireEvent.click( + await screen.findByRole('button', { name: '确认购买' }), + ); + + expect( + await screen.findByText('泥点不足,充值后即可购买这款游戏。'), + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '去充值' })); + expect(onOpenRecharge).toHaveBeenCalledTimes(1); + // 失败关闭:既没有游玩入口,也没有伪造成功。 + expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull(); + expect( + screen.queryByText('购买成功,现在可以直接开始游玩。'), + ).toBeNull(); + expect( + screen.getByRole('button', { name: /120 泥点购买/u }), + ).toBeTruthy(); + }); }); describe('GameDetailPage 作者视角', () => { @@ -478,6 +615,70 @@ describe('GamePlayPage', () => { expect(await screen.findByText('游戏入口地址无效')).toBeTruthy(); expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull(); }); + + it('免费游戏不请求播放会话', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + await screen.findByTitle('星轨防线 在线游玩'); + expect(createGamePlaySessionMock).not.toHaveBeenCalled(); + }); + + it('付费游戏先签发播放会话再挂载 iframe 并计数', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + priceMudPoints: 120, + purchased: true, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }), + ); + createGamePlaySessionMock.mockResolvedValue({ + playUrl: '/api/game-distribution/play-sessions/token-1/', + expiresAt: '2026-10-05T00:05:00Z', + }); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + + const iframe = await screen.findByTitle('星轨防线 在线游玩'); + expect(iframe.getAttribute('src')).toBe( + new URL( + '/api/game-distribution/play-sessions/token-1/', + window.location.origin, + ).href, + ); + expect(iframe.getAttribute('sandbox')).toBe('allow-scripts'); + expect(createGamePlaySessionMock).toHaveBeenCalledWith('game-1'); + expect(recordGamePlayMock).toHaveBeenCalledWith('game-1'); + }); + + it('未购买时播放会话被拒,不挂载 iframe 也不上报游玩', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + priceMudPoints: 120, + purchased: false, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }), + ); + createGamePlaySessionMock.mockRejectedValueOnce( + new ApiClientError({ + message: '尚未购买这款游戏', + status: 403, + code: 'FORBIDDEN', + }), + ); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + + expect( + await screen.findByText( + '这款游戏需要先购买才能游玩,请返回详情页完成购买。', + ), + ).toBeTruthy(); + expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull(); + expect(recordGamePlayMock).not.toHaveBeenCalled(); + }); }); describe('GameGalleryPage 筛选与返回上下文', () => { diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx index f4f1efebd..04cec5c31 100644 --- a/src/components/game-distribution/GamePlayPage.tsx +++ b/src/components/game-distribution/GamePlayPage.tsx @@ -9,8 +9,10 @@ import { } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiClientError } from '../../services/apiClient'; import { type GameDistributionGame, + createGamePlaySession, getGame, recordGamePlay, } from '../../services/gameDistributionClient'; @@ -29,7 +31,7 @@ type GamePlayPageProps = { gameId: string | null; onBack: () => void }; /** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */ export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000; -type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout'; +type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout' | 'failed'; export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const frameRef = useRef(null); @@ -38,6 +40,10 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const [startupState, setStartupState] = useState('idle'); const [hasStarted, setHasStarted] = useState(false); const [frameRunId, setFrameRunId] = useState(0); + /** 实际交给 iframe 的发行入口:免费游戏来自详情,付费游戏来自播放会话。 */ + const [playUrl, setPlayUrl] = useState(null); + const [isPreparingPlayback, setIsPreparingPlayback] = useState(false); + const [playError, setPlayError] = useState(''); const [error, setError] = useState(''); const requestSerialRef = useRef(0); const isMobileViewport = useIsMobileViewport(); @@ -49,6 +55,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { setGame(null); setHasStarted(false); setStartupState('idle'); + setPlayUrl(null); + setPlayError(''); setError(''); if (!normalizedGameId) { setError('缺少游戏编号'); @@ -59,14 +67,25 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { void getGame(normalizedGameId) .then((value) => { if (requestSerial !== requestSerialRef.current) return; - if (!value?.currentVersion?.entryUrl) { + const currentVersion = value?.currentVersion; + if (!value || !currentVersion) { throw new Error('这款游戏暂时没有可玩的版本'); } - const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl); + const priceMudPoints = value.priceMudPoints ?? 0; + const rawEntryUrl = currentVersion.entryUrl; + if (!rawEntryUrl) { + // 付费作品的入口由播放会话签发;免费游戏没有入口说明服务端确实没有可玩版本。 + if (priceMudPoints <= 0) { + throw new Error('这款游戏暂时没有可玩的版本'); + } + setGame({ ...value, currentVersion: { ...currentVersion, entryUrl: null } }); + return; + } + const entryUrl = normalizeGameEntryUrl(rawEntryUrl); if (!entryUrl) throw new Error('游戏入口地址无效'); setGame({ ...value, - currentVersion: { ...value.currentVersion, entryUrl }, + currentVersion: { ...currentVersion, entryUrl }, }); }) .catch((loadError: unknown) => { @@ -104,9 +123,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const mobilePlaybackBlocked = isMobileViewport && !game?.deviceSupport.mobile; const showRotationHint = Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport; - const startGame = () => { - if (mobilePlaybackBlocked) return; - // 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。 + /** 进入游戏:计数是 fire-and-forget,失败也不阻断;付费作品在会话签发成功后才计数。 */ + const startFrame = () => { const playGameId = game?.id ?? normalizeGameId(gameId); if (playGameId) { void recordGamePlay(playGameId).catch(() => {}); @@ -116,8 +134,47 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { setFrameRunId((runId) => runId + 1); }; - /** 重试只重挂载发行页,不改变已开始的会话,也不会在退出后自动重启。 */ + const startGame = () => { + if (mobilePlaybackBlocked || !game || isPreparingPlayback) return; + setPlayError(''); + const priceMudPoints = game.priceMudPoints ?? 0; + if (priceMudPoints <= 0) { + // 免费游戏维持原来的公开入口链路,不经过播放会话。 + setPlayUrl(game.currentVersion?.entryUrl ?? null); + startFrame(); + return; + } + setIsPreparingPlayback(true); + void createGamePlaySession(game.id) + .then((session) => { + const sessionUrl = normalizeGameEntryUrl(session.playUrl); + if (!sessionUrl) throw new Error('播放地址无效'); + setPlayUrl(sessionUrl); + startFrame(); + }) + .catch((sessionError: unknown) => { + setHasStarted(true); + setStartupState('failed'); + if (sessionError instanceof ApiClientError && sessionError.status === 403) { + setPlayError('这款游戏需要先购买才能游玩,请返回详情页完成购买。'); + return; + } + setPlayError( + sessionError instanceof Error && sessionError.message.trim() + ? sessionError.message + : '创建播放会话失败,请稍后重试。', + ); + }) + .finally(() => setIsPreparingPlayback(false)); + }; + + /** 重试:付费作品在会话缺失时重新签发,其余情况只重挂载发行页。 */ const retryGame = () => { + setPlayError(''); + if ((game?.priceMudPoints ?? 0) > 0 && !playUrl) { + startGame(); + return; + } setStartupState('loading'); setFrameRunId((runId) => runId + 1); }; @@ -182,7 +239,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {

) : null}
- {!hasStarted ? ( + {!hasStarted && !isPreparingPlayback ? (
@@ -211,8 +268,26 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
) : null} - {hasStarted && startupState === 'loading' ? ( -
游戏正在启动…
+ {isPreparingPlayback || + (hasStarted && startupState === 'loading' && !playError) ? ( +
+ {isPreparingPlayback ? '正在准备游戏…' : '游戏正在启动…'} +
+ ) : null} + {playError ? ( + + {playError} + + + ) : null} {hasStarted && startupState === 'timeout' ? ( ) : null} - {hasStarted ? ( + {hasStarted && playUrl ? (