diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index df1709dd4..b47ed6fcc 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1183,6 +1183,8 @@ export interface AdminGameDistributionReviewEntry { packageBytes: number; status: string; publicationRevision: number; + /** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */ + priceMudPoints?: number; reviewReason: string | null; createdAt: string; updatedAt: string; @@ -1211,6 +1213,8 @@ export interface AdminGameDistributionGameDetail { orientation: string; status: string; publicationRevision: number; + /** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */ + priceMudPoints?: number; playCount: number; createdAt: string; } diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 434373f2d..b5f122a2b 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -19,6 +19,7 @@ import { reviewAdminGameDistributionVersion, } from '../api/adminApiClient'; import type { + AdminGameDistributionGameDetail, AdminGameDistributionGameListResponse, AdminGameDistributionReviewEntry, AdminGameDistributionVersionDetailResponse, @@ -50,6 +51,7 @@ const entry: AdminGameDistributionReviewEntry = { packageBytes: 2048, status: 'pending_review', publicationRevision: 4, + priceMudPoints: 30, reviewReason: null, // 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。 createdAt: '1791008678.817257Z', @@ -94,6 +96,7 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = { orientation: 'landscape', status: 'unpublished', publicationRevision: 7, + priceMudPoints: 30, playCount: 0, createdAt: '2026-09-20T08:00:00Z', }, @@ -230,7 +233,8 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', const row = screen.getByText(gameTitle).closest('tr'); expect(row).not.toBeNull(); - const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3]; + // 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。 + const submittedCell = within(row as HTMLElement).getAllByRole('cell')[4]; // 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。 expect(submittedCell?.textContent).not.toBe('-'); expect(submittedCell?.textContent).toMatch( @@ -238,6 +242,56 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', ); }); +test('价格列按买断制口径展示,免费与缺失价格都不显示 NaN', async () => { + const freeEntry: AdminGameDistributionReviewEntry = { + ...entry, + versionId: 'version-2', + gameId: 'game_2', + versionNumber: 1, + priceMudPoints: 0, + }; + // 旧响应可能完全没有该字段;列表必须回落到“免费”而不是 NaN。 + const legacyEntry = { + ...entry, + versionId: 'version-3', + gameId: 'game_3', + versionNumber: 3, + priceMudPoints: undefined, + } as unknown as AdminGameDistributionReviewEntry; + vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ + entries: [entry, freeEntry, legacyEntry], + nextCursor: null, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + const table = screen.getByText(gameTitle).closest('table'); + expect(table).not.toBeNull(); + // 按列名定位:绑「第 4 个格子」的话,任何插列或换序都会读到别的字段,断言随之失效。 + const priceColumnIndex = within(table as HTMLElement) + .getAllByRole('columnheader') + .findIndex((header) => header.textContent?.trim() === '价格'); + expect(priceColumnIndex).toBeGreaterThanOrEqual(0); + + const cellAt = (text: string) => { + const row = screen.getByText(text).closest('tr'); + expect(row).not.toBeNull(); + return within(row as HTMLElement).getAllByRole('cell')[priceColumnIndex] + ?.textContent; + }; + + // 三行三态都按精确文案钉死:付费、免费与旧响应缺字段都不能渲染成 NaN。 + expect(cellAt(gameTitle)).toBe('30 泥点'); + expect(cellAt('game_2')).toBe('免费'); + expect(cellAt('game_3')).toBe('免费'); +}); + test('取消理由输入时不做审核操作', async () => { render( { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({ + // 服务端 `version_detail_payload` 已把「待审版本冻结价优先、缺字段按 0」归一写进 + // `game.priceMudPoints`,审核页只渲染这个字段。 + game: { ...detailResponse.game, priceMudPoints: 30 }, + version: { + ...detailResponse.version, + // 对抗用例:冻结资料里的价格与服务端归一结果故意不同(999 ≠ 30)。客户端若自行 + // 复刻「冻结价优先」,就会显示 999 —— 这里必须仍然是服务端字段的 30。 + frozenMetadata: { title: '测试游戏', priceMudPoints: 999 }, + }, + }); + + 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('999 泥点')).toBeNull(); + 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 158e5381e..0fe3d3118 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -100,6 +100,8 @@ function applyFrozenGameMetadata( typeof metadata.orientation === 'string' ? metadata.orientation : game.orientation, + // 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、 + // 缺字段按 0」把结果归一写进 `game.priceMudPoints`,客户端直接沿用该字段。 }; } @@ -113,6 +115,11 @@ function formatBytes(value: number) { return `${value} B`; } +/** 买断制价格口径:0 或缺失(旧响应 / undefined)都按“免费”展示,避免出现 NaN。 */ +function formatPriceMudPoints(value: number | null | undefined) { + return typeof value === 'number' && value > 0 ? `${value} 泥点` : '免费'; +} + function createReviewIdempotencyKey(versionId: string) { const random = typeof crypto !== 'undefined' && 'randomUUID' in crypto @@ -439,6 +446,7 @@ export function AdminGameDistributionReviewPage({ { key: 'gameId', label: '游戏' }, { key: 'versionNumber', label: '版本' }, { key: 'packageBytes', label: '发行包' }, + { key: 'priceMudPoints', label: '价格' }, { key: 'createdAt', label: '提交时间' }, { key: 'actions', label: '审核' }, ]} @@ -484,6 +492,7 @@ export function AdminGameDistributionReviewPage({ {entry.packageSha256.slice(0, 12)} + {formatPriceMudPoints(entry.priceMudPoints)} {formatAdminDateTime(entry.createdAt)} @@ -649,6 +658,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..48d48ccc6 100644 --- a/apps/admin-web/src/pages/adminGameDistributionReview.css +++ b/apps/admin-web/src/pages/adminGameDistributionReview.css @@ -27,14 +27,22 @@ table.admin-game-review-table td:nth-child(3) { width: 124px; } +/* 价格列:只展示「免费」或「N 泥点」,上限 1000000 泥点最长;给足宽度并允许换行, + 固定列宽 + overflow:hidden 下保持 nowrap 会把最大价硬裁掉。 */ table.admin-game-review-table th:nth-child(4), table.admin-game-review-table td:nth-child(4) { - width: 172px; - white-space: nowrap; + width: 120px; + white-space: normal; } 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 +108,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; } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index d3446e0a4..343aee81a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -51,6 +51,9 @@ const PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; /// JS `Number.MAX_SAFE_INTEGER`:版本号经 JSON number 传递,超过它就会丢精度。 const MAX_SAFE_VERSION_NUMBER: u64 = 9_007_199_254_740_991; +/// 买断价上限(整数泥点),与服务端 `MAX_GAME_PRICE_MUD_POINTS` 及前端共享常量一致。 +const MAX_GAME_PRICE_MUD_POINTS: u64 = 1_000_000; + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub(crate) struct GameDistributionPublishResult { @@ -110,6 +113,12 @@ pub(crate) struct GameDistributionPublicationDraft { pub(crate) input_modes: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) orientation: Option, + /// 更新面板预填的买断价(整数泥点,`0` 表示免费):取该作品**最近版本的冻结价** + /// (该版本可能仍处于 `pending_review` / `rejected`,因此预填值未必已生效);该版本 + /// 冻结价缺失或为 `0` 时回落游戏行当前生效价,两条来源都取不到才按 `0`(免费), + /// 避免把已上线的付费作品预填成免费。旧原生响应缺该字段时按免费兜底。 + #[serde(default)] + pub(crate) price_mud_points: u64, } /// 发布面板打开时的发布状态回读。 @@ -130,6 +139,13 @@ pub(crate) struct GameDistributionPublicationReadResult { pub(crate) draft: Option, #[serde(default)] pub(crate) visibility: Option, + /// 平台侧这次发布实际会用的版本号(作者视图已有版本的最大 `versionNumber + 1`)。 + /// + /// 首次发布会新建作品,服务端从 `1` 开始派发,所以这里也是 `1`。拿不到平台数据的路径 + /// (`unavailable`、以及「有本地绑定但没能刷新线上状态」)不下发:渲染层不得回落到本地工程 + /// 迭代计数,只能据此禁用提交。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) next_version_number: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) message: Option, } @@ -238,12 +254,22 @@ struct OwnerGameEntry { publication_revision: u64, #[serde(default)] latest_version: Option, + /// 该作者作品的版本列表(作者自有视图按最近更新倒序、单游戏最多 10 条)。 + /// + /// 平台只在客户端不传版本号时按 `max_existing + 1` 自动派发,所以「下一个版本号」必须取这里 + /// 已下发版本的最大 `versionNumber`;`latestVersion` 是最近**更新**的那条(可能是被改过状态的 + /// 历史版本),单靠它或本地工程迭代计数都会推出重复 / 回退的版本号。 + #[serde(default)] + versions: Vec, #[serde(default)] device_support: Option, #[serde(default)] input_modes: Vec, #[serde(default)] orientation: Option, + /// 游戏行当前生效的买断价(作者自有视图的 `priceMudPoints`)。 + #[serde(default)] + price_mud_points: u64, } #[derive(Clone, Debug, Default, Deserialize)] @@ -254,6 +280,9 @@ struct OwnerGameVersion { version_number: u64, #[serde(default)] status: String, + /// 该版本审核通过后冻结的买断价;历史版本缺省为 `0`。 + #[serde(default)] + price_mud_points: u64, } #[derive(Clone, Debug, Deserialize)] @@ -438,11 +467,19 @@ fn normalized_origin(value: &str) -> String { value.trim().trim_end_matches('/').to_string() } -fn metadata_digest(metadata: &GameDistributionCreateGameRequest) -> Result { +/// 发布资料摘要:随版本冻结的资料与买断价一起参与。 +/// +/// 价格必须进摘要:同一份包、同一版本标签只改价格时,幂等账本必须换键,否则会重放上一次 +/// 尝试的收据,把作者改后的价格静默丢掉。 +fn metadata_digest( + metadata: &GameDistributionCreateGameRequest, + price_mud_points: u64, +) -> Result { let bytes = serde_json::to_vec(metadata).map_err(|error| format!("计算发布资料摘要失败:{error}"))?; let mut hasher = Sha256::new(); hasher.update(&bytes); + hasher.update(price_mud_points.to_le_bytes()); Ok(format!("{:x}", hasher.finalize())) } @@ -741,6 +778,8 @@ fn first_publish_state(message: Option) -> GameDistributionPublicationRe refreshed: true, draft: None, visibility: None, + // 首次发布会新建作品,平台自己也从 `1` 派发这一版的版本号。 + next_version_number: Some(1), message, } } @@ -752,15 +791,35 @@ fn unavailable_state(message: Option) -> GameDistributionPublicationRead refreshed: false, draft: None, visibility: None, + // 拿不到平台数据就不给版本号:渲染层据此禁用提交,不得回落到本地工程迭代计数。 + next_version_number: None, message, } } +/// 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1`。 +/// +/// 与平台自身的派发口径一致(`module-game-distribution::resolve_version_number` 的 +/// `max_existing + 1`)。`latestVersion` 只是最近更新的一条,必须与版本列表一起取最大值。 +/// 作者视图单游戏最多下发 10 条版本(`GAME_DISTRIBUTION_MAX_OWNER_VERSIONS_PER_GAME`): +/// 号最大的那条被挤出列表时会低估一个已经存在的版本号,这是当前作者视图的取数边界。 +fn resolve_owner_next_version_number(entry: &OwnerGameEntry) -> u64 { + entry + .versions + .iter() + .chain(entry.latest_version.iter()) + .map(|version| version.version_number) + .max() + .unwrap_or(0) + .saturating_add(1) +} + /// 回写发布绑定;本地遗留的 `projectVersion` 一并清空。 /// -/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不再从绑定推导(它由 -/// AGC 工程内部版本 `versions[]` 派生、只读),所以这里只落绑定,并把存量清单里可能残留的 -/// 遗留标签置空——`skip_serializing_if` 会让该键随下一次落盘消失。 +/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不从绑定推导,也不来自 +/// AGC 工程内部版本:它取平台作者视图里该作品的最大版本号 + 1(发布时显式提交同一个值), +/// 所以这里只落绑定,并把存量清单里可能残留的遗留标签置空——`skip_serializing_if` 会让该键 +/// 随下一次落盘消失。 fn write_publication_binding( root: &Path, binding: GameCreationAppPublicationBinding, @@ -1014,9 +1073,21 @@ async fn build_publication_draft( device_support, input_modes: parse_input_modes(input_modes), orientation: parse_orientation(orientation), + price_mud_points: resolve_publication_prefill_price(entry), } } +/// 更新面板预填的买断价:优先取最近版本的冻结价(与网页更新模式同源),该版本历史无价格时 +/// 回退游戏行当前价。两条来源都拿不到才按 `0`(免费),避免把已上线的付费作品预填成免费。 +fn resolve_publication_prefill_price(entry: &OwnerGameEntry) -> u64 { + entry + .latest_version + .as_ref() + .map(|version| version.price_mud_points) + .filter(|price| *price > 0) + .unwrap_or(entry.price_mud_points) +} + async fn bound_publication_result( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, @@ -1053,6 +1124,7 @@ async fn bound_publication_result( refreshed: true, draft: Some(draft), visibility: Some(entry.status.clone()).filter(|status| !status.is_empty()), + next_version_number: Some(resolve_owner_next_version_number(&entry)), message: None, }) } @@ -1207,12 +1279,14 @@ pub(crate) async fn read_game_distribution_publication( } Err(GameDistributionReadError::Unavailable(message)) => { // 结果未知:保留本地绑定,按更新模式返回但标注未刷新,由发布时的 CAS 兜底。 + // 这一路没有平台版本数据,所以不给版本号:渲染层禁用提交,等下一次刷新成功。 Ok(GameDistributionPublicationReadResult { state: "update".to_string(), binding: Some(binding), refreshed: false, draft: None, visibility: None, + next_version_number: None, message: Some(format!("无法刷新线上状态:{message}")), }) } @@ -1656,6 +1730,8 @@ pub(crate) async fn publish_local_project_game( expected_publication_revision: Option, idempotency_key: Option, include_project_bundle: Option, + // 买断价(整数泥点,`0` 表示免费)。旧前端不传时为 `None`,按免费提交,保持向后兼容。 + price_mud_points: Option, ) -> Result { let snapshot = require_platform_session()?; let app_data_dir = app @@ -1717,7 +1793,12 @@ pub(crate) async fn publish_local_project_game( }); } } - let digest = metadata_digest(&metadata)?; + // 买断价只允许 `0..=上限`;面板已按同一口径拦住非法输入,这里对命令边界再收一次口。 + let price_mud_points = price_mud_points.unwrap_or(0); + if price_mud_points > MAX_GAME_PRICE_MUD_POINTS { + return Err(format!("买断价不能超过 {MAX_GAME_PRICE_MUD_POINTS} 泥点")); + } + let digest = metadata_digest(&metadata, price_mud_points)?; // 工程源包要不要一起传,只取决于**授权档位**与面板勾选,与后面把 metadata 交给 // 创建版本请求无关:这里先算出来,避免 metadata 被 move 之后再用它。 let fork_authorized = !matches!( @@ -1801,6 +1882,8 @@ pub(crate) async fn publish_local_project_game( package_file_count: staged.package_file_count, package_entry_path: "index.html".to_string(), version_number, + // 买断价随版本冻结;面板已本地校验,这里透传服务端口径的整数泥点。 + price_mud_points, game_metadata: metadata, }; let version_value = request_json( @@ -1944,7 +2027,8 @@ pub(crate) async fn publish_local_project_game( } }); // 发布成功:把平台作品绑定和最近版本状态写回本地清单。项目版本标签不在这里对齐——它由 - // 工程内部版本派生,这次提交的平台版本号不回写本地(遗留 `projectVersion` 一并清空)。 + // 平台作者视图的最大版本号 + 1 推出、在提交前已经决定了下一次要用的值(遗留 `projectVersion` + // 一并清空)。 // 写回失败不回滚已经成功的发布,但会把错误交给作者——否则下次打开项目会退回首次发布, // 甚至可能重复建作品。 let binding = GameCreationAppPublicationBinding { @@ -1957,7 +2041,7 @@ pub(crate) async fn publish_local_project_game( status: Some(status.clone()), }; let manifest = crate::project::mutate_manifest_at(root, |manifest| { - // 项目版本标签由工程内部版本派生,不由这次提交的平台版本号回写;遗留位一并清空。 + // 项目版本标签由平台作者视图派生并在提交前传下去,不由这次提交的平台版本号回写;遗留位一并清空。 manifest.project_version = None; manifest.publication = Some(binding.clone()); Ok(manifest.clone()) @@ -2099,25 +2183,27 @@ mod tests { // 改编来源声明:只在从平台作品改编时由发布链路并入,这里构造基线资料时为空。 fork: None, }; - let first = metadata_digest(&metadata).expect("digest"); + let first = metadata_digest(&metadata, 0).expect("digest"); assert_eq!(first.len(), 64); - assert_eq!(metadata_digest(&metadata).expect("stable"), first); + assert_eq!(metadata_digest(&metadata, 0).expect("stable"), first); + // 只改价格的提交必须换幂等键:否则账本会重放上一次收据,静默丢掉改价。 + assert_ne!(metadata_digest(&metadata, 120).expect("priced"), first); metadata.title = "新标题".to_string(); - assert_ne!(metadata_digest(&metadata).expect("changed"), first); + assert_ne!(metadata_digest(&metadata, 0).expect("changed"), first); // 改编声明也参与幂等摘要:同内容稳定,并入声明即变(这是预期行为,不是漂移)。 metadata.title = "游戏".to_string(); - assert_eq!(metadata_digest(&metadata).expect("restored"), first); + assert_eq!(metadata_digest(&metadata, 0).expect("restored"), first); metadata.fork = Some( shared_contracts::game_distribution::GameDistributionForkDeclaration { parent_game_id: "game_parent".to_string(), parent_version_id: "gamever_parent".to_string(), }, ); - let with_fork = metadata_digest(&metadata).expect("digest with fork"); + let with_fork = metadata_digest(&metadata, 0).expect("digest with fork"); assert_ne!(with_fork, first); assert_eq!( - metadata_digest(&metadata).expect("stable with fork"), + metadata_digest(&metadata, 0).expect("stable with fork"), with_fork ); } @@ -2212,20 +2298,115 @@ mod tests { "summary": "守住轨道城", "status": "published", "publicationRevision": 4, + "priceMudPoints": 240, "latestVersion": { "versionId": "gamever-2", "versionNumber": 2, - "status": "pending_review" - } + "status": "pending_review", + "priceMudPoints": 300 + }, + "versions": [ + { + "versionId": "gamever-2", + "versionNumber": 2, + "status": "pending_review", + "priceMudPoints": 300 + }, + { + "versionId": "gamever-5", + "versionNumber": 5, + "status": "published", + "priceMudPoints": 0 + } + ] })) .expect("owner entry"); assert_eq!(entry.id, "game-1"); assert_eq!(entry.local_project_id.as_deref(), Some("proj-1")); assert_eq!(entry.publication_revision, 4); + assert_eq!(entry.price_mud_points, 240); + assert_eq!(entry.versions.len(), 2); let latest = entry.latest_version.expect("latest version"); assert_eq!(latest.version_id, "gamever-2"); assert_eq!(latest.version_number, 2); assert_eq!(latest.status, "pending_review"); + assert_eq!(latest.price_mud_points, 300); + } + + #[test] + fn next_version_number_follows_platform_maximum_not_latest_or_local_count() { + // 作者视图按最近更新倒序:`latestVersion` 是 v2(最近被改过状态),列表里号最大的是 v5。 + // 平台派发口径是 max_existing + 1,所以下一个版本号必须是 6,不能是 2 + 1。 + let entry = OwnerGameEntry { + latest_version: Some(OwnerGameVersion { + version_number: 2, + ..Default::default() + }), + versions: vec![ + OwnerGameVersion { + version_number: 2, + ..Default::default() + }, + OwnerGameVersion { + version_number: 5, + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!(resolve_owner_next_version_number(&entry), 6); + + // 没有任何版本记录的作品:平台会从 1 开始派发。 + assert_eq!( + resolve_owner_next_version_number(&OwnerGameEntry::default()), + 1 + ); + + // 列表被条数上限截断、只带回旧版本时也只能按已下发的最大值推出(取数边界见函数注释)。 + let truncated = OwnerGameEntry { + latest_version: None, + versions: vec![OwnerGameVersion { + version_number: 1, + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(resolve_owner_next_version_number(&truncated), 2); + } + + #[test] + fn publication_prefill_price_prefers_latest_frozen_price_and_falls_back() { + // 最新版本冻结价 > 0:与网页更新模式同源,直接用它。 + let paid = OwnerGameEntry { + price_mud_points: 240, + latest_version: Some(OwnerGameVersion { + price_mud_points: 300, + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(resolve_publication_prefill_price(&paid), 300); + + // 历史版本没有冻结价(0):回退游戏行当前价,避免把已上线的付费作品预填成免费。 + let legacy = OwnerGameEntry { + price_mud_points: 240, + latest_version: Some(OwnerGameVersion::default()), + ..Default::default() + }; + assert_eq!(resolve_publication_prefill_price(&legacy), 240); + + // 免费作品、以及完全缺该字段的旧响应,都按 0(免费)。 + assert_eq!( + resolve_publication_prefill_price(&OwnerGameEntry::default()), + 0 + ); + let legacy_entry = parse_owner_game_entry(json!({ + "id": "game-2", + "title": "免费游戏", + "latestVersion": { "versionId": "v1", "versionNumber": 1, "status": "published" } + })) + .expect("legacy owner entry"); + assert_eq!(resolve_publication_prefill_price(&legacy_entry), 0); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs b/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs index bd7d94085..735072077 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs @@ -36,8 +36,8 @@ struct GamePublishAttemptRecord { /// 目标游戏身份:更新已有作品时非空;首次发布为空。 #[serde(default)] target_game_id: Option, - /// 这次发布提交的项目版本标签(由清单 `versions[]` 的条数派生,不由 `projectVersion` - /// 这种遗留兼容位提供):换了标签就是一次新尝试。 + /// 这次发布提交的平台版本号(作者视图里该作品的最大版本号 + 1,首次发布为 `1`; + /// 不由清单 `versions[]` 的条数或遗留 `projectVersion` 位提供):换了版本号就是一次新尝试。 #[serde(default)] target_version_number: Option, /// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 94242824d..44e55db43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -207,9 +207,9 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin /// 遗留字段 `projectVersion` 的取值范围:缺省合法(旧清单),写了就必须是正整数。 /// -/// 它**不参与**发布版本标签的推导(标签由 `versions[]` 的条数派生),只是为了让存量清单继续 -/// 可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值,所以在读 -/// 写两侧都失败关闭,避免坏值被静默传播。 +/// 它**不参与**发布版本号的推导(版本号取平台作者视图里该作品的最大版本号 + 1),只是为了让 +/// 存量清单继续可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值, +/// 所以在读写两侧都失败关闭,避免坏值被静默传播。 fn validate_manifest_project_version(value: Option) -> Result<(), String> { if value == Some(0) { return Err("manifest projectVersion 必须是大于 0 的整数".to_string()); diff --git a/apps/ai-game-creator-shell/src/components/common/ComposerNoticeToast.tsx b/apps/ai-game-creator-shell/src/components/common/ComposerNoticeToast.tsx new file mode 100644 index 000000000..535c7b336 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/common/ComposerNoticeToast.tsx @@ -0,0 +1,56 @@ +import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import { COMPOSER_NOTICE_MILLIS, type ComposerNotice } from './composerNotice'; + +/** + * 输入区浮层提示的本体(底部居中 portal、6s 自动收起)。 + * + * 只由 `useComposerNoticeHost` 选中的宿主渲染;`data-composer-model-toast` 是各宿主用例的判据。 + */ +export function ComposerNoticeToast({ + notice, + onDismiss, +}: { + notice: ComposerNotice | null; + onDismiss: () => void; +}) { + /** 收起回调只从 ref 读:宿主每次渲染都换箭头函数,写进依赖会让计时器反复重起。 */ + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; + useEffect(() => { + if (!notice) { + return undefined; + } + const timer = window.setTimeout( + () => onDismissRef.current(), + COMPOSER_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/components/common/composerNotice.ts b/apps/ai-game-creator-shell/src/components/common/composerNotice.ts new file mode 100644 index 000000000..201df4333 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/common/composerNotice.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react'; + +/** 浮层提示的停留时长:与外壳(`LauncherNoticeToast`)与模板库提示同一档(6s)。 */ +export const COMPOSER_NOTICE_MILLIS = 6000; + +export type ComposerNotice = { + /** 每次提示自增:同一条文案再出现时也会重新弹一次并重起计时。 */ + id: number; + message: string; + /** + * 语气:失败(模型服务不可用 / 推理档保存失败)用 `error`;状态(模型列表已刷新、 + * 正在刷新模型列表、默认模型已停用等)用 `neutral`。 + */ + tone: 'error' | 'neutral'; +}; + +/** + * 输入区浮层提示的**模块级单例**:模型选择器与推理档控件都往这里发,宿主不必接任何回调。 + * + * 为什么是单例而不是各自 portal: + * 1. 输入区**不留常驻状态行**——那一排控件容不下第二行文字,一旦在控件里排一行提示,整颗 + * 控件会变成两行高、把触发钮顶到行线上方(#600 复核实测的错位); + * 2. 同一个输入区里可能同时挂着模型选择器与推理档控件(首页、策划、工作台都一样),各自 + * portal 会在同一位置叠出两条 toast。这里由**编号最小的那个宿主**渲染,保证同一时刻 + * 只有一条。 + */ +let notice: ComposerNotice | null = null; +let sequence = 0; +let hostSequence = 0; +const hosts = new Map void>(); + +function notifyHosts() { + for (const listener of hosts.values()) { + listener(); + } +} + +/** 发一条提示(每条都会重新弹一次并重起计时)。 */ +export function publishComposerNotice( + message: string, + tone: ComposerNotice['tone'] = 'error', +) { + sequence += 1; + notice = { id: sequence, message, tone }; + notifyHosts(); +} + +/** 收起当前提示(6s 计时结束或宿主主动收)。 */ +export function dismissComposerNotice() { + notice = null; + notifyHosts(); +} + +/** + * 在输入区里挂一个 toast 宿主。同一个输入区里多个调用点只有一个会真正渲染(编号最小的那个), + * 所以模型选择器与推理档控件可以各自调用,页面里也只会出现一条浮层。 + * + * 返回 `notice: null` 表示这次由别的宿主渲染(调用方照样把 `` 挂上即可)。 + */ +export function useComposerNoticeHost(): { + notice: ComposerNotice | null; + dismiss: () => void; +} { + const hostIdRef = useRef(null); + const [, forceRender] = useState(0); + + if (hostIdRef.current === null) { + hostSequence += 1; + hostIdRef.current = hostSequence; + } + + useEffect(() => { + const hostId = hostIdRef.current as number; + const listener = () => forceRender((value) => value + 1); + hosts.set(hostId, listener); + notifyHosts(); + return () => { + hosts.delete(hostId); + if (hosts.size === 0) { + // 输入区整体卸载(切页/测试清理)时把待显示的提示一并丢掉,避免留给下一个宿主。 + notice = null; + } + notifyHosts(); + }; + }, []); + + if (hosts.size === 0 || Math.min(...hosts.keys()) !== hostIdRef.current) { + return { notice: null, dismiss: dismissComposerNotice }; + } + return { notice, dismiss: dismissComposerNotice }; +} diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index ba121851c..8fef03dba 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -1,10 +1,12 @@ -import { Eye, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; - import { - type GameCreationAppManifest, - resolveGameCreationAppProjectVersion, -} from '../../../../../packages/shared/src/contracts/gameCreationApp'; + PlatformGamePricingField, + type PlatformGamePricingMode, + resolvePlatformGamePriceMudPoints, +} from '@genarrative/shared/components'; +import { Eye, Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { type GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { GAME_DISTRIBUTION_CATEGORIES, GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, @@ -262,6 +264,10 @@ export function GameDistributionPublishPanel({ * 只是退化为「只能被产物级改编」。 */ const [projectBundleConfirmed, setProjectBundleConfirmed] = useState(true); + /** 付费方式与买断价输入;校验口径与网页、服务端一致。 */ + const [priceMode, setPriceMode] = useState('free'); + const [priceInput, setPriceInput] = useState(''); + const [priceError, setPriceError] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState( @@ -295,14 +301,15 @@ export function GameDistributionPublishPanel({ /** 线上最近提交的版本号;首次发布为 0(没有线上版本)。 */ const [latestVersionNumber, setLatestVersionNumber] = useState(0); /** - * 面板展示并提交的「项目版本」:AGC 工程内部版本序数,唯一来源是清单的 `versions[]`。 + * 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1` + * (首次发布为 `1`),由原生回读下发。 * - * 只读派生值,不是可编辑 state:用户不能改,平台版本号也改不了它。每次智能体修订追加一条 - * 内部版本,这个值随之推进。 + * `null` = 还没拿到平台数据(恢复失败、或更新模式没能刷新线上状态):这时不允许提交, + * 也不回落到 AGC 工程内部版本 `versions[]` 的条数——那是本地迭代计数,会提交重复 / + * 回退的平台版本号。面板展示的值与提交的值都只取这一个来源。 */ - const projectVersion = useMemo( - () => resolveGameCreationAppProjectVersion(manifest), - [manifest], + const [nextVersionNumber, setNextVersionNumber] = useState( + null, ); const metadataSuggestionRequestRef = useRef(0); const publicationRequestRef = useRef(0); @@ -329,6 +336,9 @@ export function GameDistributionPublishPanel({ setTitle(manifest.name.trim()); setSummary(fallbackSummary); setCategory('其他'); + setPriceMode('free'); + setPriceInput(''); + setPriceError(''); setCover(null); setScreenshots([]); setBusy(false); @@ -345,6 +355,7 @@ export function GameDistributionPublishPanel({ setPublicationRefreshed(false); setPublicationMessage(''); setLatestVersionNumber(0); + setNextVersionNumber(null); summaryTouchedRef.current = false; categoryTouchedRef.current = false; publicationDraftAppliedRef.current = false; @@ -384,7 +395,8 @@ export function GameDistributionPublishPanel({ // 面板据此禁用提交而不是退化成首次发布。 const invoke = resolveTauriInvoke(); if (!invoke) { - // 非 Tauri 宿主:没有可恢复的原生状态,交给提交时的失败提示兜底。 + // 非 Tauri 宿主:没有可恢复的原生状态,也就拿不到平台版本号——按首次发布展示表单, + // 提交按钮因为版本号未知保持禁用(发布本身也需要原生宿主)。 setPublicationState('first-publish'); return; } @@ -400,8 +412,15 @@ export function GameDistributionPublishPanel({ ); return; } + // 版本号只认原生按平台作者视图推出来的值;缺字段(旧原生端 / 没刷新成功)就保持未知。 + const platformNextVersion = recovery.nextVersionNumber; + setNextVersionNumber( + typeof platformNextVersion === 'number' && platformNextVersion > 0 + ? platformNextVersion + : null, + ); if (recovery.state === 'update') { - // 线上最近提交的版本号只用于展示平台侧事实;面板的项目版本由内部版本派生,与它无关。 + // 线上最近提交的版本号只用于展示平台侧事实,与这次要用的版本号无关。 const latestVersionNumber = recovery.binding.latestVersionNumber ?? 0; setPublicationState('update'); setPublicationBinding(recovery.binding); @@ -447,6 +466,20 @@ export function GameDistributionPublishPanel({ if (!categoryTouchedRef.current && draft.category) { setCategory(draft.category); } + // 线上价格预填:付费作品进面板默认免费会在送审后把作品改成免费,这里按回读的 + // 当前价预填,作者不改就不变。历史响应缺该字段时按免费兜底。 + const existingPriceMudPoints = draft.priceMudPoints ?? 0; + if ( + typeof existingPriceMudPoints === 'number' && + existingPriceMudPoints > 0 + ) { + setPriceMode('paid'); + setPriceInput(String(existingPriceMudPoints)); + } else { + setPriceMode('free'); + setPriceInput(''); + } + setPriceError(''); if (draft.cover && (draft.cover.assetId || draft.cover.previewUrl)) { setCover(buildRestoredCover(draft.cover)); } @@ -681,6 +714,11 @@ export function GameDistributionPublishPanel({ setError('请先导出有效的试玩包'); return; } + // 版本号必须来自平台作者视图;拿不到就不发请求,也不猜本地工程迭代计数。 + if (nextVersionNumber === null) { + setError('无法确认项目版本,请重新打开发布面板后再试'); + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setError('需要在 Tauri App 内发布'); @@ -711,6 +749,12 @@ export function GameDistributionPublishPanel({ setError('素材还在上传中,请稍候再发布'); return; } + const price = resolvePlatformGamePriceMudPoints(priceMode, priceInput); + if (price.error) { + setPriceError(price.error); + return; + } + setPriceError(''); setBusy(true); setError(''); setPublishPhase({ @@ -769,7 +813,9 @@ export function GameDistributionPublishPanel({ publicationState === 'update' ? (publicationBinding?.gameId ?? null) : null, - versionNumber: projectVersion, + // 展示与提交同一个平台版本号(作者视图最大版本号 + 1,首次发布为 1)。 + versionNumber: nextVersionNumber, + priceMudPoints: price.priceMudPoints, expectedPublicationRevision: publicationState === 'update' ? (publicationBinding?.revision ?? null) @@ -984,7 +1030,8 @@ export function GameDistributionPublishPanel({ 设计稿、笔记等不在任何拒绝集内,会随包一起对任何登录用户公开。默认勾选与授权 语义一致(允许共创即开放源码),但作者可以取消:取消即本次不上传源码。 */} - {forkAuthorization !== 'forbidden' && !forkAuthorizationLocked ? ( + {forkAuthorization !== 'forbidden' && + !forkAuthorizationLocked ? ( ) : null} - {forkAuthorization !== 'forbidden' && !forkAuthorizationLocked ? ( + {forkAuthorization !== 'forbidden' && + !forkAuthorizationLocked ? ( {'默认已勾选,可随时取消;不勾选则本次不上传工程源码。'} ) : null} - + 项目版本 - {`v${projectVersion}`} + {nextVersionNumber === null ? '—' : `v${nextVersionNumber}`} + {/* 平台派发的版本号,作者不能填:只读 + 禁用观感,提交的也是同一个值。 + 保留 readOnly 而不是 disabled:值仍可聚焦 / 可复制,读屏也能念出来。 */} - + + { + setPriceMode(nextMode); + setPriceError(''); + }} + onPriceInputChange={(nextValue) => { + setPriceInput(nextValue); + setPriceError(''); + }} + /> void; projectPath?: string; ref?: Ref; + /** + * 菜单顶部的附加区(Direct 输入盒用来放「思维强度」滑块)。 + * + * 传了它才会把弹层包一层 `.conversation-model-menu-shell`:外壳负责定位 / 描边 / 底色, + * 强度区在上、模型列表(`role="listbox"`)在下,滑块**不进** listbox 子树 + * (参数控件放进菜单角色里会让读屏进菜单模式后读不到滑块)。 + * 不传时渲染与改动前完全一致,策划输入盒、首页、画布都不受影响。 + */ + strengthSection?: ReactNode; }) { const initialCatalog = cachedLlmModelCatalog(); const [models, setModels] = useState( @@ -91,6 +106,9 @@ export function ConversationModelSelect({ const [busy, setBusy] = useState(!initialCatalog); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); + /** 触发钮上显示的模型名(目录还没读完时用「模型」占位);同时用作触发钮的 `title`。 */ + const triggerLabel = + models.find((model) => model.id === selected)?.displayName ?? '模型'; const [manualRefreshBusy, setManualRefreshBusy] = useState(false); const [open, setOpen] = useState(false); const containerRef = useRef(null); @@ -109,6 +127,41 @@ export function ConversationModelSelect({ onReadyRef.current = onReady; }, [onReady]); + /** + * 失败与状态都走**组件自己的浮层**(`composerNotice` 单例 + toast 宿主):任何宿主都不留 + * 常驻 `role="alert"` / `role="status"` 行——输入区那一排控件容不下第二行文字,一旦排一行 + * 提示,整颗控件会变成两行高,把触发钮顶到行线上方(#600 复核实测的错位)。 + * 两条通道各自去重:同一条只报一次,清空后再出现可以再报。 + */ + const reportedErrorRef = useRef(''); + useEffect(() => { + if (error === '') { + reportedErrorRef.current = ''; + return; + } + if (reportedErrorRef.current === error) { + return; + } + reportedErrorRef.current = error; + publishComposerNotice(error, 'error'); + }, [error]); + + const reportedNoticeRef = useRef(''); + useEffect(() => { + if (notice === '') { + reportedNoticeRef.current = ''; + return; + } + if (reportedNoticeRef.current === notice) { + return; + } + reportedNoticeRef.current = notice; + publishComposerNotice(notice, 'neutral'); + }, [notice]); + + // 这个输入区里的 toast 宿主(同一输入区多个调用点只有一个真正渲染)。 + const composerNotice = useComposerNoticeHost(); + useEffect(() => { mountedRef.current = true; return () => { @@ -407,6 +460,75 @@ export function ConversationModelSelect({ } } + const modelMenu = ( + + {busy && models.length === 0 ? ( + 正在读取模型 + ) : null} + {models.map((model) => ( + { + setOpen(false); + void select(model.id); + }} + > + + {model.displayName} + {model.id === defaultModelId ? ( + 默认 + ) : null} + + {model.id === selected ? ( + + ) : null} + + ))} + {/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露, + 且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */} + {unavailableModels.map((model) => { + const reasonLabel = unavailableModelReasonLabel(model.reason); + return ( + + + {model.displayName} + + + + ); + })} + + void syncCatalog(true, true)} + > + + {manualRefreshBusy ? '刷新中…' : '刷新模型列表'} + + + ); + return ( - {error ? {error} : null} - {notice ? {notice} : null} + {/* 失败与状态都只走浮层:控件里不再渲染常驻 `role="alert"` / `role="status"` 行。 */} + {/* 控件位只显示已选模型名;目录还没读完时用「模型」占位(加载提示留在菜单里)。 */} - {models.find((model) => model.id === selected)?.displayName ?? '模型'} + {triggerLabel} {open ? ( - - {busy && models.length === 0 ? ( - - 正在读取模型 - - ) : null} - {models.map((model) => ( - { - setOpen(false); - void select(model.id); - }} - > - - {model.displayName} - {model.id === defaultModelId ? ( - 默认 - ) : null} - - {model.id === selected ? ( - - ) : null} - - ))} - {/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露, - 且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */} - {unavailableModels.map((model) => { - const reasonLabel = unavailableModelReasonLabel(model.reason); - return ( - - - {model.displayName} - - - - ); - })} - - void syncCatalog(true, true)} - > - - {manualRefreshBusy ? '刷新中…' : '刷新模型列表'} - - + strengthSection ? ( + /* 合并形态(Direct):强度区在上、模型列表在下,外壳负责定位与描边。 */ + + {strengthSection} + {modelMenu} + + ) : ( + modelMenu + ) ) : null} ); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 149e5f7f7..bf158680d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -114,6 +114,13 @@ type ResourceReferenceInputProps = { submitSuppressed?: boolean; /** 输入区操作排(编辑器右侧那一列)里宿主注入的内容,例如素材选择器的 `@` 触发钮。 */ inputActions?: ReactNode; + /** + * 把「AI 润色 / 恢复原文」这一排**渲染到宿主指定的容器里**(`createPortal`)。 + * + * Direct 输入区把它挪进下方那条工具条,让润色钮与 `@` / `+` / 模型 / 语音 / 发送 同一水平线 + * (否则它会自成一行,窄面板下看起来像"堆叠")。不传时仍然渲染在输入盒内(策划面等保持原样)。 + */ + actionsPortalTarget?: HTMLElement | null; inputRef?: RefObject; /** 输入区容器元素:宿主定位自己的浮层时用它当锚点。 */ containerRef?: RefObject; @@ -431,6 +438,7 @@ function ResourceReferenceEditor({ showPolishAction = true, submitSuppressed = false, inputActions, + actionsPortalTarget, composerRef, rootRef, }: ResourceReferenceInputProps & { @@ -842,48 +850,55 @@ function ResourceReferenceEditor({ return () => form.removeEventListener('submit', handleFormSubmit, true); }, [rootRef]); - return ( - <> - - {inputActions} - {showPolishAction ? ( - <> + /** + * 「输入区操作排」这一块:宿主注入的 `inputActions` + 内置的「AI 润色 / 恢复原文」。 + * 默认渲染在输入盒里;宿主给了 `actionsPortalTarget` 时改渲染到那个容器(Direct 把它放进 + * 下方工具条,与 `@` / `+` / 模型 / 语音 / 发送 同一行)。 + */ + const inputActionsBlock = ( + + {inputActions} + {showPolishAction ? ( + <> + event.preventDefault()} + onClick={() => void polishPrompt()} + > + {polishing ? ( + + ) : ( + + )} + + {polishedOriginalText !== null ? ( event.preventDefault()} - onClick={() => void polishPrompt()} + onClick={restoreOriginalPrompt} > - {polishing ? ( - - ) : ( - - )} + - {polishedOriginalText !== null ? ( - event.preventDefault()} - onClick={restoreOriginalPrompt} - > - - - ) : null} - > - ) : null} - + ) : null} + > + ) : null} + + ); + + return ( + <> + {actionsPortalTarget + ? createPortal(inputActionsBlock, actionsPortalTarget) + : inputActionsBlock} {/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} {/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */} {showPolishAction && diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 78236e2d4..915e6feaf 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -22,7 +22,7 @@ export type ClientLlmModelCatalog = LlmModelsResponse; export async function loadClientLlmModels(): Promise { const invoke = resolveTauriInvoke(); - if (!invoke) throw new Error('本地模型服务不可用'); + if (!invoke) throw new Error('模型服务不可用'); // Rust typed command 已经把响应解析成 `LlmModelsResponse`(含 unavailableModels)。 return invoke('load_game_creator_llm_models'); } diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index 892dc2179..d104ccb86 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -1,7 +1,8 @@ +import { PLATFORM_GAME_MAX_PRICE_MUD_POINTS } from '@genarrative/shared/components'; + import { type GameCreationAppManifest, type GameCreationAppPublicationBinding, - resolveGameCreationAppProjectVersion, } from '../../../../packages/shared/src/contracts/gameCreationApp'; import { GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, @@ -87,6 +88,13 @@ export type GameDistributionPublicationDraft = { deviceSupport?: GameDistributionDeviceSupport | null; inputModes?: GameDistributionInputMode[]; orientation?: GameDistributionOrientation | null; + /** + * 线上该作品当前的买断价(整数泥点,`0` 表示免费)。 + * + * 更新面板据此预填价格与模式:作者不改就不会把已上线的付费作品静默改成免费。 + * 旧原生端不返回该字段,读取方按 `0`(免费)兜底。 + */ + priceMudPoints?: number | null; }; /** @@ -94,6 +102,9 @@ export type GameDistributionPublicationDraft = { * * `state` 三态必须分开处理:`unavailable` 表示恢复被网络/401/5xx 挡住,绝不能当成首次发布; * `first-publish` 只在服务端给出确定答案(无绑定且作者名下没有匹配作品)时出现。 + * + * `nextVersionNumber` 是平台侧这次发布会用的版本号(作者视图已有版本的最大 `versionNumber + 1`; + * 首次发布为 `1`)。取不到平台数据时缺省,调用方不得回落到本地工程迭代计数,只能禁用提交。 */ export type GameDistributionPublicationReadResult = | { @@ -102,6 +113,7 @@ export type GameDistributionPublicationReadResult = refreshed: boolean; draft: null; visibility: null; + nextVersionNumber?: number | null; message?: string | null; } | { @@ -110,6 +122,7 @@ export type GameDistributionPublicationReadResult = refreshed: boolean; draft: GameDistributionPublicationDraft | null; visibility?: string | null; + nextVersionNumber?: number | null; message?: string | null; } | { @@ -118,6 +131,7 @@ export type GameDistributionPublicationReadResult = refreshed: false; draft: null; visibility: null; + nextVersionNumber?: number | null; message?: string | null; }; @@ -279,7 +293,8 @@ function normalizeMetadata( inputModes: metadata?.inputModes ?? ['keyboard', 'mouse', 'touch'], orientation: metadata?.orientation ?? 'responsive', forkAuthorization: - metadata?.forkAuthorization ?? GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, + metadata?.forkAuthorization ?? + GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, }; } @@ -302,7 +317,8 @@ function toCreateGameRequest( // 上架时的共创授权档位:渲染层只提交用户选择(或默认值),省略与显式传 `forbidden` // 在服务端等价,因此这里始终显式提交,面板状态与请求体一一对应。 forkAuthorization: - metadata.forkAuthorization ?? GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, + metadata.forkAuthorization ?? + GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, // 改编来源声明**不**由渲染层提交:它必须来自项目内真实取过件的 // `.agent/fork-source.json`,由原生发布链路在首次发布时读取并并入请求体, // 渲染层既拿不到也无法伪造。 @@ -321,15 +337,23 @@ export async function publishLocalProjectGame(args: { */ gameId?: string | null; /** - * 这次提交使用的项目版本标签。 + * 这次提交使用的平台版本号。 * - * 缺省时按 AGC 工程内部版本 `versions[]` 派生(见 `resolveGameCreationAppProjectVersion`): - * 标签等于当前存活的内部版本条数,发布面板展示的只读值就是它,不存在第二份版本来源,也不 - * 读取平台绑定里的版本号。同一个版本标签可以反复提交,每次提交生成新的 `versionId`。 + * **必填**:只接受调用方从平台作者视图推出的值(该作品已有版本的最大 `versionNumber + 1`, + * 首次发布为 `1`),也就是发布面板展示给作者的同一个数。这里刻意不回落到 AGC 工程内部版本 + * `versions[]` 的条数——那是本地迭代计数,与平台版本序列无关,会提交重复 / 回退的版本号。 + * 同一个版本号可以反复提交(每次提交生成新的 `versionId`)。 */ - versionNumber?: number | null; + versionNumber: number; /** 更新送审所需的公开修订号 CAS;来自发布绑定。 */ expectedPublicationRevision?: number | null; + /** + * 本版本的买断价(整数泥点,`0` 表示免费)。 + * + * 缺省或为空按免费提交,兼容未接入定价的调用方;服务端仍会独立校验 + * `0..=1_000_000`。发布面板在提交前已按同一口径拦住非法输入。 + */ + priceMudPoints?: number | null; /** * 发布根幂等键。渲染层默认**不**生成也不持久化它:原生发布链路按 * 「账号身份 + 本地项目 + 发行包摘要 + 目标游戏/版本 + 资料摘要」在应用数据里维护发布 @@ -355,13 +379,23 @@ export async function publishLocalProjectGame(args: { throw new Error('发布需要本地项目标识,请重新打开项目后再试'); } const gameId = args.gameId?.trim() ?? ''; - // 版本标签只有一个来源:调用方显式传入的值优先(发布面板展示的派生值),否则回落到按 AGC - // 工程内部版本派生的同一个值。服务端的自动递增路径只服务不传版本号的老客户端。 - const versionNumber = - args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest); + // 版本号只有一个来源:调用方按平台作者视图推出的值(发布面板展示的同一个只读值)。 + // 不回落本地工程迭代计数:那是「本地有几条迭代」而不是「平台版本序列的下一个号」, + // 提交重复 / 回退的版本号时平台不报错,版本序列会乱。 + const versionNumber = args.versionNumber; if (!Number.isSafeInteger(versionNumber) || versionNumber <= 0) { throw new Error('项目版本必须是大于 0 的安全整数'); } + const priceMudPoints = args.priceMudPoints ?? 0; + if ( + !Number.isSafeInteger(priceMudPoints) || + priceMudPoints < 0 || + priceMudPoints > PLATFORM_GAME_MAX_PRICE_MUD_POINTS + ) { + throw new Error( + `买断价必须是 0 到 ${PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 之间的整数泥点`, + ); + } if ( gameId && (args.expectedPublicationRevision === null || @@ -381,6 +415,7 @@ export async function publishLocalProjectGame(args: { metadata: gameMetadata, ...(gameId ? { gameId } : {}), versionNumber, + priceMudPoints, ...(gameId ? { expectedPublicationRevision: args.expectedPublicationRevision } : {}), diff --git a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts index 0582f7c5e..cc73afc8c 100644 --- a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts +++ b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts @@ -30,7 +30,7 @@ export function notifyLlmConfigChanged() { async function loadEffectiveCatalog(epoch: number) { const invoke = resolveTauriInvoke(); if (!invoke) { - throw new LlmModelCatalogConfigError('本地模型服务不可用'); + throw new LlmModelCatalogConfigError('模型服务不可用'); } let config: GameCreatorAppConfigView | undefined; try { diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 3910d7e40..791026a72 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3847,7 +3847,32 @@ textarea { line-height: 1.5; } -/* 项目版本标签:值就是 AGC 工程内部版本序数(资源总览「项目版本」栏目里最新那张卡的「版本 N」)。 */ +/* 项目版本字段:值是平台按作者视图派发的下一个版本号,作者不能填。 + 外壳与 `.game-distribution-publish-panel__fields label` 同形(grid + 6px 间距), + 值用禁用观感(灰底 + 弱化文字 + not-allowed),与同组其它可编辑输入一眼分得开。 */ +.game-distribution-publish-panel__version-field { + display: grid; + gap: 6px; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 700; +} + +.game-distribution-publish-panel__fields + .game-distribution-publish-panel__version-value { + background: rgb(244 238 232 / 88%); + color: var(--platform-text-muted); + cursor: not-allowed; +} + +.game-distribution-publish-panel__fields + .game-distribution-publish-panel__version-value:focus-visible { + border-color: var(--platform-subpanel-border); + outline: 2px solid rgb(168 102 61 / 20%); + outline-offset: 1px; +} + +/* 项目版本标签:值是**平台**这次发布会用的版本号(作者视图最大版本号 + 1)。 */ .game-distribution-publish-panel__version-label { display: flex; align-items: center; @@ -10654,14 +10679,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 { @@ -11485,29 +11505,331 @@ button.design-workspace-tree__entry:hover, color: var(--platform-text-soft, #6b7280); font-size: 12px; } +/* ============================================================ + 模型菜单的**共享样式面**(任何宿主都拿同一套观感) + ============================================================ + 选择器只用组件自带类名(`.conversation-model-select` 一族),不带宿主祖先链:工作台输入区、 + 首页输入区等**任何** `ConversationModelSelect` 实例都得到同一套浮层观感(#600 复核后的口径)。 + 宿主专属的东西——工作台输入区的锚点、与工具栏的几何关系、强度区布局——留在各自作用域; + 这里只放卡片与列表本身,两边不各抄一份。 + 观感:卡片 16px 圆角 / 12px 内边距 / 极淡描边 / 既有暖色投影 token;列表行 34px、行圆角 9px、 + hover 极淡暖底、选中是轻量 pill;滚动条 6px 且平时透明(悬停控件才显形)。 + 颜色全部取 `--platform-*` token,不引入新色值。 */ + +/* 卡片:合并形态(带强度区)用外壳当卡片,独立形态菜单自己就是卡片——两处同一套值。 + 独立形态的锚点沿用上面那条基础规则(`position: absolute; right: 0; bottom: calc(100% + 8px)`), + 不在这里重复。 */ +.conversation-model-select .conversation-model-menu-shell, +.conversation-model-select .conversation-model-menu { + display: flex; + flex-direction: column; + width: max-content; + min-width: min(260px, calc(100vw - 24px)); + max-width: min(320px, calc(100vw - 24px)); + /* 独立形态:卡片自己就是滚动区,194 = 170 行高 + 上下各 12 内边距,与合并形态同高。 */ + max-height: min(194px, calc(100vh - 24px)); + padding: 12px; + border: 1px solid var(--platform-line-soft); + border-radius: 16px; + background: var(--platform-input-fill); + box-shadow: var(--platform-desktop-hover-shadow); + overflow: auto; + scrollbar-width: thin; + scrollbar-color: var(--platform-subpanel-border) transparent; + pointer-events: auto; +} + +/* 外壳里:强度区与列表之间只留 6px;列表只负责滚动与截断,卡片观感归外壳。 + 里层这几条必须带 `!important`:基础规则里那条 `.conversation-model-menu` 用 + `background: … !important` 声明了菜单底色(它服务的是"菜单自己就是卡片"的旧形态)。 */ +.conversation-model-select .conversation-model-menu-shell { + /* 锚点与独立形态的菜单同一套值(贴锚点右缘、向上展开、距锚点 8px):这是**组件级**的 + 弹层朝向,任何宿主都该一致;宿主只决定"锚点容器"在哪(首页 `.home-input-model-select`、 + 工作台控制排里的 `.conversation-model-select` 都是 `position: relative`)。 */ + position: absolute; + right: 0; + bottom: calc(100% + 8px); + z-index: 20; + gap: 6px; + /* 合并形态的高度由内容决定(强度区 + 列表各自的上下界已经定死), + 不再套用上面那条"卡片自己就是滚动区"的 194px 上限——那会把列表挤矮。 */ + max-height: none; +} + +.conversation-model-select + .conversation-model-menu-shell + .conversation-model-menu { + position: static !important; + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + /* 170 = 5 行 × 34px:不会停在半行上;再长就滚动(细滚动条,悬停显形)。 */ + max-height: 170px !important; + padding: 0 !important; + border: 0 !important; + border-radius: 12px !important; + background: transparent !important; + box-shadow: none !important; +} + +/* 行:34px 高、9px 圆角、hover 极淡暖底,选中是轻量 pill(不做重色块)。 + 基础规则里同属性的旧值用 `!important` 声明,这里按同一档 `!important` 覆盖。 */ +.conversation-model-select .conversation-model-menu button { + min-height: 34px !important; + padding: 0 10px !important; + border-radius: 9px !important; +} + +/* 选项行:名字在左、勾在右(`space-between`);刷新行不是选项,保持**左对齐** + ——图标与「刷新模型列表」文本贴最左成一组,文本左边缘 = 图标右边缘 + 7px(见 + `.conversation-model-menu-refresh`)。这条按 `[role='option']` 限定,避免再波及刷新行。 */ +.conversation-model-select .conversation-model-menu button[role='option'] { + justify-content: space-between !important; +} + +.conversation-model-select .conversation-model-menu button:hover:not(:disabled), +.conversation-model-select .conversation-model-menu button:focus-visible { + background: var(--platform-nav-item-icon-fill) !important; + color: var(--platform-text-strong) !important; +} + +.conversation-model-select + .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` 不截断)。 */ +.conversation-model-select .conversation-model-menu-option-main { + min-width: 0; + overflow: hidden; +} + +.conversation-model-select .conversation-model-menu-option-main > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-model-select .conversation-model-menu::-webkit-scrollbar { + width: 6px; +} + +.conversation-model-select .conversation-model-menu::-webkit-scrollbar-track { + background: transparent; +} + +/* 静止时不画滚动条,鼠标进控件(或键盘焦点在控件内)才显形。 */ +.conversation-model-select .conversation-model-menu::-webkit-scrollbar-thumb { + border-radius: 999px; + background: transparent; +} + +.conversation-model-select:hover + .conversation-model-menu::-webkit-scrollbar-thumb, +.conversation-model-select + .conversation-model-menu:focus-within::-webkit-scrollbar-thumb { + background: var(--platform-subpanel-border); +} + +/* ---- 强度区(组件自带类名;任何宿主共用同一份,没有宿主分支) ---- */ + +/* 强度区:菜单顶部,与列表之间只用留白分开(不画横贯分隔线),标题左右层级分明。 + 左右不留内边距:滑块(轨道 + 圆钮)直接铺满卡片内容宽度,填充段与圆钮两端与轨道两端贴齐 + —— 这条内边距原先只是为档位点排布留位,点删掉后必须归零,否则两端各多 6px 空白。 */ +.project-chat-reasoning-effort.is-section { + display: grid; + /* 单列铺满:`auto` 轨道在这里只按 max-content 定尺,滑块会被压回固有宽度。 */ + grid-template-columns: minmax(0, 1fr); + justify-items: stretch; + width: 100%; + align-self: stretch; + gap: 10px; + padding: 4px 0 2px; +} + +/* 滑块:圆钮(20px)与轨道(6px)叠在一起,命中与按 `step` 吸附由原生 range 负责; + 档位圆点删掉后这里不再需要相对定位的容器(点是唯一的绝对定位子元素)。 */ +.project-chat-composer-strength-slider { + display: grid; + grid-template-columns: minmax(0, 1fr); + width: 100%; +} + +.project-chat-composer-strength-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.project-chat-composer-strength-title { + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 600; +} + +.project-chat-composer-strength-range { + /* 圆钮(input 的 thumb)要压在档位点之上:实心钮不该被它下面那颗点透出来。 */ + position: relative; + z-index: 1; + width: 100%; + height: 26px; + margin: 0; + appearance: none; + border: 0; + background: transparent; + cursor: pointer; +} + +.project-chat-composer-strength-range::-webkit-slider-runnable-track { + height: 6px; + border-radius: 999px; + /* 已选段终点 = **圆钮中心**(`--strength-ratio` 是无单位档位比例;圆钮中心 = + 10px + ratio * (100% - 20px),两端各留一个圆钮半径)。原先终点写成 20px + …(= 圆钮 + **右缘**),亚像素 / 圆角 / 描边一有误差就在圆钮右侧露出一小截橙色。 */ + background: linear-gradient( + to right, + var(--platform-accent) 0 + calc(10px + var(--strength-ratio, 0) * (100% - 20px)), + var(--platform-subpanel-border) + calc(10px + var(--strength-ratio, 0) * (100% - 20px)) 100% + ); +} + +.project-chat-composer-strength-range::-webkit-slider-thumb { + width: 20px; + height: 20px; + margin-top: -7px; + appearance: none; + border: 1px solid var(--platform-subpanel-border); + border-radius: 999px; + /* 实心暖白圆钮:用 `--platform-panel-fill`(两个主题都是不透明面板色)而不是半透明的 + `--platform-input-highlight`——后者浅色主题只有 90% 不透明,下面的已选段会透出来, + 看起来就是个"靶心"。不再画中心点、也不用主色描边成空心环。 */ + background: var(--platform-panel-fill); + /* 圆钮柔影:从品牌主色派生(`color-mix`,不新造 token)。 */ + box-shadow: + 0 2px 6px color-mix(in srgb, var(--platform-accent) 24%, transparent), + 0 1px 2px color-mix(in srgb, var(--platform-accent) 16%, transparent); +} + +.project-chat-composer-strength-range:hover:not( + :disabled + )::-webkit-slider-thumb { + transform: scale(1.06); + /* 悬停把暖影压深一档(同族派生,不新造色值)。 */ + box-shadow: + 0 3px 9px color-mix(in srgb, var(--platform-accent) 30%, transparent), + 0 1px 3px color-mix(in srgb, var(--platform-accent) 20%, transparent); +} + +.project-chat-composer-strength-range:active:not( + :disabled + )::-webkit-slider-thumb { + transform: scale(0.96); +} + +.project-chat-composer-strength-range:focus-visible { + outline: none; +} + +.project-chat-composer-strength-range:focus-visible::-webkit-slider-thumb { + /* 「白钮 + 间隙 + 环」三明治:先铺一圈 2px 卡片底色当间隙,再叠焦点环——否则 3px 环 + 紧贴 20px 白钮会被读成"空心钮/靶心"。间隙色只能用**颜色** token + (`--platform-panel-fill` 是渐变,写进 `box-shadow` 会让整条声明失效、连环和影子一起丢), + 所以取 `--platform-input-fill`;环色与厚度不变,对比度不回退。 */ + box-shadow: + 0 0 0 2px var(--platform-input-fill), + 0 0 0 4px var(--platform-input-focus-ring), + 0 2px 6px color-mix(in srgb, var(--platform-accent) 24%, transparent); +} + +.project-chat-composer-strength-range:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +/* Firefox 的滑块伪元素与 WebKit 不同名,同一套取值再来一份。 */ +.project-chat-composer-strength-range::-moz-range-track { + height: 6px; + border-radius: 999px; + background: var(--platform-subpanel-border); +} + +.project-chat-composer-strength-range::-moz-range-progress { + height: 6px; + border-radius: 999px; + background: var(--platform-accent); +} + +.project-chat-composer-strength-range::-moz-range-thumb { + width: 20px; + height: 20px; + /* 与 WebKit 同一套实心钮:暖白底 + 极淡描边 + 柔和暖影(不用主色描边成空心环)。 */ + border: 1px solid var(--platform-subpanel-border); + border-radius: 999px; + background: var(--platform-panel-fill); + box-shadow: + 0 2px 6px color-mix(in srgb, var(--platform-accent) 24%, transparent), + 0 1px 2px color-mix(in srgb, var(--platform-accent) 16%, transparent); +} + +/* 高对比(forced-colors)模式下 thumb 的 box-shadow 会被系统丢弃,焦点会完全不可见, + 这里回退到系统色 outline;只在 forced-colors 生效,亮色观感不受影响。 */ +@media (forced-colors: active) { + .project-chat-composer-strength-range:focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } +} + +/* Firefox 的焦点:先关掉轨道自带的 focus-outer 描边,再给圆钮同一套「间隙 + 环」。 */ +.project-chat-composer-strength-range::-moz-focus-outer { + border: 0; +} + +.project-chat-composer-strength-range:focus-visible::-moz-range-thumb { + box-shadow: + 0 0 0 2px var(--platform-input-fill), + 0 0 0 4px var(--platform-input-focus-ring), + 0 2px 6px color-mix(in srgb, var(--platform-accent) 24%, transparent); +} + +/* 档位圆点已删除(用户口径「很丑」,第一选择就是删):连同 `space-between` 排布、 + `z-index: 2` 抬层、`.is-active{opacity:0}` 与点的 `color-mix` 底色一起删掉, + 容器上那条「圆钮半径 − 半点宽」的 `padding: 0 7px` 也随之作废(滑块左右不再留白)。 + 历史成因(点被 6px 轨道盖住、与圆钮两端错位 ±12.2px)由 Git 与 pitfalls 追溯。 */ + +.project-chat-composer-strength-value { + color: var(--platform-text-soft); + font-size: 11px; + text-align: right; +} + .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer .conversation-model-select .conversation-model-menu button { + /* 只保留"抵消宿主通用按钮样式"的几何:工作台输入区的 `button` 有 28px 方块一族规则, + 尺寸 / 圆角 / 配色交给上面的共享样式面,避免宿主再抄一份观感。 + 这里**不再**统一写 `justify-content: space-between`——那样会连刷新行一起推到两端 + (图标在左、文案被顶到右)。对齐由共享面的「选项行 space-between / 刷新行 flex-start」 + 两条规则各管各的。 */ position: static !important; display: flex !important; align-items: center !important; - justify-content: space-between !important; width: 100% !important; min-width: 0 !important; - min-height: 32px !important; - height: 32px !important; margin: 0 !important; - padding: 0 9px !important; border: 0 !important; - border-radius: 6px !important; - background: transparent !important; - color: var(--platform-text-base, #374151) !important; box-shadow: none !important; font-size: 12px !important; - font-weight: 500 !important; white-space: nowrap !important; } .game-workbench-chat @@ -11528,8 +11850,6 @@ button.design-workspace-tree__entry:hover, .conversation-model-select .conversation-model-menu button[aria-selected='true'] { - background: #f3f1ee !important; - color: var(--platform-text-strong, #1f2937) !important; outline: none !important; } .conversation-model-menu-divider { @@ -11538,8 +11858,10 @@ button.design-workspace-tree__entry:hover, background: var(--platform-line-soft, #ece9e5); } .conversation-model-menu-refresh { + /* 刷新行不是选项:图标 + 文本贴最左成一组(选项行才用 `space-between`)。 + `gap` 取卡片内既有的 8px 节奏(与工具条列间距同值,不新造数)。 */ justify-content: flex-start !important; - gap: 7px !important; + gap: 8px !important; color: var(--platform-text-soft, #6b7280) !important; } .conversation-model-menu-refresh:hover, @@ -11588,7 +11910,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 @@ -11641,8 +11965,12 @@ 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; + /* 单行工具条下限 252 内缩在盒内(左右各 12px 内边距 + 各 1px 边框)→ 外壳最小宽度 + 252 + 24 + 2 = **278**:低于它时不换行、不重叠,内容溢出可见(面板不裁切)。 */ + min-width: 278px; + /* 控件行 / 状态层之间靠行间距分开(状态层没有内容时为 0 高,不占行); + `gap` 同时给队列 chip 与输入盒之间留出同一档间距。 */ + gap: 8px; align-items: stretch; position: relative; right: 0; @@ -11667,8 +11995,10 @@ button.design-workspace-tree__entry:hover, .project-chat-composer.is-direct-codex .resource-reference-input { width: 100%; - min-height: 56px; - padding: 0 0 4px; + /* 盒子几何(高度下界 / 内边距)在这一条里定死:后面那条只补网格与行距, + 不再重复声明,避免"同权重靠后写取胜"的顺序依赖。 */ + min-height: 0; + padding: 0; border: 0; border-radius: 0; background: transparent; @@ -11706,11 +12036,8 @@ button.design-workspace-tree__entry:hover, color: var(--platform-text-soft); } -.game-workbench-layout--design .project-chat-composer-controls, -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer.is-direct-codex - .project-chat-composer-controls { +/* 策划控件排:保持改动前的几何(不改策划面的间距与表现)。 */ +.game-workbench-layout--design .project-chat-composer-controls { display: flex; align-items: center; justify-content: space-between; @@ -11720,8 +12047,32 @@ button.design-workspace-tree__entry:hover, pointer-events: auto; } +/* Direct 控件排:**唯一一行控件,永远不换行**(`@` / `+` / AI 润色 / 模型 / 语音 / 发送 平铺)。 + 单行下限 = Σ 控件最小宽度 + 列间距:`@` 28 + `+` 28 + 润色 28 + 模型钮 64(`min-width: 64px`, + 名字走省略号)+ 语音 28 + 发送 28 = 204,加 5 个 8px 列间距 = 244;再留 8px 余量 → **252** + (夹具实测:自然宽度 290.4、下限 244)。低于 252 时不换行、不重叠,内容溢出可见。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + gap: 6px 8px; + min-width: 252px; + 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 @@ -11734,16 +12085,33 @@ button.design-workspace-tree__entry:hover, .project-chat-composer-controls-right { display: flex; align-items: center; - gap: 8px; + gap: 6px 8px; min-width: 0; } +/* Direct 左侧动作组(`@` 引用 / `+` 附件 / AI 润色钮)同样不换行:工具条只有一行。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls-left { + /* 左组是固定宽度的动作钮(`@` / `+` / 润色),不参与收缩:收缩会让它们溢出、 + 压到右侧的模型钮上;要收缩就由右组的模型钮(名字走省略号)承担。 */ + flex: 0 0 auto; + flex-wrap: nowrap; +} + +/* 右侧那一组是**不可再压的控件**(推理档 / 模型 / 语音 / 发送):它原来写死 + `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; } /* 左侧引用按钮保留输入盒的内边距,点击区域不向外偏移。 */ @@ -11779,14 +12147,28 @@ button.design-workspace-tree__entry:hover, 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; +} + .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .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; } @@ -12059,18 +12441,11 @@ button.design-workspace-tree__entry:hover, color: #a82f2f; } -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer.is-direct-codex - .project-chat-reasoning-effort { - display: inline-flex; - align-items: center; - flex: 0 1 auto; - min-width: 0; - gap: 6px; -} - -/* 队列 chip:压在输入区上方,横向排布、超出换行。 */ +/* 独立「推理档」控件已经并入模型菜单(`.project-chat-reasoning-effort.is-section`, + 观感见文件上方「模型菜单的共享样式面」);这里那条给它排版的 + `.game-workbench-chat … .project-chat-reasoning-effort` 规则(`display: inline-flex` 一族) + 会以更高特异性盖掉共享面的 `display: grid`,已按「删死重」删除。 + 队列 chip:压在输入区上方,横向排布、超出换行。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -12079,7 +12454,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; } @@ -12134,22 +12510,38 @@ button.design-workspace-tree__entry:hover, font-variant-numeric: tabular-nums; } -.game-workbench-layout--design .project-chat-composer-notice { - max-height: 96px; - overflow: auto; - overflow-wrap: anywhere; -} - +/* 提示段落的高度上限与折行:策划面保持改动前的 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 { + max-height: 96px; + overflow: auto; + overflow-wrap: anywhere; +} + +/* 策划面:提示段落自带 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 { + margin: 0; + color: var(--platform-text-soft); + font-size: 12px; +} + /* 消息列表的最终几何,故意放在文件靠后的位置:它与上面那条同选择器同权重 (`.game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-message-list`),按"后写胜出"把下面窄屏块之前可能出现的 @@ -12250,29 +12642,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 @@ -12285,12 +12667,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; @@ -12341,7 +12724,9 @@ button.design-workspace-tree__entry:hover, grid-column: 1; align-self: center; min-width: 0; - margin-top: 0; + /* 状态行自带 6px 上间距(输入盒的 `gap` row 分量为 0:没内容时不留幻影行距, + 有内容时由这里给间距)。不要把 `margin-top: 0` 写回来——它会盖掉这一条。 */ + margin-top: 6px; } /* direct-codex 的 `@` 触发钮是下面控制排里的 `.project-chat-reference-trigger`, @@ -13003,33 +13388,55 @@ button.design-workspace-tree__entry:hover, /* ============================================================ 右侧对话面板:输入盒内文字与控件的对齐(2026-09) ============================================================ - 这一组必须放在文件最后:输入区的网格结构与「AI 润色」按钮的位置由更早的 - `.resource-reference-input` / `.resource-reference-input-actions` 规则决定, - 同特异性下只有靠后写的才能盖回去。 + 这一组是「文字 + 区内操作」两行网格的**唯一来源**,盒子几何(内边距 / 高度下界) + 则由前面那条六类 `.… .resource-reference-input` 规则定死:两边不再声明同一个属性, + 所以**不依赖源码先后**(放在文件末尾只是为了集中阅读与就近维护)。 - 输入区是两列网格(文本列 + 操作列),右侧那枚「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; + /* 行间距归 0:状态层没有内容时第二行高度为 0,不留幻影行距;有内容时由状态元素 + 自带的 6px 上间距提供间隔(见 `.resource-reference-input-status` 那两条规则)。 */ + gap: 0 8px; } +/* 编辑器横跨两列:文字不再被右侧 28px 的操作列长期挤窄。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .resource-reference-input - .resource-reference-input-actions { - position: absolute; - right: 0; - bottom: 0; - display: flex; - align-items: flex-end; - justify-content: flex-end; + .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; + /* 状态行自带 6px 上间距(输入盒的 `gap` row 分量为 0)。 */ + margin-top: 6px; + overflow-wrap: anywhere; } /* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。 @@ -13455,3 +13862,92 @@ details.design-agent-reasoning[open] text-align: center; white-space: pre-wrap; } + +/* ============================================================ + 输入区工具栏与状态提示(2026-10 · issue #600) + ============================================================ + **只作用于工作台 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 下限、推理档的 + `flex: 0 0 auto`、提示段落 margin 均写在前文各自那条规则里(Direct 侧用多一层 + 真实祖先提高特异性)。**本区块不依赖源码顺序**:这里要么是前文从未声明过的属性, + 要么用更长的选择器链严格盖过前文,把整块挪到文件中间结果也不变。 + ① 工具栏里的下拉控件(推理档 / 模型)要能收缩、模型名要能省略,宽度不够时靠换行 + 让位,而不是把整组推到输入盒外面去和左边的 `@` `+` 重叠; + ② 输入区不留常驻状态行:模型服务的失败走 `ComposerModelNoticeToast` 浮层提示 + (控件排容不下第二行文字,实测会把触发钮顶到行线上方,见下面 ② 段); + ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距; + ④ 思维强度并入模型菜单:底行只留模型触发钮一颗控件,强度区(原生 ``) + 嵌在菜单顶部,与模型列表共用同一张卡(`.conversation-model-menu-shell`)。 */ + +/* 工具条里的「AI 润色 / 恢复原文」挂点(`ResourceReferenceInput` 用 portal 渲染进来): + 位于**右组发送钮之前**——润色钮与发送钮同为圆角矩形按钮,相邻成组;空输入态只是不显示 + 按钮,不占额外行。DOM 顺序即 `模型 → 麦克风 → 润色 → 发送`(Tab / 读屏顺序同视觉)。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls-actions { + display: inline-flex; + /* 不参与收缩:里面的润色钮是 28px 固定方块,槽位被压窄会让它溢出去压到 `+`。 */ + flex: 0 0 auto; + align-items: center; + min-width: 0; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls-actions + .resource-reference-input-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +/* ① 下拉控件:可收缩(模型名超长走省略号);整条工具条不换行,控件排只占一行。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls + .conversation-model-select { + flex-wrap: nowrap; + justify-content: flex-end; +} + +.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; +} + +/* 上面两条给独立「推理档」控件排版的规则(`flex-wrap` / `justify-content` / 提示整行) + 随该控件并入模型菜单一起退役:现役元素是 `.project-chat-reasoning-effort.is-section` + (网格,观感见「模型菜单的共享样式面」),`flex-*` 对它无效。 */ + +/* ② 模型控件的错误 / 状态提示:**不在控件里渲染**。 + 这一段原来把 `.conversation-model-select [role='alert']` 从 `bottom: 36px` 浮层收回 + 控件排内独占一行;实测这样会让整颗模型控件变成两行高,在 `align-items: center` 的 + 工具条里把触发钮顶到行线上方、提示落到行线下方(用户复核反馈的"完全没对齐")。 + 现在失败与状态一律走组件自己的底部浮层(`components/common/composerNotice.ts` + + `ComposerNoticeToast`,任何宿主都一样):控件排内不再有这两颗元素,本条规则恒无命中对象,已删。 */ + +/* ③ 状态提示区(队列 chip、提示段落)与输入盒、工具栏共用同一档行间距: + 队列 chip 与提示段落的 `margin` 都写回前文各自那条规则。 */ + +/* ④ 思维强度**并入模型菜单**:底行只留模型触发钮一颗控件,强度区嵌在菜单顶部。 + 外壳(`.conversation-model-menu-shell`)的锚点与朝向是**组件级**的(见文件上方「模型菜单的 + 共享样式面」:`position: absolute; right: 0; bottom: calc(100% + 8px)`,首页那颗同一套); + 这里不再有工作台专属的浮层 / 卡片 / 强度区规则。 + 强度区里是原生 ``,自带 `role="slider"`、`aria-valuemin/max/now` + 与 ←/→/Home/End,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 */ + +/* 强度区的观感(标题 / 滑块 / 档位点)同样已经抬到文件上方「模型菜单的共享样式面」一节, + 选择器只用组件自带类名(`.project-chat-reasoning-effort.is-section` 一族), + 首页与工作台共用同一份;这里不再有工作台专属的强度区规则。 */ diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 64307288d..97dab66c9 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -16,6 +16,7 @@ import type { ProjectStartMode } from '../../app/types'; import { resolveHomeCreationFailureMessage } from '../../features/app-shell/homeCreationFailure'; import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect'; import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; +import { ComposerReasoningEffortSelect } from '../project-development/chat/components/DirectProjectComposer/ComposerControls'; import RichInputArea, { UploadButton } from './components/RichInputArea'; import { richTextToAttachments, @@ -297,6 +298,14 @@ export default function HomeView({ + } /> ( @@ -319,9 +332,24 @@ export function ComposerReasoningEffortSelect({ ); const [saving, setSaving] = useState(false); const [notice, setNotice] = useState(''); + /* + 拖动中的预览档位:`input` 只更新它(视觉立刻跟手),松手 / 抬键才真正落盘。 + 这样写盘不会发生在拖动过程中——写盘会置 `saving`,一旦用它 disable 掉原生 range, + 浏览器会当场中断正在进行的拖拽(实测:拖动 6 步只收到 1 个 input 事件, + 圆钮停在第一格不动)。 + */ + const [previewEffort, setPreviewEffort] = + useState(null); + const previewEffortRef = useRef(null); const writeChainRef = useRef>(Promise.resolve()); const mountedRef = useRef(true); const options = composerReasoningEffortOptions(); + const shownEffort = previewEffort ?? effort; + const activeIndex = Math.max( + 0, + options.findIndex((option) => option.value === shownEffort), + ); + const activeLabel = options[activeIndex]?.label ?? shownEffort; useEffect(() => { mountedRef.current = true; @@ -330,34 +358,92 @@ export function ComposerReasoningEffortSelect({ }; }, []); + /* + 落盘时机交给原生 `change`:range 的 `change` 就是"值被提交"那一刻(鼠标松手 / + 键盘一次调整),`input` 才是拖动过程中的连续事件。React 的 `onChange` 对 range + 映射的是 `input`(连续),所以这里直接挂原生监听,避免把连续事件当成提交。 + */ + const rangeRef = useRef(null); + const commitPreviewRef = useRef<() => void>(() => undefined); useEffect(() => { - let cancelled = false; + const node = rangeRef.current; + if (!node) { + return undefined; + } + const handleChange = () => commitPreviewRef.current(); + node.addEventListener('change', handleChange); + return () => { + node.removeEventListener('change', handleChange); + }; + }, []); + + /* + 读一次配置:种「最近落盘值」并在空闲时回显。 + —— 配置有**别的写入者**(运行配置对话框改 `llm.reasoningEffort` 后 `notifyLlmConfigChanged()`, + 也可能在窗口失焦期间被改)。只在挂载时读一次会让「最近落盘值」过期:用户重选**同一档**会被 + 去重跳过,界面显示 A、磁盘却是 B。所以挂载、`LLM_CONFIG_CHANGED_EVENT`、窗口重新获得焦点 + 时都重读一次(**只读不写盘**),重新种 `persistedEffortRef`。 + —— 写盘在途或拖动预览未提交时不回显(回读拿到的还是旧值,回显会让界面闪一下)。 + */ + const writeInFlightRef = useRef(0); + const readEffortRef = useRef<(silent?: boolean) => void>(() => undefined); + readEffortRef.current = (silent = false) => { const invoke = resolveTauriInvoke(); if (!invoke) { - return undefined; + return; } void invoke('read_game_creator_app_config') .then((view) => { - if (cancelled || !mountedRef.current) return; - setEffort( - normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort), + if (!mountedRef.current) return; + const read = normalizeComposerReasoningEffort( + view?.config?.llm?.reasoningEffort, ); + // 回读值就是"磁盘上的档位":把它登记成最近落盘值,之后提交同一档位不再写盘。 + persistedEffortRef.current = read; + if (writeInFlightRef.current > 0 || previewEffortRef.current !== null) { + return; + } + setEffort(read); }) .catch(() => { - if (cancelled || !mountedRef.current) return; + if (!mountedRef.current || silent) return; setNotice('推理档读取失败'); }); + }; + + useEffect(() => { + readEffortRef.current(); + }, []); + + useEffect(() => { + // 外部写入者改档 / 窗口重新获得焦点:重读一次让「最近落盘值」跟上,避免去重吞掉真实改档。 + const reread = () => readEffortRef.current(true); + window.addEventListener(LLM_CONFIG_CHANGED_EVENT, reread); + window.addEventListener('focus', reread); return () => { - cancelled = true; + window.removeEventListener(LLM_CONFIG_CHANGED_EVENT, reread); + window.removeEventListener('focus', reread); }; }, []); + /** 最近一次**落盘成功**的档位:重复提交同一档位不再写一次(键盘长按/多事件源)。 */ + const persistedEffortRef = useRef(null); + /** 卸载 flush 需要读到最新档位,不能依赖渲染闭包。 */ + const effortRef = useRef(effort); + effortRef.current = effort; + function selectEffort(next: GameCreatorLlmReasoningEffort) { const invoke = resolveTauriInvoke(); if (!invoke) { setNotice('需要在 Tauri App 内运行'); return; } + if (persistedEffortRef.current === next) { + // 已经就是这个值:不再写盘(键盘 auto-repeat / 多个事件源汇到同一档位时只写一次)。 + setEffort(next); + setNotice(''); + return; + } const previous = effort; setEffort(next); setNotice(''); @@ -373,11 +459,13 @@ export function ComposerReasoningEffortSelect({ ); void run .then((view) => { + const echoed = normalizeComposerReasoningEffort( + view?.config?.llm?.reasoningEffort, + ); + persistedEffortRef.current = echoed; if (!mountedRef.current) return; // 以落盘后的回读值为准,避免界面显示一个没有真正保存的档位。 - setEffort( - normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort), - ); + setEffort(echoed); }) .catch(() => { if (!mountedRef.current) return; @@ -391,63 +479,185 @@ export function ComposerReasoningEffortSelect({ }); } + /* + 拖动中被 Esc / 卸载打断时,预览值还没落盘:在卸载 cleanup 里主动 flush 一次 + (只读 ref,不再碰已卸载组件的 state),否则这次拖动会静默丢掉。 + */ + const flushPreviewRef = useRef<() => void>(() => undefined); + flushPreviewRef.current = () => { + const pending = previewEffortRef.current; + previewEffortRef.current = null; + if (!pending || pending === effortRef.current) { + return; + } + selectEffort(pending); + }; + useEffect( + () => () => { + flushPreviewRef.current(); + }, + [], + ); + + /** 状态文案(推理档读取/保存失败)同样走组件自己的浮层:任何宿主都不留常驻状态行。 */ + const reportedNoticeRef = useRef(''); + useEffect(() => { + if (notice === '') { + reportedNoticeRef.current = ''; + return; + } + if (reportedNoticeRef.current === notice) { + return; + } + reportedNoticeRef.current = notice; + publishComposerNotice(notice, 'neutral'); + }, [notice]); + + // 这个输入区里的 toast 宿主(与模型选择器共用单例,同一时刻只有一条浮层)。 + const composerNotice = useComposerNoticeHost(); + + /** 拖动中的连续事件只改预览:视觉立刻跟手,落盘留到松手那一刻。 */ + function previewFromEvent(event: { currentTarget: HTMLInputElement }) { + const next = options[Number(event.currentTarget.value)]; + if (!next || next.value === shownEffort) { + return; + } + previewEffortRef.current = next.value; + setPreviewEffort(next.value); + } + + /** 松手 / 抬键 / 失焦时把预览落盘(一个手势只写一次)。 */ + function commitPreview() { + const next = previewEffortRef.current; + previewEffortRef.current = null; + setPreviewEffort(null); + if (next && next !== effort) { + selectEffort(next); + } + } + commitPreviewRef.current = commitPreview; + + /* + 下拉形态(策划等宿主原样沿用):与模型选择器同一套观感,复用 `conversation-model-*` + 的触发钮与浮层样式。 + */ + const dropdownSelect = ( + + setOpen((current) => !current)} + > + + {activeLabel} + + + {open ? ( + + {options.map((option) => ( + { + setOpen(false); + selectEffort(option.value); + }} + > + + {option.label} + + {option.value === effort ? ( + + ) : null} + + ))} + + ) : null} + + ); + + /* + 强度区(Direct 输入盒):嵌在模型菜单顶部,底行因此只留一颗控件。 + 原生 `` 自带 `role="slider"` 与 ←/→/Home/End 键盘行为, + 点轨道(含拖拽)按 `step` 吸附到档位,`aria-valuetext` 报中文档位名; + 浮层的开合与 Esc / 点外部关闭由模型菜单自己负责(本区不再自带触发钮与浮层)。 + */ + const strengthSection = ( + + + 思维强度 + + {`当前:${activeLabel}`} + + + + {/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是 + 中文档位名,读屏念的是「最高」而不是「4」。 */} + + {/* 档位圆点已删除(用户口径「很丑」,第一选择就是删):轨道本身就是刻度, + 命中与按 `step` 吸附仍由原生 range 负责,`aria-valuetext` 报中文档位名。 */} + + + ); + return ( - {/* 与模型选择器同一套观感:复用 `conversation-model-*` 的触发钮与浮层样式, - 不再用原生 ``(原生下拉是操作系统菜单,圆角/描边/选中态都跟旁边不一致)。 */} - - setOpen((current) => !current)} - > - - - {options.find((option) => option.value === effort)?.label ?? effort} - - - - {open ? ( - - {options.map((option) => ( - { - setOpen(false); - selectEffort(option.value); - }} - > - - {option.label} - - {option.value === effort ? ( - - ) : null} - - ))} - - ) : null} - - {notice ? ( - - {notice} - - ) : null} + {presentation === 'section' ? strengthSection : dropdownSelect} + {/* 状态文案只走浮层:控件里不再渲染常驻 `role="status"` 行。 */} + ); } 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 ebc11610c..01b519ba6 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 @@ -133,6 +133,9 @@ export function DirectProjectComposer({ }) { const composerRef = useRef(null); const composerRootRef = useRef(null); + /** 工具条里给「AI 润色 / 恢复原文」的挂点(`ResourceReferenceInput` 用 portal 渲染进来)。 */ + const [controlsActionsSlot, setControlsActionsSlot] = + useState(null); const pickerRef = useRef(null); const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); @@ -263,6 +266,9 @@ export function DirectProjectComposer({ placeholder="描述你的想法,或 @ 引用素材" submitSuppressed={pickerOpen} containerRef={composerRootRef} + /* 「AI 润色 / 恢复原文」排进下方工具条**发送钮左边**:与发送钮同为圆角矩形按钮、 + 相邻成组;空输入态也不再自带一行(窄面板下那一行看起来像"堆叠")。 */ + actionsPortalTarget={controlsActionsSlot} /> @@ -283,18 +289,36 @@ export function DirectProjectComposer({ /> - + } /> composerRef.current?.insertText(text)} + onTranscript={(text) => { + /* 拿到转写就把语音的提示收掉:提示是「上一次动作的回执」, + 留着会变成输入区里的常驻文本(那一排容不下常驻行)。 */ + setVoiceNotice(''); + composerRef.current?.insertText(text); + }} onNotice={setVoiceNotice} /> + {/* 润色 / 恢复原文的挂点(portal 目标):放在**发送钮左边**——它与发送钮同为 + 圆角矩形按钮,相邻成组更协调;DOM 顺序即 `模型 → 麦克风 → 润色 → 发送`, + 所以 Tab 序与读屏顺序与视觉一致。 */} + {canStopTurn ? ( { + 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) => { @@ -1042,37 +1051,557 @@ export function registerChatComposerControlTests() { }, }); - const select = within(surface).getByRole('button', { name: '推理档' }); - await waitFor(() => { - expect(select.textContent).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(); - expect(select.closest('.project-chat-composer-controls')).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(); - fireEvent.click(select); - fireEvent.click(within(surface).getByRole('option', { name: '低' })); + // 打开模型菜单:强度区在顶部,同一个菜单里还有模型列表。 + 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(within(surface).getByText('当前:高')).not.toBeNull(); + const listbox = within(surface).getByRole('listbox', { + name: '对话模型', + }); + // 滑块不能落进 listbox 子树:参数控件混进菜单角色会让读屏进菜单模式后读不到它。 + expect(listbox.contains(slider)).toBe(false); + + // 拖动 / 点档位:连续事件只改预览,松手(这里用抬键路径)才落到既有持久化通道与取值。 + fireEvent.change(slider, { target: { value: '1' } }); + fireEvent.keyUp(slider); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( 'select_game_creator_reasoning_effort', { effort: 'low' }, ); }); - // 以落盘后的回读值为准。 + // 以落盘后的回读值为准;改档后菜单保持打开(连续调节不用重开)。 await waitFor(() => { - expect( - within(surface).getByRole('button', { name: '推理档' }).textContent, - ).toContain('低'); + expect(within(surface).getByText('当前:低')).not.toBeNull(); }); 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(row as HTMLElement).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('strength slider: drag follows continuously and only commits once on release', async () => { + let stored = 'default'; + const { invoke, surface } = await openDirectCodexSurface({ + select_game_creator_reasoning_effort: (args) => { + stored = String(args?.effort ?? ''); + return gameCreatorConfigView(stored); + }, + }); + + fireEvent.click(within(surface).getByRole('button', { name: '对话模型' })); + const slider = within(surface).getByRole('slider', { + name: '思维强度', + }) as HTMLInputElement; + const writes = () => + invoke.mock.calls.filter( + ([command]) => command === 'select_game_creator_reasoning_effort', + ).length; + + // 拖动中:连续 input 必须逐步跟手;既不能把控件 disable(原生拖拽会被中断), + // 也不能每格都落盘(写盘回读会把拖动中的值顶回去)。 + // jsdom 对受控 range 的值回写与指针事件不全(不实现 PointerEvent、原生 change 不触发), + // 所以这里断言环境能看到的契约:控件保持可用、节点不被重挂载、拖动中不落盘; + // 真实的「按下 → 多步移动 → 抬起」指针序列与松手落盘在无头 Chromium 夹具里验证(见 PR 正文)。 + for (const value of ['0', '1', '2'] as const) { + fireEvent.input(slider, { target: { value } }); + expect( + within(surface).getAllByRole('slider'), + '拖动期间只应有一颗滑块', + ).toHaveLength(1); + expect( + within(surface).getByRole('slider', { name: '思维强度' }), + '滑块节点不能因 input 更新被重挂载', + ).toBe(slider); + expect( + slider.disabled, + '拖动中不应禁用滑块:禁用会中断正在进行的原生拖拽', + ).toBe(false); + expect(writes(), '拖动过程中不落盘').toBe(0); + } + + // 松手才提交一次,取值走既有通道、与旧下拉一致(jsdom 里用抬键路径代替松手,见上)。 + fireEvent.keyUp(slider); + await waitFor(() => { + expect(writes()).toBe(1); + }); + expect(invoke).toHaveBeenCalledWith( + 'select_game_creator_reasoning_effort', + { effort: 'medium' }, + ); + expect(stored).toBe('medium'); + }); + + it('strength slider: keyboard auto-repeat on one grade writes once, a new grade writes again', async () => { + // 长按 ←/→ 时浏览器会以 ~30/s 的频率重复派发 input + keyup;旧实现在每个 input 都落盘 + // (整条写链 + 回读),这里钉住"同一个已落盘档位的重复提交不再写盘",同时不吞掉真实改档。 + const writes: string[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'select_game_creator_reasoning_effort') { + writes.push(String(args?.effort ?? '')); + return gameCreatorConfigView(String(args?.effort ?? '')); + } + if (command === 'read_game_creator_app_config') { + return gameCreatorConfigView('default'); + } + return null; + }, + ); + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); + render( + React.createElement(ComposerReasoningEffortSelect, { + disabled: false, + presentation: 'section', + }), + ); + const slider = (await screen.findByRole('slider', { + name: '思维强度', + })) as HTMLInputElement; + + // 第一次改到「中」:拖动/按键松手提交一次。 + fireEvent.input(slider, { target: { value: '2' } }); + fireEvent.keyUp(slider); + await waitFor(() => { + expect(writes).toEqual(['medium']); + }); + + // 长按继续:同一档位的重复 input + keyup(中间隔着写盘回读)不再落盘。 + for (let repeat = 0; repeat < 8; repeat += 1) { + fireEvent.input(slider, { target: { value: '2' } }); + fireEvent.keyUp(slider); + } + await act(async () => { + await Promise.resolve(); + }); + expect(writes, '同一档位的重复提交只能写一次').toEqual(['medium']); + + // 换一档仍然照常写:去重不能把真实变化吞掉。 + fireEvent.input(slider, { target: { value: '4' } }); + fireEvent.keyUp(slider); + await waitFor(() => { + expect(writes).toEqual(['medium', 'max']); + }); + }); + + it('strength slider: flushes the previewed grade once when the control unmounts mid-drag', async () => { + // 拖动中被 Esc 关掉菜单 / 宿主卸载时,预览值还没落盘:卸载 cleanup 必须主动 flush 一次。 + const writes: string[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'select_game_creator_reasoning_effort') { + writes.push(String(args?.effort ?? '')); + return gameCreatorConfigView(String(args?.effort ?? '')); + } + if (command === 'read_game_creator_app_config') { + return gameCreatorConfigView('default'); + } + return null; + }, + ); + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); + const view = render( + React.createElement(ComposerReasoningEffortSelect, { + disabled: false, + presentation: 'section', + }), + ); + const slider = (await screen.findByRole('slider', { + name: '思维强度', + })) as HTMLInputElement; + + // 拖动到「中」但还没松手(只有 input,没有 change / keyup):此刻不该落盘。 + fireEvent.input(slider, { target: { value: '2' } }); + expect(writes).toEqual([]); + + view.unmount(); + await waitFor(() => { + expect(writes, '卸载 flush 只写一次').toEqual(['medium']); + }); + }); + + it('strength slider: an external config change is re-read so re-selecting that grade still commits', async () => { + // 配置还有别的写入者(`RuntimeConfigDialog` 保存后 `notifyLlmConfigChanged()`)。组件必须重读 + // 配置并重新种「最近落盘值」,否则外部把档位改成「最高」后用户再选「高」会被去重跳过: + // 界面显示高、磁盘仍是最高(旧实现在这条用例里 writes 不含 'high')。 + let stored = 'default'; + const writes: string[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_game_creator_app_config') { + return gameCreatorConfigView(stored); + } + if (command === 'select_game_creator_reasoning_effort') { + writes.push(String(args?.effort ?? '')); + stored = String(args?.effort ?? ''); + return gameCreatorConfigView(stored); + } + return null; + }, + ); + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); + render( + React.createElement(ComposerReasoningEffortSelect, { + disabled: false, + presentation: 'section', + }), + ); + const slider = (await screen.findByRole('slider', { + name: '思维强度', + })) as HTMLInputElement; + await waitFor(() => + expect(slider.getAttribute('aria-valuetext')).toBe('默认'), + ); + + // 外部改档(另一处写完配置会派发这个事件):组件重读并回显成「最高」。 + stored = 'max'; + await act(async () => { + window.dispatchEvent(new Event(LLM_CONFIG_CHANGED_EVENT)); + await Promise.resolve(); + }); + await waitFor(() => + expect(slider.getAttribute('aria-valuetext')).toBe('最高'), + ); + + // 用户改选「高」:仍然要真落盘一次(档位枚举 0=默认 1=低 2=中 3=高 4=最高)。 + fireEvent.input(slider, { target: { value: '3' } }); + fireEvent.keyUp(slider); + await waitFor(() => expect(writes).toEqual(['high'])); + expect(stored).toBe('high'); + }); + + it('strength slider: pointercancel commits the pending preview once', async () => { + // 触屏取消(pointercancel)与「松手即提交」同一口径:预览值不能滞留到下一次提交或卸载 flush。 + // jsdom 没有 PointerEvent,这里派发同名原生事件(React 的合成 handler 按事件名分发)。 + const writes: string[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'select_game_creator_reasoning_effort') { + writes.push(String(args?.effort ?? '')); + return gameCreatorConfigView(String(args?.effort ?? '')); + } + if (command === 'read_game_creator_app_config') { + return gameCreatorConfigView('default'); + } + return null; + }, + ); + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); + render( + React.createElement(ComposerReasoningEffortSelect, { + disabled: false, + presentation: 'section', + }), + ); + const slider = (await screen.findByRole('slider', { + name: '思维强度', + })) as HTMLInputElement; + await waitFor(() => + expect(slider.getAttribute('aria-valuetext')).toBe('默认'), + ); + + fireEvent.input(slider, { target: { value: '2' } }); + expect(writes, '拖动过程中不落盘').toEqual([]); + await act(async () => { + slider.dispatchEvent(new Event('pointercancel', { bubbles: true })); + await Promise.resolve(); + }); + await waitFor(() => expect(writes).toEqual(['medium'])); + }); + + it('语音提示是上一次动作的回执:拿到转写后不再常驻', async () => { + // 语音报错会写一条 `project-chat-composer-notice`(输入盒下方的独立一行)。它是「上一次动作的 + // 回执」,下一次拿到转写必须收掉——否则语音报错会常驻到组件卸载,和"输入区不留常驻文本"相冲。 + const fake = installFakeSpeechRecognition(); + try { + const { surface } = await openDirectCodexSurface(); + const mic = within(surface).getByRole('button', { name: '语音输入' }); + fireEvent.click(mic); + act(() => { + ( + fake.instances[0] as unknown as { + onerror?: (event: unknown) => void; + } + ).onerror?.({ error: 'network' }); + }); + const notice = await waitFor(() => { + const element = surface.querySelector('.project-chat-composer-notice'); + expect(element?.textContent).toContain('语音识别服务'); + return element as HTMLElement; + }); + + // 下一次拿到转写:提示收掉,输入区不留常驻行。 + act(() => { + fake.instances[0]?.emitTranscript('做一个小游戏'); + }); + await waitFor(() => { + expect( + surface.querySelector('.project-chat-composer-notice'), + ).toBeNull(); + }); + expect(notice.isConnected).toBe(false); + } finally { + fake.restore(); + } + }); + + it('AI 润色钮在工具条这一行里(DOM 位置级)', async () => { + // 声明级的 `flex-wrap: nowrap` 抓不到"渲染后仍换行":这里钉住润色钮真的挂在 + // `.project-chat-composer-controls` 里(`actionsPortalTarget` 把它 portal 进工具条), + // 而不是留在输入盒的第二行。同一行的几何(top 差值 ≤1px、两两不相交、空态没有 + // 行外按钮)由无头 Chromium 夹具实测:240/280/296/320/360/438/900 七档全部通过。 + const { surface } = await openDirectCodexSurface(); + const controls = surface.querySelector('.project-chat-composer-controls'); + const polish = surface.querySelector('.resource-reference-input-polish'); + const referenceTrigger = surface.querySelector( + '.project-chat-reference-trigger', + ); + const input = surface.querySelector('.resource-reference-input'); + expect(controls).not.toBeNull(); + expect(polish).not.toBeNull(); + expect(referenceTrigger).not.toBeNull(); + expect(input).not.toBeNull(); + expect(controls?.contains(polish as Node)).toBe(true); + // 润色钮在**右组、发送钮左边**(与发送同为圆角矩形按钮,相邻成组): + // 挂点父链是 `-controls-right`,DOM 顺序里润色紧挨发送之前(Tab/读屏顺序同视觉)。 + const polishButton = polish as HTMLElement | null; + const slot = polishButton?.closest( + '.project-chat-composer-controls-actions', + ); + expect(slot?.parentElement?.className).toContain( + 'project-chat-composer-controls-right', + ); + const sendButton = within(surface).getByRole('button', { name: '发送' }); + expect(slot?.nextElementSibling).toBe(sendButton); + expect( + Boolean( + polishButton && + polishButton.compareDocumentPosition(sendButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ), + '润色钮必须排在发送钮之前', + ).toBe(true); + expect(input?.contains(polishButton)).toBe(false); + // 空输入态:工具条之外不再有第二行控件(润色状态行只在有内容时渲染)。 + expect( + surface.querySelectorAll( + 'form.project-chat-composer button:not(.conversation-model-menu *)', + ), + ).toHaveLength(6); + // 触发钮最窄可缩到 64px、名字靠省略号收住:必须带 `title`,否则截断后无从查看。 + const modelTrigger = within(surface).getByRole('button', { + name: '对话模型', + }); + expect(modelTrigger.getAttribute('title')).toBe( + modelTrigger.textContent?.trim(), + ); + }); + + 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('routes model-list notices to the toast and keeps the Direct composer free of status rows', async () => { + // 刷新模型列表会写一条 notice(「模型列表已刷新」)。toast 形态下它也必须走浮层: + // 输入区子树里既不能留常驻 `role="alert"`(失败),也不能留常驻 `role="status"`(状态)。 + const { surface } = await openDirectCodexSurface({ + load_game_creator_llm_models: () => ({ + defaultModelId: 'quality', + models: [{ id: 'quality', displayName: '高质量', agentMode: 'codex' }], + revision: 23, + }), + }); + + fireEvent.click(within(surface).getByRole('button', { name: '对话模型' })); + fireEvent.click( + within(surface).getByRole('button', { name: '刷新模型列表' }), + ); + + const toast = await waitFor(() => { + const element = document.querySelector( + '[data-composer-model-toast="true"]', + ); + expect(element?.textContent).toContain('模型列表已刷新'); + return element as HTMLElement; + }); + // 浮层挂在输入区之外(portal 到 body)。 + expect(toast.closest('.project-chat-composer.is-direct-codex')).toBeNull(); + + const composer = surface.querySelector( + '.project-chat-composer.is-direct-codex', + ); + expect(composer).not.toBeNull(); + expect(composer?.querySelector('[role="status"]')).toBeNull(); + expect(composer?.querySelector('[role="alert"]')).toBeNull(); + expect(composer?.textContent).not.toContain('模型列表已刷新'); + // 目录缓存是模块级的:这条用例装配过一份成功目录,不清掉会漏给后面的用例。 + resetLlmModelCatalogCacheForTest(); + }); + + it('reports one newly appeared model failure and never repeats the same message', async () => { + 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 }, + }); + const view = render( + React.createElement(ConversationModelSelect, { disabled: false }), + ); + + // 组件自己弹浮层:底部 toast 一条,控件子树里没有常驻 `role="alert"` 行。 + const toastOf = () => + document.querySelector('[data-composer-model-toast="true"]'); + await waitFor(() => { + expect(toastOf()?.textContent).toContain('模型服务不可用'); + }); + const firstToast = toastOf(); + expect( + document.querySelectorAll('[data-composer-model-toast="true"]'), + ).toHaveLength(1); + // 常驻行(控件里那两颗)没了;浮层自身的 `role="alert"` 在 portal 里,属于 toast 本体。 + expect(view.container.querySelector('[role="alert"]')).toBeNull(); + expect(view.container.querySelector('[role="status"]')).toBeNull(); + + // 同一条失败再次同步目录(打开下拉会触发一次)不重复弹。 + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await act(async () => { + await Promise.resolve(); + }); + expect( + document.querySelectorAll('[data-composer-model-toast="true"]'), + ).toHaveLength(1); + expect(toastOf()).toBe(firstToast); + + // 失败清空(目录恢复正常)后再出现,可以再弹一次:节点换新(每次提示自增 id)。 + resetLlmModelCatalogCacheForTest(); + failing = false; + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await waitFor(() => { + expect(toastOf()?.textContent).toContain('模型列表已刷新'); + }); + resetLlmModelCatalogCacheForTest(); + failing = true; + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await waitFor(() => { + expect(toastOf()?.textContent).toContain('模型服务不可用'); + expect(toastOf()).not.toBe(firstToast); + }); + expect( + document.querySelectorAll('[data-composer-model-toast="true"]'), + ).toHaveLength(1); }); it('appends the dictated transcript after the draft typed into the composer', async () => { 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..f533c5c33 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', { @@ -384,7 +387,24 @@ export function registerDesignAgentSurfaceTests() { await waitFor(() => expect(reasoning.textContent).toContain('高')); fireEvent.click(reasoning); fireEvent.click(screen.getByRole('option', { name: '低' })); - await screen.findByText('推理档保存失败'); + // 失败文案走组件自己的底部 toast(portal 在 body),策划输入区子树里不留常驻行。 + const toast = await waitFor(() => { + const element = document.querySelector( + '[data-composer-model-toast="true"]', + ); + expect(element?.textContent).toContain('推理档保存失败'); + return element as HTMLElement; + }); + expect( + document.querySelectorAll('[data-composer-model-toast="true"]'), + ).toHaveLength(1); + expect(toast.querySelector('[role]')?.getAttribute('role')).toBe('status'); + const effortControl = document.querySelector( + '.project-chat-reasoning-effort', + ); + expect(effortControl).not.toBeNull(); + expect(effortControl?.querySelector('[role="status"]')).toBeNull(); + expect(effortControl?.contains(toast)).toBe(false); expect(reasoning.textContent).toContain('高'); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 7e778ceae..6b5bd81f0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1,6 +1,15 @@ +import { readFileSync } from 'node:fs'; + import { HOME_CREATION_AFTER_PROJECT_FAILURE } from '../../src/features/app-shell/homeCreationFailure'; import type { ProjectChatComponentProps } from '../../src/features/app-shell/model'; import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; +import { LlmModelCatalogConfigError } from '../../src/services/llmModelCatalog'; +import { repoPath } from '../repoPath'; +import { + declaration, + parseStyleSheet, + resolveDeclarations, +} from '../styleCascade'; import { act, App, @@ -164,6 +173,127 @@ export function registerClientHomeTests() { expect(createButton).toHaveProperty('disabled', false); }); + it('首页模型菜单复用同一套共享样式面(卡片与列表观感与工作台一致)', async () => { + // 样式面抬升前,首页这颗只命中基础规则(10px 圆角 / 5px 内边距 / 32px 行), + // 工作台那套(16px 圆角 / 12px 内边距 / 34px 行)挂在 `.game-workbench-chat …` 祖先链上。 + // 这条用例钉住"任何 ConversationModelSelect 实例都拿到同一份":断言只靠组件自带类名 + // (`.conversation-model-select …`)就能解析出卡片与列表的观感。 + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'load_game_creator_llm_models') { + return { + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + ], + revision: 1, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + installTauriRuntime({ core: { invoke } }); + renderLauncherAt('/?launcher'); + + const modelTrigger = await screen.findByRole('button', { + name: '对话模型', + }); + fireEvent.click(modelTrigger); + const menu = await waitFor(() => { + const element = document.querySelector( + '.home-input-model-select .conversation-model-menu', + ); + expect(element).not.toBeNull(); + return element as HTMLElement; + }); + + // 组件自带类名在场:共享样式面的选择器就靠它,不需要任何宿主祖先链。 + expect(menu.classList.contains('conversation-model-menu')).toBe(true); + expect(menu.closest('.conversation-model-select')).not.toBeNull(); + expect(menu.closest('.game-workbench-chat')).toBeNull(); + // 首页这颗也有「思维强度」区(同一个组件、同一份配置通道),且菜单里有原生滑块。 + const group = document.querySelector( + '.home-input-model-select [role="group"]', + ); + expect(group?.getAttribute('aria-label')).toBe('思维强度'); + const slider = document.querySelector( + '.home-input-model-select input[type="range"]', + ); + expect(slider).not.toBeNull(); + expect(slider?.getAttribute('aria-label')).toBe('思维强度'); + await waitFor(() => + expect(slider?.getAttribute('aria-valuetext')).toBe('默认'), + ); + // 档位圆点已删除(样式表里与渲染里都不再有那组元素)。 + expect( + document.querySelector('.project-chat-composer-strength-stops'), + ).toBeNull(); + + const rules = parseStyleSheet( + readFileSync( + repoPath('apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ), + ); + // 共享样式面按 `!important` 声明(要压过基础规则里同属性的旧值),断言只看最终值。 + const value = (declarations: Map, property: string) => + declaration(declarations, property).replace(/ !important$/u, ''); + const sharedCard = '.conversation-model-select .conversation-model-menu'; + const card = resolveDeclarations(rules, [sharedCard], 1440); + expect(value(card, 'border-radius')).toBe('16px'); + expect(value(card, 'padding')).toBe('12px'); + expect(value(card, 'border')).toContain('var(--platform-line-soft)'); + expect(value(card, 'box-shadow')).toContain( + 'var(--platform-desktop-hover-shadow)', + ); + + const row = resolveDeclarations(rules, [`${sharedCard} button`], 1440); + expect(value(row, 'min-height')).toBe('34px'); + expect(value(row, 'padding')).toBe('0 10px'); + expect(value(row, 'border-radius')).toBe('9px'); + + const selected = resolveDeclarations( + rules, + [`${sharedCard} button[aria-selected='true']`], + 1440, + ); + expect(value(selected, 'background')).toContain('var(--platform-warm-bg)'); + }); + + it('首页模型不可用与状态也走组件自己的底部 toast(子树不留常驻行)', async () => { + // 任何宿主都不留常驻 `role="alert"` / `role="status"`:浮层收进组件内部(模块级单例 + 宿主)。 + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'load_game_creator_llm_models') { + throw new LlmModelCatalogConfigError('模型服务不可用'); + } + throw new Error(`unexpected invoke ${command}`); + }); + installTauriRuntime({ core: { invoke } }); + renderLauncherAt('/?launcher'); + + const toast = await waitFor(() => { + const element = document.querySelector( + '[data-composer-model-toast="true"]', + ); + expect(element?.textContent).toContain('模型服务不可用'); + return element as HTMLElement; + }); + // 浮层 portal 在 body(底部居中),不在首页输入区子树里;子树里也没有常驻行。 + const homeSelect = document.querySelector('.home-input-model-select'); + expect(homeSelect).not.toBeNull(); + expect(homeSelect?.contains(toast)).toBe(false); + expect(homeSelect?.querySelector('[role="alert"]')).toBeNull(); + expect(homeSelect?.querySelector('[role="status"]')).toBeNull(); + expect( + document.querySelectorAll('[data-composer-model-toast="true"]'), + ).toHaveLength(1); + }); + it('anchors the empty home input placeholder to the editor while the page scrolls', () => { renderLauncherAt('/?launcher'); @@ -4076,7 +4206,9 @@ export function registerCoCreationEntryTests() { expect(await screen.findByLabelText('创作想法')).not.toBeNull(); // 内联面板整体消失:标题、按钮都不得再出现在首页。 expect(screen.queryByText('从平台作品 Fork')).toBeNull(); - expect(screen.queryByRole('button', { name: 'Fork 到我的项目' })).toBeNull(); + expect( + screen.queryByRole('button', { name: 'Fork 到我的项目' }), + ).toBeNull(); }); it('侧边栏「共创」入口可切换,并落在共创页上', async () => { 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 c83bafff6..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 @@ -5961,14 +5961,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` 这类「左右一个值、上下另一个值」的写法。 @@ -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, ); @@ -6410,13 +6417,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 bcb9b3135..4e2f41816 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -20,6 +20,14 @@ const CHAT_CONVERSATION_PATH = repoPath( const CHAT_COMPOSER_PATH = repoPath( 'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx', ); +/** 共享输入区实现(「AI 润色 / 恢复原文」与状态行都在这里渲染)。 */ +const INPUT_SOURCE_PATH = repoPath( + 'apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx', +); +/** 同目录的控件实现(推理强度滑块在这里渲染)。 */ +const CHAT_COMPOSER_CONTROLS_PATH = repoPath( + 'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx', +); function lengthPx(rawValue: string, label: string): number { const value = rawValue.trim(); @@ -118,6 +126,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 +156,29 @@ 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`; +/** 模型触发钮:工具栏那一排里最高的一颗(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 SHARED_CARD = '.conversation-model-select .conversation-model-menu'; +const SHARED_SHELL = + '.conversation-model-select .conversation-model-menu-shell'; +const SHARED_CARD_IN_SHELL = `${SHARED_SHELL} .conversation-model-menu`; +/** 强度区与滑块(issue #600 的复核点;档位圆点按用户口径已删除)。 + 这些规则与卡片一样在共享样式面(组件自带类名,不带宿主祖先链)。 */ +const STRENGTH_SECTION = '.project-chat-reasoning-effort.is-section'; +const STRENGTH_RANGE = '.project-chat-composer-strength-range'; +const STRENGTH_THUMB = `${STRENGTH_RANGE}::-webkit-slider-thumb`; +/** 已删除的档位点容器:留着它做反向守卫(谁都别把点写回来)。 */ +const STRENGTH_STOPS = '.project-chat-composer-strength-stops'; const TOPBAR = `${CHAT} .project-chat-topbar`; /** 列表实际会命中的全部选择器:direct-codex 直连与基础规则。 */ const LIST_SELECTORS = [MESSAGE_LIST, MESSAGE_LIST_PLAIN]; @@ -169,47 +212,79 @@ 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', ); 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'); - const inputRowGap = pixelValue(input, 'gap'); + // 「AI 润色 / 恢复原文」已经排进下方工具条(`.project-chat-composer-controls-actions` + // 挂点 + `actionsPortalTarget`),不再是输入盒里的第二行:这里只留它的按钮高度断言, + // 输入盒的第二行现在只可能放润色状态(没内容时为 0 高)。 + expect( + pixelValue(polishButton, 'height'), + '润色钮是 28px 方块,改了要同步改工具条那一排的高度', + ).toBe(28); + // 输入盒与输入盒外各有一条行间距:前者是「文字区 / 润色状态」之间(状态没内容时为 0), + // 后者是「输入内容区 / 工具栏」之间。 + 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'); + // 工具栏行高由这一排里**最高的一颗控件**决定:模型触发钮 30px 高于发送 / 语音的 + // 28px(`align-items: center` 只做居中,不会把行高压回 28)。所以这里取两者最大值, + // 而不是写死某一颗钮的高度——改任一颗的尺寸都会自动反映到算式里。 + expect( + controls.has('padding-top'), + '工具栏与输入区之间的间距由输入盒的 row-gap 提供,不再用 padding-top', + ).toBe(false); + expect( + pixelValue(trigger, 'height'), + '工具栏行高由模型触发钮决定,改了要重新核对这条算式', + ).toBe(30); + const controlsRowHeight = Math.max( + pixelValue(submit, 'height'), + pixelValue(trigger, 'height'), + ); expect( pixelValue(editor, 'min-height'), '编辑器最小高度是输入盒高度的下界,改了要同步改这里的算式', - ).toBe(96); - // 操作排的高度就是那三只 28px 方钮自己撑起来的:它自己不设 height / min-height, - // 一旦设了,网格行高会被顶起来、编辑器的 min-height 也跟着变,算式随之作废。 - expect(actions.has('height')).toBe(false); - expect(actions.has('min-height')).toBe(false); + ).toBe(72); + // 输入盒第二行是**条件渲染**的润色状态行:`ResourceReferenceInput` 只在 + // `polishing || polishError || polishNotice` 时才渲染它,空态这一行高度为 0(`gap` 的 + // row 分量已归 0,有内容时由状态元素自带的 6px 上间距提供间隔)。这里断言那条条件 + // 还在(谁把它改成常驻渲染,这条就红),所以 `statusRowHeight = 0` 是真实依据而不是写死。 + const inputSource = readFileSync(INPUT_SOURCE_PATH, 'utf8'); + expect( + inputSource, + '润色状态行必须是条件渲染(空态不occupy第二行)', + ).toContain('polishing || polishError || polishNotice'); + const statusRule = desktopDeclarations( + `${COMPOSER_INPUT} .resource-reference-input-status`, + ); + expect(pixelValue(statusRule, 'margin-top')).toBe(6); + const statusRowHeight = 0; return { - actionsRowHeight, + polishButtonHeight: pixelValue(polishButton, 'height'), total: + composerPaddingTop + editorMaxHeight + inputRowGap + - actionsRowHeight + + statusRowHeight + inputPaddingBottom + - composerPaddingTop + - composerPaddingBottom + - controlsRowHeight, + composerRowGap + + controlsRowHeight + + composerPaddingBottom, }; } @@ -268,13 +343,15 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 it('消息列表底部不留给浮层的空白,滚动到底不会多出一段空白(宽屏)', () => { const list = desktopDeclarations(...LIST_SELECTORS); - const { total, actionsRowHeight } = composerMaxHeight(); + const { total, polishButtonHeight } = composerMaxHeight(); - // 输入盒最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4 - // + 输入盒内边距 24(四边同为 12:文字左内缩必须等于上内缩)+ 操作条(2 + 28 方钮)= 234。 - expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28); + // 输入盒最高高度由真实声明算出来(自上而下): + // 盒内上内边距 12 + 编辑器 160 + 输入区行距 0(状态行没内容时为 0 高,不留幻影行距) + // + 状态行 0 + 输入区下内边距 0 + 输入盒与工具栏的行距 8 + // + 工具栏 30(模型触发钮撑起来的行高)+ 盒内下内边距 12 = **222**。 + expect(polishButtonHeight, '润色钮在工具条里仍是 28px 方块').toBe(28); expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28); - expect(total).toBe(234); + expect(total).toBe(222); const paddingBottom = paddingBox(list).bottom; // 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式, @@ -287,6 +364,192 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 expect(paddingBottom).toBeLessThan(total); }); + it('思维强度并入模型菜单:外壳向上展开、强度区在菜单顶部(宽屏)', () => { + const trigger = desktopDeclarations( + COMPOSER_TRIGGER, + COMPOSER_TRIGGER_BASE, + ); + const select = desktopDeclarations( + `${COMPOSER_CONTROLS} .conversation-model-select`, + ); + // 外壳:锚点是工作台专属几何;卡片观感来自共享样式面(见下面的 `sharedCard`), + // 所以这里把"这个元素会命中的全部选择器"都传进去——只传工作台那条会漏掉搬家后的声明。 + const shell = desktopDeclarations( + `${COMPOSER} .conversation-model-menu-shell`, + SHARED_SHELL, + ); + const menuInShell = desktopDeclarations( + `${COMPOSER} .conversation-model-menu-shell .conversation-model-menu`, + SHARED_CARD, + SHARED_CARD_IN_SHELL, + ); + const section = desktopDeclarations(STRENGTH_SECTION); + const range = desktopDeclarations(STRENGTH_RANGE); + /** + * 共享样式面为了压过基础规则里那几条 `!important`,自己也是按 `!important` 声明的; + * 断言时只看最终值,去掉后缀。 + */ + const value = (declarations: Map, property: string) => + declaration(declarations, property).replace(/ !important$/u, ''); + + // 弹层锚在模型控件上、从工具条上方展开(与原来的模型菜单同一朝向,不盖输入框)。 + 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(pixelValue(shell, 'border-radius')).toBe(16); + expect(paddingBox(shell).top).toBe(12); + expect(declaration(shell, 'box-shadow')).toContain( + 'var(--platform-desktop-hover-shadow)', + ); + expect(declaration(shell, 'max-height')).toBe('none'); + // 里层菜单回到文档流(位置 / 描边 / 底色归外壳),强度区是菜单顶部的一块网格,滑块占满宽度。 + expect(value(menuInShell, 'position')).toBe('static'); + expect(value(menuInShell, 'max-height')).toBe('170px'); + expect(value(menuInShell, 'background')).toBe('transparent'); + expect(value(menuInShell, 'border-radius')).toBe('12px'); + expect(declaration(section, 'display')).toBe('grid'); + expect(declaration(range, 'width')).toBe('100%'); + // 底行没有第二颗强度控件:那一排的高度仍由模型触发钮(30px)决定,见上面的算式。 + expect(pixelValue(trigger, 'height')).toBe(30); + }); + + it('档位圆点已删除,滑块契约与「两端贴齐」的强度区内边距仍在(宽屏)', () => { + const section = desktopDeclarations(STRENGTH_SECTION); + const range = desktopDeclarations(STRENGTH_RANGE); + const thumb = desktopDeclarations(STRENGTH_THUMB); + const stops = desktopDeclarations(STRENGTH_STOPS); + + // 反向守卫①:样式表里**不再有**档位点的规则(谁把点写回来,这条就红)。 + expect(stops.has('position')).toBe(false); + expect(stops.size).toBe(0); + // 反向守卫②:组件源码里也不再渲染那组点。 + const controlsSource = readFileSync(CHAT_COMPOSER_CONTROLS_PATH, 'utf8'); + expect(controlsSource).not.toContain( + 'project-chat-composer-strength-stops', + ); + + // 滑块本体契约不变:铺满卡片内容宽度,轨道 6px、圆钮 20px。 + expect(declaration(range, 'width')).toBe('100%'); + expect(pixelValue(range, 'height')).toBe(26); + expect(pixelValue(thumb, 'width')).toBe(20); + expect(pixelValue(thumb, 'height')).toBe(20); + + // 强度区左右不留内边距(那条 padding 原先只服务档位点排布):填充段与圆钮两端与轨道贴齐, + // 中间不出现多余留白。上下留白不动(4 / 2)。 + const sectionPadding = paddingBox(section); + expect( + sectionPadding.left, + '滑块左右不留白:点删掉后这条内边距必须归零', + ).toBe(0); + expect(sectionPadding.right).toBe(0); + expect(sectionPadding.top).toBe(4); + expect(sectionPadding.bottom).toBe(2); + + // 已选段终点必须与**圆钮中心**重合(`10px + ratio * (100% - 20px)`):写成 `20px + …` + // (= 圆钮**右缘**)时圆钮右侧会露出一小截橙色(4× 设备像素实拍对照见 PR)。 + const track = desktopDeclarations( + `${STRENGTH_RANGE}::-webkit-slider-runnable-track`, + ); + const trackBackground = declaration(track, 'background'); + expect(trackBackground).toContain( + 'calc(10px + var(--strength-ratio, 0) * (100% - 20px))', + ); + expect(trackBackground).not.toContain( + 'calc(20px + var(--strength-ratio, 0) * (100% - 20px))', + ); + }); + + it('工具条只有一行:七个宽度下都不换行,控件排与外壳带单行最小宽度(含 AI 润色钮)', () => { + // 宽度扫描:任何面板宽下工具条都必须 `nowrap`,且样式表里**不再有** Direct 作用域的 + // `flex-wrap: wrap`(窄面板曾经靠换行避免重叠,现在改成 min-width + 溢出可见)。 + const CONTROLS_LEFT = `${COMPOSER} .project-chat-composer-controls-left`; + const CONTROLS_RIGHT = `${COMPOSER} .project-chat-composer-controls-right`; + const CONTROLS_MODEL_SELECT = `${COMPOSER_CONTROLS} .conversation-model-select`; + const WIDTHS = [240, 280, 296, 320, 360, 438, 900] as const; + for (const width of WIDTHS) { + const controls = resolveDeclarations(rules, [COMPOSER_CONTROLS], width); + expect( + declaration(controls, 'flex-wrap'), + `${width}px 下工具条必须保持单行`, + ).toBe('nowrap'); + const left = resolveDeclarations(rules, [CONTROLS_LEFT], width); + expect(declaration(left, 'flex-wrap'), `${width}px 下左组不换行`).toBe( + 'nowrap', + ); + const right = resolveDeclarations(rules, [CONTROLS_RIGHT], width); + expect(right.has('flex-wrap'), '右组本来就不换行').toBe(false); + const modelSelect = resolveDeclarations( + rules, + [CONTROLS_MODEL_SELECT, `${CONTROLS_RIGHT} .conversation-model-select`], + width, + ); + expect(declaration(modelSelect, 'flex-wrap')).toBe('nowrap'); + } + + // 单行下限:控件排 252px(Σ 控件最小宽度 204 + 5×8 间距 = 244,再留 8 余量), + // 外壳 252 + 左右内边距各 12 + 边框各 1 = 278px。夹具实测:自然宽度 290.4。 + const controls = desktopDeclarations(COMPOSER_CONTROLS); + expect(pixelValue(controls, 'min-width')).toBe(252); + const composer = desktopDeclarations( + COMPOSER, + COMPOSER_PLAIN, + CONVERSATION_COMPOSER, + ); + expect( + pixelValue(composer, 'min-width'), + '外壳最小宽度 = 工具条下限 + 左右内边距 + 边框', + ).toBe(278); + + // 空输入态:组件源码里「AI 润色 / 恢复原文」是渲染到工具条那个挂点里的 + // (`actionsPortalTarget`),不再是输入盒里的第二行。 + const composerSource = readFileSync(CHAT_COMPOSER_PATH, 'utf8'); + const rightGroupIndex = composerSource.indexOf( + 'className="project-chat-composer-controls-right"', + ); + const slotIndex = composerSource.indexOf( + 'project-chat-composer-controls-actions', + ); + const submitIndex = composerSource.indexOf('{canStopTurn ? ('); + expect(rightGroupIndex).toBeGreaterThanOrEqual(0); + // 挂点在**右组内**、且在发送钮之前(DOM 顺序 `模型 → 麦克风 → 润色 → 发送`,Tab 顺序同视觉)。 + expect(slotIndex).toBeGreaterThan(rightGroupIndex); + expect(submitIndex).toBeGreaterThan(slotIndex); + expect(composerSource).toContain( + 'actionsPortalTarget={controlsActionsSlot}', + ); + }); + + it('模型菜单刷新行:图标与文本贴最左,选项行才用 space-between(宽屏)', () => { + // 宿主作用域里那条「抵消通用按钮样式」的规则曾经统一写 `justify-content: space-between` + // (8 类 + `!important`),把刷新行的文案顶到右边——现在它不写对齐,改由共享面按角色分: + // 选项行 `space-between`(名字左 / 勾右),刷新行 `flex-start`(图标 + 文本成一组)。 + const HOST_ROW = + '.game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer .conversation-model-select .conversation-model-menu button'; + const REFRESH_BASE = '.conversation-model-menu-refresh'; + const refresh = desktopDeclarations(HOST_ROW, REFRESH_BASE); + /** 共享面这几条是按 `!important` 声明的(要压过基础规则里的旧值),断言只看最终值。 */ + const value = (declarations: Map, property: string) => + declaration(declarations, property).replace(/ !important$/u, ''); + expect( + value(refresh, 'justify-content'), + '刷新行必须左对齐(图标 + 文案成一组)', + ).toBe('flex-start'); + expect(value(refresh, 'gap')).toBe('8px'); + + const OPTION_SHARED = `${SHARED_CARD} button[role='option']`; + const OPTION_BASE = '.conversation-model-menu button[role="option"]'; + const option = desktopDeclarations(OPTION_SHARED, OPTION_BASE); + expect(value(option, 'justify-content'), '选项行仍是名字左 / 勾右').toBe( + 'space-between', + ); + + // 说明:上面两条断言已经覆盖「宿主块不再盖掉刷新行」——`resolveDeclarations` 会把 + // 宿主链那条 8 类的规则一并算进来,如果它又写回 `justify-content: space-between !important`, + // 第一条断言会直接变回 `space-between` 而红。所以不再额外做源码切片断言。 + }); + it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => { const conversation = mobileDeclarations(CONVERSATION); const list = mobileDeclarations(...LIST_SELECTORS); diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index c75b60aea..33b00be56 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -544,7 +544,10 @@ test('pre-send validation falls back when the selected model is disabled', async await act(async () => { await expect(ref.current?.ensureUsable()).resolves.toBe(true); }); - expect(screen.getByText('所选模型已下线,已切换为默认模型')).not.toBeNull(); + // 状态文案现在走组件自带的底部 toast(portal 到 body):等它出现,别用同步查询。 + expect( + await screen.findByText('所选模型已下线,已切换为默认模型'), + ).not.toBeNull(); expect(savedModelId).toBe('quality'); expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { modelId: 'quality', @@ -570,7 +573,7 @@ test('follows the new server default when the saved selection was the default', await waitFor(() => expect(savedModelId).toBe('fast')); expect(savedModelIsDefault).toBe(true); expect( - screen.getByText('默认模型已更新,已切换为新的默认模型'), + await screen.findByText('默认模型已更新,已切换为新的默认模型'), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { modelId: 'fast', diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index 3390526a4..38d0dba9f 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -69,6 +69,8 @@ describe('Rust 发布 facade', () => { coverAssetId: 'asset_cover', screenshots: ['asset_shot_1', 'asset_shot_2'], }, + // 平台版本号必填:调用方按作者视图推出的值,这里直接透传。 + versionNumber: 4, idempotencyKey: 'agc-publish-test', }); @@ -79,6 +81,8 @@ describe('Rust 发布 facade', () => { projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', idempotencyKey: 'agc-publish-test', + // 未传价格时按免费提交,保持旧调用方向后兼容。 + priceMudPoints: 0, metadata: { localProjectId: 'local-proj-1', coverAssetId: 'asset_cover', @@ -102,6 +106,7 @@ describe('Rust 发布 facade', () => { packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, metadata: { coverAssetId: 'asset_cover' }, + versionNumber: 4, }); expect(calls).toHaveLength(1); @@ -183,6 +188,7 @@ describe('Rust 发布 facade', () => { packageRelativePath: 'exports/playtest-package-1.zip', manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest, metadata: { coverAssetId: 'asset_cover' }, + versionNumber: 4, }), ).rejects.toThrow('发布需要本地项目标识'); expect(invoke).not.toHaveBeenCalled(); @@ -197,6 +203,7 @@ describe('Rust 发布 facade', () => { packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, metadata: { coverAssetId: ' ' }, + versionNumber: 4, }), ).rejects.toThrow('请先选择游戏封面'); expect(invoke).not.toHaveBeenCalled(); @@ -217,11 +224,51 @@ describe('Rust 发布 facade', () => { (_, index) => `asset_${index}`, ), }, + versionNumber: 4, }), ).rejects.toThrow('游戏截图最多 6 张'); expect(invoke).not.toHaveBeenCalled(); }); + test('买断价原样透传给 native 发布命令', async () => { + const { invoke, calls } = installNativeInvoke((command) => { + if (command === 'publish_local_project_game') return PUBLISH_RESULT; + throw new Error(`未预期的命令:${command}`); + }); + + await publishLocalProjectGame({ + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { coverAssetId: 'asset_cover' }, + versionNumber: 4, + priceMudPoints: 240, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.args).toMatchObject({ priceMudPoints: 240 }); + }); + + test('买断价越界或非整数时在 native command 前失败关闭', async () => { + const invoke = vi.fn() as unknown as TauriInvoke; + const base = { + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { coverAssetId: 'asset_cover' }, + versionNumber: 4, + }; + await expect( + publishLocalProjectGame({ ...base, priceMudPoints: 1_000_001 }), + ).rejects.toThrow('买断价必须是 0 到 1000000 之间的整数泥点'); + await expect( + publishLocalProjectGame({ ...base, priceMudPoints: 1.5 }), + ).rejects.toThrow('买断价必须是 0 到 1000000 之间的整数泥点'); + expect(invoke).not.toHaveBeenCalled(); + }); + test('资料建议和价格均通过 native command', async () => { const { invoke, calls } = installNativeInvoke((command) => { if (command === 'suggest_game_distribution_publish_metadata') { @@ -371,21 +418,23 @@ describe('Rust 发布 facade', () => { }); }); - test('未显式给出项目版本时按工程内部版本条数派生', async () => { - const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT); + test('项目版本只认调用方传入的平台版本号:缺省失败关闭,本地迭代计数不参与', async () => { + // 缺省:不回落本地工程迭代计数(那是「本地有几条迭代」,不是平台版本序列的下一个号), + // 直接失败关闭,避免提交重复 / 回退的版本号。 + const missingInvoke = vi.fn() as unknown as TauriInvoke; + await expect( + publishLocalProjectGame({ + invoke: missingInvoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { coverAssetId: 'asset_cover' }, + } as unknown as Parameters[0]), + ).rejects.toThrow('项目版本必须是大于 0 的安全整数'); + expect(missingInvoke).not.toHaveBeenCalled(); - // 没有任何内部版本记录:首版回落 1,不把「没写」变成服务端自动递增。 - await publishLocalProjectGame({ - invoke, - projectPath: '/tmp/project', - packageRelativePath: 'exports/playtest-package-1.zip', - manifest: MANIFEST, - metadata: { coverAssetId: 'asset_cover' }, - }); - expect(calls[0]?.args).toMatchObject({ versionNumber: 1 }); - - // 4 条内部版本 → 标签就是最新那张卡的序数 4;平台绑定里的版本号不参与派生。 - const withVersions = { + // 本地有 4 条内部版本、还有遗留 projectVersion 与绑定里的版本号:都不能改写传下去的值。 + const withLocalHistory = { ...MANIFEST, versions: [ { versionId: 'initial-13', parentVersionId: null, projectRevision: 13 }, @@ -408,20 +457,17 @@ describe('Rust 发布 facade', () => { projectVersion: 6, publication: { latestVersionNumber: 6 }, } as unknown as GameCreationAppManifest; + const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT); await publishLocalProjectGame({ invoke, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', - manifest: withVersions, + manifest: withLocalHistory, metadata: { coverAssetId: 'asset_cover' }, - gameId: 'game_9', - expectedPublicationRevision: 7, - }); - expect(calls[1]?.args).toMatchObject({ - gameId: 'game_9', - versionNumber: 4, - expectedPublicationRevision: 7, + versionNumber: 7, }); + expect(calls[0]?.args).toMatchObject({ versionNumber: 7 }); + expect(calls[0]?.args).not.toMatchObject({ versionNumber: 4 }); }); test('更新发布缺少公开修订号时在 native command 前失败关闭', async () => { diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index ceafb61fd..d69fb7f02 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -639,6 +639,8 @@ liveTest( packageRelativePath: 'dist/game.zip', manifest, metadata: { ...metadata, inputModes: [...metadata.inputModes] }, + // 平台侧接下来会用的版本号由作者视图推出:这个项目刚建,平台从 1 开始派发。 + versionNumber: 1, }); expect(first.status).toBe('pending_review'); expect(first.versionNumber).toBe(1); @@ -676,6 +678,8 @@ liveTest( packageRelativePath: 'dist/game.zip', manifest, metadata: { ...metadata, inputModes: [...metadata.inputModes] }, + // 同一作品第二次发布:作者视图已有 v1 → 这次用 2。 + versionNumber: 2, }); expect(second.gameId).toBe(first.gameId); expect(second.versionNumber).toBe(first.versionNumber + 1); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx index 6e0b7156c..e29795373 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx @@ -129,12 +129,14 @@ beforeEach(() => { () => new Promise(() => undefined), ); // 默认按「首次发布」恢复,保持既有用例的提交路径不变;更新模式用例单独覆盖。 + // `nextVersionNumber` 是原生按平台作者视图推出来的值:首次发布没有线上作品,平台从 1 派发。 vi.mocked(readGameDistributionPublication).mockResolvedValue({ state: 'first-publish', binding: null, refreshed: true, draft: null, visibility: null, + nextVersionNumber: 1, }); }); @@ -332,6 +334,8 @@ describe('GameDistributionPublishPanel', () => { // 根幂等键由原生发布链路按「账号 + 本地项目 + 包摘要」的账本解析并持久化, // 渲染层不再自己铸键,因此这里不带 idempotencyKey。 expect(args?.idempotencyKey).toBeUndefined(); + // 默认免费:不改付费方式就按 0 泥点提交。 + expect(args?.priceMudPoints).toBe(0); expect(invoke).toBeDefined(); expect(await screen.findByText('已提交审核')).not.toBeNull(); @@ -354,9 +358,10 @@ describe('GameDistributionPublishPanel', () => { // 三个档位都在场,且默认落在「禁止共创」(与库表默认、旧客户端行为一致)。 expect(screen.getAllByRole('radio')).toHaveLength(3); - expect( - screen.getByRole('radio', { name: '禁止共创' }), - ).toHaveProperty('checked', true); + expect(screen.getByRole('radio', { name: '禁止共创' })).toHaveProperty( + 'checked', + true, + ); expect( screen.getByRole('radio', { name: '允许非商用共创' }), ).toHaveProperty('checked', false); @@ -369,9 +374,10 @@ describe('GameDistributionPublishPanel', () => { expect( screen.getByRole('radio', { name: '允许全开放共创' }), ).toHaveProperty('checked', true); - expect( - screen.getByRole('radio', { name: '禁止共创' }), - ).toHaveProperty('checked', false); + expect(screen.getByRole('radio', { name: '禁止共创' })).toHaveProperty( + 'checked', + false, + ); await selectCover(); fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); @@ -382,6 +388,47 @@ describe('GameDistributionPublishPanel', () => { expect(args?.metadata?.forkAuthorization).toBe('full'); }); + test('买断制先本地校验价格,合法时随版本提交', async () => { + installTauriInvoke(async () => undefined); + vi.mocked(publishLocalProjectGame).mockResolvedValue({ + gameId: 'game_1', + versionId: 'gamever_1', + versionNumber: 1, + status: 'pending_review', + packageSha256: 'a'.repeat(64), + packageSizeBytes: 3, + fileCount: 2, + }); + renderPanel(); + await selectCover(); + const submit = await screen.findByRole('button', { name: '发布游戏' }); + + fireEvent.click(screen.getByLabelText('买断制')); + // 空价格先被本地拦住,不发任何发布请求。 + fireEvent.click(submit); + expect(await screen.findByText('买断价必须是整数泥点')).not.toBeNull(); + expect(publishLocalProjectGame).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText(/买断价/u), { + target: { value: '1000001' }, + }); + fireEvent.click(submit); + expect( + await screen.findByText('买断价必须是 1 到 1000000 之间的整数泥点'), + ).not.toBeNull(); + expect(publishLocalProjectGame).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText(/买断价/u), { + target: { value: '120' }, + }); + fireEvent.click(submit); + await waitFor(() => + expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), + ); + const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0]; + expect(args?.priceMudPoints).toBe(120); + }); + test('授权开放后确认项出现且默认已勾选,并如实告知后果', async () => { installTauriInvoke(async () => undefined); vi.mocked(publishLocalProjectGame).mockResolvedValue({ @@ -403,7 +450,9 @@ describe('GameDistributionPublishPanel', () => { // 产品口径:允许共创即开放源码,不设独立开关 → 默认已勾选(作者可主动取消)。 expect(consent).toHaveProperty('checked', true); // 必要告知只留一句:整个工程(含素材与文档)会可见且不可撤销(确认项与授权说明各出现一次)。 - expect(screen.getByText(/我已确认工程里没有不愿公开的内容/u)).not.toBeNull(); + expect( + screen.getByText(/我已确认工程里没有不愿公开的内容/u), + ).not.toBeNull(); expect( screen.getAllByText(/整个工程(含素材与文档)/u).length, ).toBeGreaterThan(0); @@ -454,9 +503,9 @@ describe('GameDistributionPublishPanel', () => { expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); // 这是「作者能退出」的证据:显式 false 表示源码不外发。 - expect( - vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0], - ).toMatchObject({ includeProjectBundle: false }); + expect(vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0]).toMatchObject( + { includeProjectBundle: false }, + ); }); test('重新选择授权档位会回到「默认已勾选」,切回禁止共创则不出现确认项', async () => { @@ -477,9 +526,10 @@ describe('GameDistributionPublishPanel', () => { // 切回「禁止共创」:确认项消失(源码谁也取不走,不需要这个动作)。 fireEvent.click(screen.getByRole('radio', { name: '禁止共创' })); await waitFor(() => expect(screen.queryByRole('checkbox')).toBeNull()); - expect( - screen.getByRole('radio', { name: '禁止共创' }), - ).toHaveProperty('checked', true); + expect(screen.getByRole('radio', { name: '禁止共创' })).toHaveProperty( + 'checked', + true, + ); }); test('工程源包未上传时成功页如实提示只能产物级改编', async () => { @@ -492,7 +542,8 @@ describe('GameDistributionPublishPanel', () => { packageSha256: 'a'.repeat(64), packageSizeBytes: 3, fileCount: 2, - projectBundleWarning: '未上传工程源码,本作品只能被产物级改编:工程源包过大', + projectBundleWarning: + '未上传工程源码,本作品只能被产物级改编:工程源包过大', }); renderPanel(); @@ -533,9 +584,7 @@ describe('GameDistributionPublishPanel', () => { true, ); } - expect( - screen.getByText(/共创授权只在首次发布时生效/u), - ).not.toBeNull(); + expect(screen.getByText(/共创授权只在首次发布时生效/u)).not.toBeNull(); }); test('连续点击只提交一次,上传中禁用动作按钮', async () => { @@ -871,8 +920,10 @@ describe('GameDistributionPublishPanel', () => { test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => { renderPanel(); - fireEvent.click(screen.getByRole('button', { name: '发布游戏' })); - expect(await screen.findByText('需要在 Tauri App 内发布')).not.toBeNull(); + // 没有原生宿主就没有平台版本号:不猜本地迭代计数,提交按钮保持禁用。 + expect( + await screen.findByRole('button', { name: '发布游戏' }), + ).toHaveProperty('disabled', true); expect(publishLocalProjectGame).not.toHaveBeenCalled(); cleanup(); @@ -884,7 +935,7 @@ describe('GameDistributionPublishPanel', () => { expect(screen.queryByLabelText('发行包摘要')).toBeNull(); }); - test('已有绑定时进入更新模式,回填线上资料并按派生项目版本提交原 gameId', async () => { + test('已有绑定时进入更新模式,回填线上资料并提交平台下一个版本号', async () => { installTauriInvoke(async () => undefined); vi.mocked(readGameDistributionPublication).mockResolvedValue({ state: 'update', @@ -898,6 +949,7 @@ describe('GameDistributionPublishPanel', () => { status: 'pending_review', }, refreshed: true, + nextVersionNumber: 3, draft: { title: '星轨防线(线上)', summary: '线上简介', @@ -931,8 +983,7 @@ describe('GameDistributionPublishPanel', () => { }); const { onPublished } = renderPanel(); - // 回填线上冻结资料;项目版本按工程内部版本派生(本 fixture 没有 versions → 首版 1), - // 平台绑定的线上版本号只用于展示,不参与派生。 + // 回填线上冻结资料;项目版本取平台作者视图的下一个版本号(已有 v2 → 这次用 3)。 await screen.findByRole('button', { name: '更新游戏' }); expect(screen.getByLabelText('游戏名称')).toHaveProperty( 'value', @@ -942,10 +993,11 @@ describe('GameDistributionPublishPanel', () => { 'value', '线上简介', ); - const versionInput = screen.getByLabelText('项目版本'); - expect(versionInput).toHaveProperty('value', '1'); - expect(versionInput).toHaveProperty('readOnly', true); - expect(screen.getByText('v1')).not.toBeNull(); + const versionInput = screen.getByLabelText('项目版本') as HTMLInputElement; + expect(versionInput.value).toBe('3'); + expect(versionInput.readOnly).toBe(true); + expect(versionInput.getAttribute('aria-readonly')).toBe('true'); + expect(screen.getByText('v3')).not.toBeNull(); expect(screen.getByText(/线上最近提交 v2 · 审核中/u)).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '更新游戏' })); @@ -954,9 +1006,11 @@ describe('GameDistributionPublishPanel', () => { ); const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0]; expect(args?.gameId).toBe('game_9'); - // 提交的就是派生出来的内部版本序数,与线上最近提交的 v2 无关。 - expect(args?.versionNumber).toBe(1); + // 展示给作者的值与提交的值是同一个:平台已有 v2 → 提交 3,与本地迭代计数无关。 + expect(args?.versionNumber).toBe(3); expect(args?.expectedPublicationRevision).toBe(7); + // 该草稿是历史响应,没有回读价格:按免费预填,不填就提交 0。 + expect(args?.priceMudPoints).toBe(0); // 冻结素材 ID 直接复用,不要求作者重新上传封面/截图。 expect(args?.metadata?.coverAssetId).toBe('asset_cover_old'); expect(args?.metadata?.screenshots).toEqual(['asset_shot_old']); @@ -972,7 +1026,7 @@ describe('GameDistributionPublishPanel', () => { expect(onPublished).toHaveBeenCalledTimes(1); }); - test('项目版本由工程内部版本条数派生,线上版本号与遗留字段都不参与', async () => { + test('更新模式预填线上买断价,作者不改就按原价提交', async () => { installTauriInvoke(async () => undefined); vi.mocked(readGameDistributionPublication).mockResolvedValue({ state: 'update', @@ -981,17 +1035,74 @@ describe('GameDistributionPublishPanel', () => { apiBaseUrl: 'https://dev.test', gameId: 'game_9', revision: 7, - latestVersionId: 'gamever_5', - latestVersionNumber: 5, + latestVersionId: 'gamever_2', + latestVersionNumber: 2, + status: 'published', + }, + refreshed: true, + nextVersionNumber: 3, + draft: { + title: '星轨防线(线上)', + summary: '线上简介', + description: '线上详介', + category: '策略', + tags: ['塔防'], + cover: { + assetId: 'asset_cover_old', + previewUrl: 'https://assets.test/cover.png', + }, + screenshots: [], + priceMudPoints: 240, + }, + visibility: 'published', + }); + vi.mocked(publishLocalProjectGame).mockResolvedValue({ + gameId: 'game_9', + versionId: 'gamever_3', + versionNumber: 1, + status: 'pending_review', + packageSha256: 'a'.repeat(64), + packageSizeBytes: 3, + fileCount: 2, + }); + renderPanel(); + + const submit = await screen.findByRole('button', { name: '更新游戏' }); + // 预填线上价与模式:不改就按原价提交,避免审核通过后把付费作品静默改成免费。 + expect(screen.getByLabelText('买断制')).toHaveProperty('checked', true); + expect(screen.getByLabelText(/买断价/u)).toHaveProperty('value', '240'); + + fireEvent.click(submit); + await waitFor(() => + expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), + ); + expect( + vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].priceMudPoints, + ).toBe(240); + }); + + test('项目版本取平台作者视图的下一个版本号:本地迭代计数与遗留字段都不参与', async () => { + installTauriInvoke(async () => undefined); + vi.mocked(readGameDistributionPublication).mockResolvedValue({ + state: 'update', + binding: { + accountId: 'user-1', + apiBaseUrl: 'https://dev.test', + gameId: 'game_9', + revision: 7, + latestVersionId: 'gamever_3', + latestVersionNumber: 3, status: 'published', }, refreshed: true, draft: null, visibility: 'published', + // 原生按作者视图算出「已有版本号 1..3 → 这次用 4」。 + nextVersionNumber: 4, }); vi.mocked(publishLocalProjectGame).mockResolvedValue({ gameId: 'game_9', - versionId: 'gamever_6', + versionId: 'gamever_4', versionNumber: 4, status: 'pending_review', packageSha256: 'a'.repeat(64), @@ -1001,48 +1112,96 @@ describe('GameDistributionPublishPanel', () => { renderPanel({ manifest: { ...MANIFEST, - // 4 条内部版本 → 标签 4;遗留 projectVersion 6 与线上 v5 都不得影响它。 + // 本地只有 1 条内部版本、遗留 projectVersion 6:按旧口径会提交 1(或 2), + // 这次必须采用平台推出的 4。 versions: [ { versionId: 'initial-13', parentVersionId: null, projectRevision: 13, }, - { - versionId: 'agent-22', - parentVersionId: 'initial-13', - projectRevision: 22, - }, - { - versionId: 'agent-42', - parentVersionId: 'agent-22', - projectRevision: 42, - }, - { - versionId: 'agent-46', - parentVersionId: 'agent-42', - projectRevision: 46, - }, ], projectVersion: 6, } as unknown as GameCreationAppManifest, }); await screen.findByRole('button', { name: '更新游戏' }); - const input = screen.getByLabelText('项目版本'); - expect(input).toHaveProperty('value', '4'); - expect(input).toHaveProperty('readOnly', true); + const input = screen.getByLabelText('项目版本') as HTMLInputElement; + expect(input.value).toBe('4'); + expect(input.readOnly).toBe(true); expect(screen.getByText('v4')).not.toBeNull(); - expect(screen.getByText(/线上最近提交 v5 · 已公开/u)).not.toBeNull(); + expect(screen.getByText(/线上最近提交 v3 · 已公开/u)).not.toBeNull(); await selectCover(); fireEvent.click(screen.getByRole('button', { name: '更新游戏' })); await waitFor(() => expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), ); + const submittedVersion = vi.mocked(publishLocalProjectGame).mock + .calls[0]?.[0].versionNumber; + // 显示值 == 提交值 == 平台下一个版本号。 + expect(submittedVersion).toBe(4); + // 本地迭代计数(1 条)与「本地计数 + 1」都不得成为提交值。 + expect(submittedVersion).not.toBe(1); + expect(submittedVersion).not.toBe(2); + }); + + test('平台版本号不可得时显示占位、提交禁用,且不回落本地迭代计数', async () => { + installTauriInvoke(async () => undefined); + vi.mocked(readGameDistributionPublication).mockResolvedValue({ + state: 'update', + binding: { + accountId: 'user-1', + apiBaseUrl: 'https://dev.test', + gameId: 'game_9', + revision: 7, + latestVersionId: 'gamever_2', + latestVersionNumber: 2, + status: 'pending_review', + }, + // 有本地绑定但没能刷新线上状态:这一路没有平台版本数据。 + refreshed: false, + draft: null, + visibility: null, + message: '无法刷新线上状态:无法连接登录服务', + }); + renderPanel({ + manifest: { + ...MANIFEST, + // 本地 3 条内部版本:不得被当成版本号提交。 + versions: [ + { versionId: 'a', parentVersionId: null, projectRevision: 1 }, + { versionId: 'b', parentVersionId: 'a', projectRevision: 2 }, + { versionId: 'c', parentVersionId: 'b', projectRevision: 3 }, + ], + } as unknown as GameCreationAppManifest, + }); + + const versionInput = (await screen.findByLabelText( + '项目版本', + )) as HTMLInputElement; + expect(versionInput.value).toBe(''); + expect(versionInput.placeholder).toBe('—'); expect( - vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].versionNumber, - ).toBe(4); + screen.getByText('—', { + selector: '.game-distribution-publish-panel__version-tag', + }), + ).not.toBeNull(); + expect( + screen.getByText('无法刷新线上状态:无法连接登录服务'), + ).not.toBeNull(); + + // 其余必填项补齐后,提交仍因版本号未知保持禁用。 + fireEvent.change(screen.getByLabelText('游戏名称'), { + target: { value: '星轨防线' }, + }); + fireEvent.change(screen.getByLabelText('一句话简介'), { + target: { value: '守住轨道城' }, + }); + const submit = screen.getByRole('button', { name: '更新游戏' }); + expect(submit).toHaveProperty('disabled', true); + fireEvent.click(submit); + expect(publishLocalProjectGame).not.toHaveBeenCalled(); }); test('恢复失败(网络/5xx)时禁用提交,不按首次发布继续', async () => { diff --git a/apps/ai-game-creator-shell/tests/runPreviewRefresh.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewRefresh.test.tsx index 0e206995f..e7f67e726 100644 --- a/apps/ai-game-creator-shell/tests/runPreviewRefresh.test.tsx +++ b/apps/ai-game-creator-shell/tests/runPreviewRefresh.test.tsx @@ -113,9 +113,19 @@ describe('运行页「刷新运行画面」', () => { const second = document.querySelector('iframe'); expect(second).not.toBe(first); - expect(new URL(String(first?.getAttribute('src'))).searchParams.get('__agc_reload')).toBe('1'); - expect(new URL(String(second?.getAttribute('src'))).searchParams.get('__agc_reload')).toBe('2'); - expect(new URL(String(second?.getAttribute('src'))).origin).toBe('http://127.0.0.1:4173'); + expect( + new URL(String(first?.getAttribute('src'))).searchParams.get( + '__agc_reload', + ), + ).toBe('1'); + expect( + new URL(String(second?.getAttribute('src'))).searchParams.get( + '__agc_reload', + ), + ).toBe('2'); + expect(new URL(String(second?.getAttribute('src'))).origin).toBe( + 'http://127.0.0.1:4173', + ); }); it('没有活预览时不渲染刷新,而不是留一个点了没反应的入口', async () => { diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 8acf2e6b4..69f1583b2 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -88,6 +88,32 @@ http { } + # 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…` 是 sandbox iframe + # 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server,唯一差别是 + # 清空 Cookie:播放会话不读账号凭证,而 api-server 播放网关对带平台 refresh Cookie 的请求返回 403。 + # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 + location ^~ /api/game-distribution/play-sessions/ { + default_type application/json; + client_max_body_size 210m; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + } + location ~ ^/api(?:/|$) { default_type application/json; # 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server; diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index 4e4ef7893..34db5d5ed 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -110,3 +110,11 @@ curl -sSI -H 'Accept-Encoding: br' \ - 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。 - 审核通过时 `api-server` 按 gameId 派生同源路径 `/games//` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。 - 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/creators`、`/creators/connections`、`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。 + +## 付费游戏播放会话前缀(`/api/game-distribution/play-sessions/`) + +- 付费游戏的可玩入口是 `POST /api/game-distribution/games//play-session` 换到的 `/api/game-distribution/play-sessions//`,直接作为 sandbox iframe 的 `src`;包内相对资源沿同一前缀解析。该前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie),Cookie 一旦被转发到 `api-server` 播放网关就会命中它的 403 纵深防御,iframe 与包内每个资源都不可用。 +- 因此三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都在通用 `/api` location 之前内联 `location ^~ /api/game-distribution/play-sessions/`,代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断都与通用 `/api` location 一致,只多一条 `proxy_set_header Cookie ""`。 +- `^~` 不能省:不加时 nginx 会先命中正则 location `~ ^/api(?:/|$)`,Cookie 又被转发回去。前缀末尾的斜杠只影响裸前缀 `/api/game-distribution/play-sessions`(无 token):该形态继续走通用 `/api`,而 api-server 在这个前缀下只注册了 GET 入口与包内资源,创建会话是 `POST /api/game-distribution/games//play-session`,不在此前缀下,因此不存在「带账号凭证却落在清 Cookie 前缀里」的请求;`/play-sessions/` 与 `/play-sessions//…` 都落在该前缀内、都会被清 Cookie。 +- 本地 dev 由 `vite.config.ts` 里排在 `/api/game-distribution` 之前的同名前缀规则做同一件事(`proxyReq.removeHeader('cookie')`);`api-server` 播放网关的 403 纵深防御不放宽,只保证边缘/dev 转发时不带 Cookie。 +- 门禁:`npm run check:nginx-spa-routes` 对三份模板断言该 `^~` 前缀 location 存在、清空 Cookie、代理头齐全且排在通用 `/api` location 之前;`npm run check:pingora-route-parity` 断言矩阵里的 `play_sessions_gateway` 用例(以及 Pingora 的 `RouteDecision::PlaySessionGateway`)指向独立的清 Cookie 转发,不会被合并回通用 `/api` 规则。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index b8c2974d0..3d7593beb 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -116,6 +116,40 @@ server { } + # 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…` 是 sandbox iframe + # 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server(大小上限、 + # 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而 + # api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。 + # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 + # 只匹配带尾斜杠的前缀:`/play-sessions/` 与 `/play-sessions//…` 都落在该前缀内, + # 裸 `/api/game-distribution/play-sessions` 仍走通用 `/api`。该前缀下 api-server 只注册 GET + # 入口与包内资源;创建会话是 `POST /api/game-distribution/games//play-session`,不在此前缀下。 + location ^~ /api/game-distribution/play-sessions/ { + default_type application/json; + client_max_body_size 210m; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + } + # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 7e6e599a1..2063cb350 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -144,6 +144,40 @@ server { include /etc/nginx/snippets/genarrative-host-extras.conf; + # 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions//…` 是 sandbox iframe + # 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server(大小上限、 + # 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而 + # api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。 + # 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。 + # 只匹配带尾斜杠的前缀:`/play-sessions/` 与 `/play-sessions//…` 都落在该前缀内, + # 裸 `/api/game-distribution/play-sessions` 仍走通用 `/api`。该前缀下 api-server 只注册 GET + # 入口与包内资源;创建会话是 `POST /api/game-distribution/games//play-session`,不在此前缀下。 + location ^~ /api/game-distribution/play-sessions/ { + default_type application/json; + client_max_body_size 210m; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + } + # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 7df7ff384..811e08f7f 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -366,6 +366,27 @@ }, "docs": ["/games/game_<32 位十六进制 id>/…", "平台同源发行入口"] }, + { + "id": "play_sessions_gateway", + "samplePath": "/api/game-distribution/play-sessions/token_1/index.html", + "expect": { + "kind": "play_session_gateway", + "protectionClass": "api" + }, + "nginx": { + "production": [ + "location ^~ /api/game-distribution/play-sessions/", + "proxy_set_header Cookie \"\";", + "proxy_pass http://genarrative_api;" + ], + "development": [ + "location ^~ /api/game-distribution/play-sessions/", + "proxy_set_header Cookie \"\";", + "proxy_pass http://genarrative_api;" + ] + }, + "docs": ["平台付费游戏播放会话入口", "/api/game-distribution/play-sessions//…"] + }, { "id": "web_spa_case_trailing_slash", "samplePath": "/PROJECT/", diff --git a/docs/README.md b/docs/README.md index 53ed70416..199e4a4d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ - [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。 - [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。 - [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。 +- [游戏买断制泥点付费与播放鉴权合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏买断制泥点付费与播放鉴权合同)与[里程碑](./project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md):已实现,本机真实栈 E2E 由人工验收脚本 `check:game-distribution-purchase-e2e` 覆盖(最近一次人工运行 76 PASS / 0 FAIL / 1 WARN),该脚本不在 CI 自动门禁内;待用户验收,未部署;作者可选买断制泥点付费,购买后永久可玩,后台审核可见价格且审核员不限次试用;网页与 AGC 两个发布入口一致支持定价并共用 `packages/shared` 组件 `PlatformGamePricingField`,AGC 定价的前端用例与 Rust 预填单测已覆盖,AGC 真实栈发布未覆盖。 - [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停不强制 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。 - [游戏共创与作品 Fork](./【技术方案】游戏共创与作品Fork-2026-10-03.md):共创授权三态(只升不降)、作品级血缘与代际、成品包与工程源包两条改造路径、族谱树与溯源署名的产品设计与技术方案;同时作为该功能主规范,已评审通过,按 M1 逐里程碑实现。 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) diff --git a/docs/project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md b/docs/project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md new file mode 100644 index 000000000..946d62a3e --- /dev/null +++ b/docs/project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md @@ -0,0 +1,69 @@ +# 【里程碑】游戏买断制泥点付费与播放鉴权 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented-awaiting-runtime-acceptance | +| Date | 2026-10-05 | +| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(「游戏买断制泥点付费与播放鉴权合同」章) | + +## 目标 + +把游戏分发从“公开即免费可玩”扩展为“作者可选买断制泥点付费;其他用户购买后永久可玩;后台审核可见价格且审核员不限次试玩”。 + +## 范围 + +- 作者端定价:网页 `/games/publish` 与 AGC 发布面板在新建与更新模式选择“免费 / 买断制 N 泥点”;两入口共用 `packages/shared` 定价组件 `PlatformGamePricingField`,AGC 壳请求透传 `priceMudPoints`;给已上线作品发新版本时预填价格与模式,作者不改则价格不变。预填价口径:取该作品**最新版本的冻结价**(该版本可能是 `pending_review` / `rejected` 状态的版本,因此预填值可能尚未生效),仅当该冻结价缺失或为 0 时才回落作品行当前价,两条来源都取不到才按免费(0)。AGC 壳侧实现 `resolve_publication_prefill_price`(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:811`);网页侧读版本详情 `detail.version.priceMudPoints ?? detail.game.priceMudPoints ?? 0`(`src/components/game-distribution/GamePublishPage.tsx:164`),后端 `version_detail_payload` 已把版本详情的 `game.priceMudPoints` 一并归一为同一冻结价口径(`server-rs/crates/api-server/src/modules/game_distribution.rs:3797`)。 +- 玩家端:公开详情展示价格与购买态、泥点购买、购买后游玩。 +- 付费游玩鉴权:未购买不得游玩,直连公开发行路径必须失败。 +- 泥点钱包扣费与 `game_purchase` 流水。 +- 后台审核详情与待审列表展示价格。 + +## 不在范围内 + +- 创作者分成与结算、退款/撤销购买、促销/限时免费、订阅或游戏内购、价格运营审批流。 +- 评分/评论与付费的耦合、外部 OpenAPI。 +- 免费游戏现有链路的任何行为变化。 + +## 依赖与前置条件 + +- 现有游戏分发状态机、审核 CAS/幂等与版本冻结资料机制。 +- runtime profile 钱包的原子写与 `profile_wallet_ledger` 流水。 +- 现有发行网关 sandbox/CSP/Cookie 约束,以及后台待审版本预览会话(token 前缀承载相对资源)模式。 +- 主规范“游戏买断制泥点付费与播放鉴权合同”与本文档评审通过。评审前不写业务代码。 + +## 验收标准 + +- [x] 作者网页发布可选「免费」或「买断制 N 泥点」;负数/超上限/非整数被前后端同时拦截。证据:真实本地栈 E2E 价格 `1000001` → 400 且不落版本,`0` / `1000000` → 200;`cargo test -p module-game-distribution` 29 passed、`cargo test -p api-server game_distribution` 53 passed 覆盖定价校验,网页发布表单定价定向 Vitest 覆盖前端拦截。 +- [ ] AGC 发布面板与网页一致可选「免费 / 买断制 N 泥点」,非法值被前后端同时拦截;给已上线作品发新版本时按「最新版本冻结价优先、缺失或为 0 时才回落作品行当前价」预填价格与模式,作者不改则价格不变。 + - 已覆盖:AGC 发布面板定价定向 Vitest `apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx`(`:333` 默认免费提交 `priceMudPoints=0`;`:342` 买断制先本地校验:空值与 `1000001` 分别报「买断价必须是整数泥点」「买断价必须是 1 到 1000000 之间的整数泥点」且都不发发布请求,`120` 随版本提交 `priceMudPoints=120`;`:802` 历史响应没有回读价格时按免费预填;`:819` 更新模式预填线上买断价 240、作者不改仍按 240 提交);AGC 壳请求层校验定向 Vitest `apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts`(`:180` 透传 `priceMudPoints=240`;`:197` `1000001` / `1.5` 在 native command 之前失败关闭);预填口径 Rust 单测 `apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1715`(`publication_prefill_price_prefers_latest_frozen_price_and_falls_back`:最新版本冻结价 300 优先于作品行 240、历史版本无冻结价回落 240、免费作品与缺字段按 0);两入口共用 `packages/shared` 组件 `PlatformGamePricingField`。 + - **未覆盖,故本项保持未勾选**:AGC 真实栈「发布付费版本」未手点一次;真实本地栈 E2E 只覆盖网页发布入口的定价链路(`1000001` → 400 且不落版本、`0` / `1000000` → 200)。 +- [x] 后台审核详情展示该版本价格;无价格的历史版本按免费展示。证据:`cargo test -p api-server game_distribution` 53 passed(`private_version_payload` / `version_detail_payload` 输出 `priceMudPoints`,历史无价格版本按 0);admin-web 审核价格展示定向 Vitest。 +- [x] 审核员不限次数试玩待审付费版本,不校验购买、不扣泥点。证据:真实本地栈 E2E 审核员两次试玩待审付费版本均 200、两次包内字节一致,作者与审核员余额/流水均不变。 +- [x] 未购买用户详情可见资料但无可播放入口;直连 `/games//` 返回 404。证据:真实本地栈 E2E 详情 `priceMudPoints=30` / `purchased=false` / `entryUrl=null`,发行网关与平台同源均 404,创建会话 403,未登录 401。 +- [x] 余额不足购买失败,余额、账单与购买记录都不变。证据:真实本地栈 E2E 返回 400 `INSUFFICIENT_MUD_POINTS`,余额与流水不变。 +- [x] 购买成功:余额减少 N、账单出现 `game_purchase` 流水、购买记录唯一、可反复游玩不再扣费。证据:真实本地栈 E2E 扣 30(120→90),`game_purchase` 流水与购买行各 1 条,重复进入不再扣费。 +- [x] 双击、并发与响应丢失重试只扣一次。证据:真实本地栈 E2E 同 key 重放、换 key 重复购买均不再扣费;两路不同 key 真并发返回 200/200、只有一个 `replayed=false`、余额只扣一次、购买记录 1 条。 +- [x] 作者本人与管理员免购买即可游玩付费作品且不扣费。证据:真实本地栈 E2E 作者(自购 400 `GAME_PURCHASE_OWNER_EXEMPT`、余额/流水不变、播放会话 200)与管理员(管理员令牌创建播放会话 200、入口 200);管理员免购买经 play-session 管理员分支实现。 +- [x] 免费游戏的发布、详情、游玩与计数链路无行为变化。证据:真实本地栈 E2E 免费游戏详情保留入口、发行网关 200、匿名创建会话 200 且不签发令牌。 + +## 未覆盖边界(保持未勾选) + +- [ ] 管理员安全下架后的播放会话失效:本轮未单独运行,与作者下架共用同一 404 关闭路径。 +- [ ] 多账号 / 多游戏混合并发购买:本轮只覆盖单游戏、单账号的两路真并发。 + +## 证据要求 + +- 自动化:Rust 定向测试(定价校验、购买幂等与并发、授权判定、状态机)、Vitest(发布表单定价、详情购买态、游玩页会话挂载、审核价格展示)、schema/绑定与编码门禁。 +- 运行时:真实本地栈跑通「发布付费游戏 → 后台审核可见价格并试玩 → 通过 → 未购买访问失败 → 购买 → 游玩 → 重复游玩不扣费」。 +- 边界:并发购买、余额不足、下架/封禁后播放会话失效、猜测 URL 直连、免费游戏回归。 + +## 验收证据摘要(真实栈 E2E) + +- 验收命令:`npm run check:game-distribution-purchase-e2e`(等价于 `node scripts/check-game-distribution-purchase-e2e.mjs`,脚本入口见 `package.json:92`)。 +- 断言总数:该脚本内共 76 处 `check(...)` 调用;`check(name, ok, detail)`(脚本 `:86`)每条打印一行 `PASS` / `FAIL`,因此 76 即本轮断言总数。 +- 最近一次人工运行结论(2026-10-05,本机真实 SpacetimeDB / api-server / OSS):**76 PASS / 0 FAIL / 1 WARN**(0 FAIL,进程退出码 0)。本节只记可复核的文本结论,不记耗时秒数。 +- 唯一 WARN:「管理员令牌购买」在现役登录链路下不可达 —— 后台管理员令牌在 `/api/*` 用户路由上先被 `require_bearer_auth` 判为无效登录态,走 401 前置,未进入 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED` 分支(该分支作为纵深防御保留);管理员免购买由 play-session 管理员分支覆盖(脚本 `:1122`-`:1173`)。 +- WARN 语义:脚本的 `warn()`(`:91`)表示环境条件不满足而**跳过**的断言(例如未发现主站 Vite、`spacetime sql` 直查不可用),不是失败。 +- 脚本定位:**人工验收脚本,不在 CI 自动门禁内**(需真实 SpacetimeDB + api-server 与 `E2E_ADMIN_USER` / `E2E_ADMIN_PASSWORD` 环境变量),CI 绿不代表该链路已验证。 +- 交付状态:本里程碑结论最高到「本机真实栈验证通过,待用户验收,未部署」。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 05e2aa88a..1ed47f754 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 2026-10-05 播放会话前缀在三处入口清空 Cookie,网关 403 纵深防御不变 + +- 背景:付费游戏的可玩入口是创建播放会话后拿到的 `/api/game-distribution/play-sessions//`(sandbox iframe 的 `src`,包内相对资源沿同一前缀解析)。该前缀落在 `/api/*` 上,边缘通用 `/api` location 必须转发 Cookie(`/api/auth/*` 需要 refresh cookie),而 `api-server` 播放网关对带可解析平台 refresh Cookie 的请求返回 403,导致真实浏览器里 iframe 与每个包内资源都 403、付费游戏实际不可玩。 +- 决策(边缘):三份 nginx 模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)在通用 `/api` location 之前内联 `location ^~ /api/game-distribution/play-sessions/`;代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断与通用 `/api` 保持一致,额外清空 Cookie。`^~` 必填(否则正则 location `~ ^/api(?:/|$)` 优先命中),且只匹配带尾斜杠的前缀。 +- 决策(dev):`vite.config.ts` 在 `/api/game-distribution` 之前加同名前缀代理规则,`proxyReq.removeHeader('cookie')`。 +- 决策(网关):`server-rs/crates/pingora-gateway` 新增 `RouteDecision::PlaySessionGateway`,与通用 `/api` 同口径(api 上游、api 限流分组、大小上限、维护闸),差别只在经新增的 `route_clears_cookie` 清空 Cookie(发行入口 `ReleaseGateway` 复用同一判定)。**不放宽** `api-server` 的 Cookie 拒绝。 +- 决策(边界):前缀只认带尾斜杠的形式,创建会话的 `POST /api/game-distribution/play-sessions` 与 `POST /api/game-distribution/games/{gameId}/play-session` 继续走通用 `/api` 并保留 Cookie。 +- 门禁:矩阵新增 `play_sessions_gateway` 用例;`check:nginx-spa-routes` 新增「该前缀 location 存在、清空 Cookie、排在通用 `/api` 之前」断言,`check:pingora-route-parity` 新增「矩阵用例必须声明清 Cookie 且不得复用通用 `/api` location / Rust 播放会话分支必须排在通用 `/api` 之前」断言,`check:pingora-gateway-smoke` 新增真实网关下「该前缀清 Cookie、创建会话端点保留 Cookie」用例。 +- 影响面:`deploy/nginx/{genarrative.conf,genarrative-dev-http.conf,README.md}`、`deploy/container/nginx.conf`、`vite.config.ts`、`server-rs/crates/pingora-gateway/src/main.rs`、`deploy/pingora/nginx-route-parity.matrix.json`、`scripts/check-{nginx-spa-routes,pingora-route-parity,pingora-gateway-smoke}.mjs`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 关联:`docs/project-memory/shared-memory/pitfalls.md`「付费游戏播放会话前缀落在 `/api/*`」条。 + ## 2026-10-03 每日免费发放额为 0 时前端隐藏该池 - 背景:运营需要一个可逆的「不提供每日免费泥点」状态。不给它新增 `retired` 状态位或新字段,直接把后台配置 `daily_free_points_per_day` 配成 0,让「每日免费发放额」这个普通数值自己表达;前端据此隐藏每日免费相关入口。 @@ -9,7 +20,6 @@ - 影响范围:`packages/shared/src/utils/mudPoints.ts`、`PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`(池概览 3 列变 2 列、扣点顺序提示与泥点确认页文案随可见性切换)、`apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`(每日免费允许填 0)、`server-rs/crates/module-runtime/{commands,errors,lib}.rs`;`daily_free_grant` / `daily_free_reset` 账本文案保留,历史流水不改写。 - 验证:`cargo check -p module-runtime`、`cargo test -p module-runtime profile_wallet_config_allows_zero_daily_free_points`、`cargo fmt --check`、根 `npm run typecheck`、`npm run admin-web:typecheck`、共享层与 admin 定向 vitest 22/22、`npm run check:encoding`、`git diff --check` 通过。 - ## 2026-10-05 创作者主页与关注粉丝的产品边界 - 产品已确认:桌面第四项“创作者主页”默认进入当前账号主页,“我的”移到第五项;他人的关注/粉丝列表公开可查看,自己或他人的两类列表均可点击用户进入其创作者主页。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9cbc28991..0c6d227a4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,45 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-05 PR #607 复核修复:档位点对齐/对比度、状态文案也走浮层、键盘去重、卸载 flush + +- **档位圆点已删除(D1 的收口)**:这一轮把档位圆点**整体删除**(半透明备选方案未采用)。现在滑块只剩轨道 + 圆钮:轨道 6px 圆头、已选段 `--platform-accent` 由 `--strength-ratio` 驱动、**终点落在圆钮中心**(`calc(10px + ratio * (100% - 20px))`)、圆钮 20px 实心暖白(`--platform-panel-fill` + `--platform-subpanel-border` 1px 描边 + `color-mix` 柔影);强度区横向内边距 `4px 6px 2px` → `4px 0 2px`(滑块铺满卡片内容宽度,填充段与圆钮两端与轨道两端贴齐);相关 CSS(`space-between` 排布 / `z-index: 2` 抬层 / `.is-active{opacity:0}` / 点的 `color-mix` 底色 / 只为点对齐的 `padding: 0 7px`)与渲染标记一并删除。**判据**:`chatDialogFrameLayout.test.ts` 反向守卫(样式表里不再有 `.project-chat-composer-strength-stops` 规则、组件源码不再渲染该类名;滑块契约仍在:宽 100% / 高 26 / 圆钮 20×20、强度区左右内边距 0)+ `home.suite.ts` 首页菜单里查不到那组点。实测(447 视口,像素扫描):轨道 90..324(宽 234 = 卡片内容宽),档位 0 时圆钮左缘 90.5(距轨道左端 0.5px)、档位 4 时圆钮右缘 322.3(距右端 1.8px,扫描行不在圆钮正中所以略窄),填充段终点落在**圆钮中心**(`calc(10px + ratio * (100% - 20px))`,被圆钮盖住),因此圆钮右侧不会露出橙色(终点曾写成 `20px + …` = 圆钮右缘,4× 设备像素下能看到一小截溢出)。**历史成因(只留一句,细节由 Git 追溯)**:圆点此前被 6px 轨道盖住、且与圆钮两端错位 ±12.2px,曾用「抬到轨道之上 + space-between 对齐 + 浅暖色」修过一轮,最终整体删除。 +- **状态文案(`role="status"`)也会顶高那一排(D2)**:`notice`(「模型列表已刷新」「正在刷新模型列表」「默认模型已停用」、推理档读取/保存失败)原先无条件渲染成控件里的 `role="status"`,`toast` 形态只接管了 `error`,于是 Direct 输入区仍会被一行状态文字顶成两行高。现行口径:浮层**收进组件内部**(`components/common/composerNotice.tsx` 的模块级单例 + toast 宿主),失败与状态分两条通道各自去重(同一条只弹一次、清空后可再弹),失败 `tone="error"`、状态 `tone="neutral"`;**任何宿主**的输入区子树里都不再有常驻 `role="alert"` / `role="status"` 行,宿主的 `errorPresentation` / `onUnavailable` / `onNotice` / `noticePresentation` 这套分支已删除。判据:`chat-composer.suite.ts`「routes model-list notices to the toast…」断言 Direct 输入区子树里 `[role='status']` 与 `[role='alert']` **都为 0**,且浮层在输入区之外。 +- **键盘长按每个事件都落盘(D5)**:range 的方向键在 Chromium 里每次重复都会补 `change`,旧行为 ~30 次/秒全量写盘(写链 + 持久化 + 回读)。现行口径:`selectEffort` 先比对「最近一次**落盘成功**的档位」(`persistedEffortRef`,挂载回读时就种上),相同则不写;连续调整只写**每个新档位**一次,钳位后的重复事件 0 次。实测(真实按键序列):单击方向键 **1** 次落盘;长按 1.5s(34 个重复事件)**4** 次(`low/medium/high/max`,即每个新档位一次);已在最高档继续长按 1.2s(28 个事件)**0** 次。 +- **拖动中途卸载会丢档位(D6)**:预览值只在 `change`/`keyup`/`pointerup`/`blur` 落盘,拖到一半被 Esc 关掉菜单或宿主卸载时不会提交。现行口径:卸载 cleanup 里 flush 一次(读 ref,不碰已卸载组件的 state)。实测:拖动到「中」未松手时落盘 0 次,`unmount()` 后恰好 **1** 次(`medium`);单次拖动(按下 + 6 步横移 + 松手)全程 **1** 次(`high`),终值 = 松手位置。 +- **`forced-colors` 下焦点不可见(D4)**:高对比模式会丢弃 thumb 的 `box-shadow`,而焦点环正是画在 thumb 上的阴影。现行口径:加 `@media (forced-colors: active)` 回退 `outline: 2px solid Highlight; outline-offset: 2px`(系统色,不算新色值;只在 forced-colors 生效,亮色观感不变)。 +- **注释与实现必须同口径(D9)**:`.resource-reference-input` 的 `min-height: 0 / padding: 0` 原先写在文件末尾那条六类规则里靠**源码顺序**盖住前面的 `min-height: 56px / padding: 0 0 4px`,与「不依赖源码顺序」的注释矛盾。现行口径:几何(内边距 / 高度下界)写回 11520 行那条六类规则,末尾那条只留网格与行距,注释同步改成"不依赖源码先后"。 +- **深色主题下焦点环间隙不可靠(D3,仅记录不改代码)**:间隙色取 `--platform-input-fill`,深色主题该 token 只有 5% 不透明(`rgba(255,255,255,0.05)`),间隙会与焦点环颜色几乎一样。当前工作台根节点固定 `platform-theme--light`,走不到这条分支;将来真要启用深色主题,把间隙换成不透明 token 再验一次。 + +## 2026-10-05 模型菜单的观感是组件自带的共享样式面(首页同步复用) + +- **现象**:工作台那颗 `ConversationModelSelect` 已经改成「强度区 + 模型列表」的卡片观感,但首页输入区右下角那颗(`className="home-input-model-select"`,同一个组件)还是**旧卡片**:161px 窄卡、10px 圆角、5px 内边距、32px 行高、旧灰投影。首页与工作台应当是同一套观感。 +- **成因**:#600 那轮把卡片 / 列表的新观感全挂在 `.game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex …` 这条**宿主祖先链**上,于是只有工作台那一处命中;同一个组件换个宿主就退回基础规则。 +- **处理(现行口径)**:卡片 / 列表 / 行 / hover / 选中 pill / 细滚动条这套观感抬到**组件自带的共享样式面**——选择器只用组件类名(`.conversation-model-select .conversation-model-menu-shell`、`.conversation-model-select .conversation-model-menu`、`… .conversation-model-menu button…`),**不带任何宿主祖先链**;留在宿主作用域的只有工具栏几何(控制排的宽高与收缩、队列 chip、模型触发钮),**弹层锚点也是组件级**(`position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20`,见下方"后续")。合并形态(有外壳)与独立形态(策划/画布直接渲染菜单)**同一套值**:卡 16px 圆角 / 12px 内边距 / 1px `--platform-line-soft` 描边 / `--platform-desktop-hover-shadow`;行 34px、圆角 9px、hover `--platform-nav-item-icon-fill`、选中 `--platform-warm-bg` + 600;独立形态的卡片自己就是滚动区(`max-height: 194 = 170 + 上下各 12`,与合并形态同高),外壳形态的列表 `max-height: 170`(5×34,不出现半行)。 +- **首页强度区(分两步收口)**:先只同步了卡片观感(当时首页那颗没有 effort 的 state / props,`view/home/index.tsx` 只传 `className` 与 `disabled`,直接加强度区等于新增一个首页从未暴露过的设置项);随后按要求把强度区一并对齐,见下方"后续"。 +- **判据/取证**:`tests/appSurface/home.suite.ts`「首页模型菜单复用同一套共享样式面」——首页实例的 `.conversation-model-menu` 的祖先链里**没有** `.game-workbench-chat`,而只靠 `.conversation-model-select …` 就能解析出 16px 圆角 / 12px 内边距 / 34px 行,且菜单里确实有原生滑块(`input[type="range"]` 带 `aria-label="思维强度"`);`tests/chatDialogFrameLayout.test.ts` 的外壳用例按新选择器集合更新(锚点与卡片观感都来自共享面,工作台作用域只剩工具栏几何)。真机实测(无头 Chromium,同一夹具同一裁剪框):首页那颗 161×309 / 10px / 5px / 32px → **260×194 / 16px / 12px / 34px**(与工作台卡片逐项一致);工作台那颗外壳 260×262.5、内层列表 234×170、行 34/9px **前后逐值不变**。 +- **边界**:策划面与画布也复用同一个组件 → 它们的菜单观感同样变成这张卡片(有意为之;窄面板下卡片最小宽度 260px,仍受 `calc(100vw - 24px)` 与各自锚点约束)。若某个宿主需要另一套尺寸,在**该宿主作用域**里覆盖,不要再把观感抄第二份。 +- **后续(同一批收口)**:强度区与外壳锚点也一并抬到共享面——`view/home/index.tsx` 给首页那颗传了**同一个** `ComposerReasoningEffortSelect`(`presentation="section"`,读写仍是组件自己的 `read_game_creator_app_config` / `select_game_creator_reasoning_effort`),首页因此与工作台逐项同形:外壳 260×262.5、强度区 234×60.5、列表 234×170、滑块 222×26(447 与 1200 两档实测一致;工作台回归后逐值不变)。抬升时必须删掉工作台作用域里两条**死重**规则(给旧独立「推理档」控件排版的 `display: inline-flex` 一族),否则它们会以更高特异性盖掉共享面的 `display: grid`,把强度区重新算宽 24px。 +- **滑块两点(最终收口)**:档位圆点先是改成派生浅暖色、直径收到 6px(= 轨道高度),随后**整体删除**(见上面 D1 条)。「进度条高度与滑块高度一致」保留两种解释对照:**变体 A 落地**(填充段 = 轨道高度 6px),**变体 B**(填充段做成 20px = 圆钮高度)只在夹具里渲染出图、未提交。 + +## 2026-10-05 给 `shared-contracts` 请求 DTO 加字段:`#[serde(default)]` 救不了 Rust 结构体字面量,AGC 壳编译失败会伪装成 `npm run dev:all` 起不来 + +- **现象**:`npm run dev:all` 的根栈五个服务与 AGC Vite 都就绪、终端也会打印启动汇总,但 AGC 窗口始终不出现;stdout 里夹着 `error[E0063]: missing field `price_mud_points`in initializer of`GameDistributionCreateVersionRequest``(`src\game_distribution_publish.rs:1356`)和 `error: could not compile `genarrative-ai-game-creator-shell` (bin ...) due to 1 previous error`。编译失败不会让 `tauri dev` 退出,dev:all 只会一直挂着(`waitForAgcFrontend` 的 660s 预算耗尽后才收束整棵树),容易被误判成端口占用或根栈没起来。 +- **根因**:变更在 `server-rs/crates/shared-contracts/src/game_distribution.rs` 给请求 DTO 末尾追加了 `#[serde(default)] pub price_mud_points: u64`。`serde(default)` 只影响**反序列化缺字段**,Rust **结构体字面量仍必须列全字段**。AGC 壳 `apps/ai-game-creator-shell/src-tauri` 是**独立 cargo workspace**(自带 `[workspace]`,只把 `shared-contracts` 当 path 依赖),`server-rs` 侧的 `cargo test -p ...` 六个 crate 全绿也覆盖不到它。 +- **处理(现行口径)**:改 `shared-contracts` 里**请求 DTO 字段**时,必须同一次编译 AGC 壳:先 `npm run agc:bundled-resources:prepare`(`build.rs` 只做只读校验,未准备资源会以「随包插件存在未声明文件」失败关闭),再 `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute`(约 30s,feature 与 dev/release 对齐);完整口径是 `npm run ai-game-creator-shell:check:rust`。定价字段的取值口径分两段:**历史口径**(2026-10-05 之前)AGC 发布面板没有买断制定价入口,构造版本请求时显式按免费提交 `price_mud_points: 0`,与升级前行为一致;**现行口径** AGC 发布面板已支持「免费 / 买断制」定价,请求按作者选择从载荷透传价格,`#[serde(default)]` 仅用于向后兼容不传该字段的旧客户端(按免费处理)。 +- **边界**:`cargo check` **不能省 feature 参数**——默认 feature 下 `build.rs` 会因为已 staging 的 godot/unity 插件 DLL「不在当前 feature 组合的声明清单里」而红,那是资源准备前置条件,不是本次代码的编译错误。门禁本身**已经存在**:CI `.gitea/workflows/project-ci.yml` 的 `AI game creator shell Rust lane 1/2`、`lane 2/2` 各顺序跑两片 `npm run check:native-shells:agc-rust-shard-N`(底层 `ai-game-creator-shell:check:rust:shell` → `cargo test --locked` 编出壳 bin),本地同序列是 `npm run check:native-shells`。漏拦的原因是本地验证只跑了 `server-rs` 侧 crate、没等 MR/CI 上的 AGC lane,不是缺门禁;因为 2466 条 bin 单测跑满很贵,**不要**把整条 lane 塞进常用本地命令,只在改动 shared-contracts 请求 DTO 时补上面的 `cargo check`。 +- **判据/取证**:E0063 原文(`logs/dev-all-feature.log`);修复后 `tauri dev` watcher 重建输出 `Finished `dev` profile [unoptimized + debuginfo] target(s) in 38.16s` 并拉起 `target\debug\genarrative-ai-game-creator-shell.exe`;同一 feature 组合 `cargo check` 退出码 0;`.app/dev-stack.json` 记录的实际端口下 api-server `/healthz`、主站 `/`、admin-web `/admin/`、AGC marker `/__agc_dev_server.json` 均 200。 +- **关联**:`server-rs/crates/shared-contracts/src/game_distribution.rs`、`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs`、`.gitea/workflows/project-ci.yml`、`scripts/check-native-shells.mjs`。 + +## 2026-10-05 付费游戏播放会话前缀落在 `/api/*`:边缘转发 Cookie 会让 iframe 与包内每个资源都 403 + +- **现象**:付费游戏在真实浏览器里打不开——播放会话 `src`(`/api/game-distribution/play-sessions//`)本身和包内每个相对资源(JS/CSS/图片/音频)全是 403,同一份包的免费游戏 `/games//` 正常。 +- **原因**:播放会话前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie);`api-server` 播放网关对带**可解析平台 refresh Cookie** 的请求返回 403(与发行网关同族的纵深防御,本次不放宽),于是沙箱 iframe 的每个同前缀请求都带 Cookie、都被拒。dev 侧同样复现:`vite.config.ts` 原本只对 `/games/...` 清 Cookie,`/api/game-distribution` 规则会转发 Cookie。 +- **处理(现行口径)**:三份 nginx 模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)在通用 `/api` location **之前**加 `location ^~ /api/game-distribution/play-sessions/`——`^~` 不能省,否则正则 location `~ ^/api(?:/|$)` 优先命中、Cookie 又被转发;代理头 / `client_max_body_size 210m` / `limit_conn` / `limit_req` / 超时 / 维护判断都与通用 `/api` 一致,只多一条 `proxy_set_header Cookie ""`。`vite.config.ts` 在 `/api/game-distribution` 之前加同名前缀规则(`proxyReq.removeHeader('cookie')`)。Pingora 侧对应 `RouteDecision::PlaySessionGateway`:路径原样走 api 上游,同样套 api 限流分组、大小上限与维护闸,差别只在新增的 `route_clears_cookie` 清空 Cookie(发行入口 `ReleaseGateway` 复用同一判定)。 +- **边界**:前缀**只匹配带尾斜杠**的形式——创建会话的 `POST /api/game-distribution/play-sessions`(以及 `POST /api/game-distribution/games/{gameId}/play-session`)需要账号凭证,必须继续走通用 `/api` 并保留 Cookie。网关的 403 拒绝保持不变,只在边缘/dev 保证请求不带 Cookie。 +- **门禁**:`npm run check:nginx-spa-routes` 对三份模板断言该 `^~` location 存在、块内清空 Cookie、代理头齐全且排在通用 `/api` location 之前(变异验证:删掉块内 `proxy_set_header Cookie "";` 立刻报「播放会话前缀 location 缺少代理片段」);`npm run check:pingora-route-parity` 断言矩阵 `play_sessions_gateway` 用例声明清 Cookie 片段、不复用通用 `/api` location,且 Rust `classify_path` 的播放会话分支排在通用 `/api` 之前(变异验证:把矩阵片段换成通用 location、或在 Rust 里交换两个分支,各自单独判红);`npm run check:pingora-gateway-smoke` 用真实网关二进制断言该前缀清 Cookie、创建会话端点保留 Cookie。三条都串在 `npm run lint` 链里,有自动调用方。 +- **关联**:`server-rs/crates/pingora-gateway/src/main.rs`、`deploy/pingora/nginx-route-parity.matrix.json`、`deploy/nginx/README.md`、`vite.config.ts`;另见本文件「主站 SPA allowlist 有三处真相源」条的「别踩」(发行入口不转发 Cookie 的同族规则)。 + ## 2026-10-05 自定义作者插槽应保留昵称降级语义 - 游戏公开投影里的「创作者」「未知作者」是角色占位词。作者昵称 hook 已将加载中和查询失败分别转成 null 与空串,宿主不能再用 `|| game.author.name` 把占位词补回。 @@ -44,7 +83,7 @@ - **现象 / 风险**:改动 `preview.rs` 的注入逻辑(`inject_preview_fit_bridge` / `build_preview_response` / `PREVIEW_FIT_BRIDGE_TAG`)时,没有自动化门禁会告诉你「标签没注入」或「注入了两次」。两种失效都只在运行时才暴露:没注入等于整套运行画面点选静默失效(桥脚本的尺寸上报与点选都不执行,页面看起来完全正常);注入两次会让桥的监听、尺寸上报与点选回调各注册一遍,页面同样看不出差别。 - **现状**:`preview.rs` 的 `mod tests` 只有 4 个用例——`npm_preview_requires_build_and_prefers_bundled_assets`、`root_layout_serves_root_entry_and_keeps_legacy_paths_available`、`root_layout_does_not_expose_control_or_data_directories`、`legacy_layout_serves_root_ui_modules`;它们只断言预览路由的选取、状态行与页面自身文本,不涉及桥标签是否出现、出现几次,也不覆盖 `scan_preview_fit_bridge_html` 的「页面已带标签就不重复注入」分支(`inject_preview_fit_bridge` 里的 `if scan.has_bridge_script { return html.into_bytes(); }`)。 -- **结论(现行口径)**:这类「把常量原样返回 / 标签字符串存在」的转发型行为不固化成长用例(仓库口径:复制与源码文本断言不进测试)。改注入逻辑时按人工验证清单核对:① 响应 HTML 里 ```` 出现在 `
{entry.packageSha256.slice(0, 12)}
{'默认已勾选,可随时取消;不勾选则本次不上传工程源码。'}