Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
# Conflicts: # apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs # apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx # apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx # docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md # package.json # packages/shared/src/contracts/gameDistribution.ts # scripts/check-game-distribution-dto-parity.mjs # server-rs/crates/api-server/src/modules/game_distribution.rs # server-rs/crates/module-game-distribution/src/errors.rs # server-rs/crates/module-game-distribution/src/lib.rs # server-rs/crates/shared-contracts/src/game_distribution.rs # server-rs/crates/spacetime-client/src/active.rs # server-rs/crates/spacetime-client/src/active/mapper.rs # server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs # server-rs/crates/spacetime-client/src/game_distribution.rs # server-rs/crates/spacetime-client/src/module_bindings.rs # server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs # server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs # server-rs/crates/spacetime-module/src/game_distribution.rs # server-rs/crates/spacetime-module/src/migration.rs # src/components/game-distribution/GameDetailPage.tsx # src/components/game-distribution/GameDistributionPages.test.tsx # src/components/game-distribution/GamePlayPage.tsx # src/components/platform-entry/PlatformEntryActiveFlowShell.tsx # src/services/gameDistributionClient.test.ts # src/services/gameDistributionClient.ts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AdminGameDistributionReviewPage
|
||||
@@ -300,6 +354,9 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('测试详细介绍')).toBeTruthy();
|
||||
// 详情资料区展示买断制价格(0 或缺字段按“免费”)。
|
||||
expect(screen.getByText('价格')).toBeTruthy();
|
||||
expect(screen.getByText('30 泥点')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
|
||||
expect(frame.getAttribute('src')).toBe(
|
||||
@@ -372,7 +429,6 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async
|
||||
expiresAt: '2026-10-02T12:20:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
@@ -415,3 +471,60 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('详情价格只渲染服务端下发的 priceMudPoints,不按冻结资料二次取价', async () => {
|
||||
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(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
<code>{entry.packageSha256.slice(0, 12)}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatPriceMudPoints(entry.priceMudPoints)}</td>
|
||||
<td>{formatAdminDateTime(entry.createdAt)}</td>
|
||||
<td className="admin-game-review-cell--actions">
|
||||
<AdminActionRow className="admin-review-actions">
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GameDistributionInputMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) orientation: Option<GameDistributionOrientation>,
|
||||
/// 更新面板预填的买断价(整数泥点,`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<GameDistributionPublicationDraft>,
|
||||
#[serde(default)]
|
||||
pub(crate) visibility: Option<String>,
|
||||
/// 平台侧这次发布实际会用的版本号(作者视图已有版本的最大 `versionNumber + 1`)。
|
||||
///
|
||||
/// 首次发布会新建作品,服务端从 `1` 开始派发,所以这里也是 `1`。拿不到平台数据的路径
|
||||
/// (`unavailable`、以及「有本地绑定但没能刷新线上状态」)不下发:渲染层不得回落到本地工程
|
||||
/// 迭代计数,只能据此禁用提交。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) next_version_number: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) message: Option<String>,
|
||||
}
|
||||
@@ -238,12 +254,22 @@ struct OwnerGameEntry {
|
||||
publication_revision: u64,
|
||||
#[serde(default)]
|
||||
latest_version: Option<OwnerGameVersion>,
|
||||
/// 该作者作品的版本列表(作者自有视图按最近更新倒序、单游戏最多 10 条)。
|
||||
///
|
||||
/// 平台只在客户端不传版本号时按 `max_existing + 1` 自动派发,所以「下一个版本号」必须取这里
|
||||
/// 已下发版本的最大 `versionNumber`;`latestVersion` 是最近**更新**的那条(可能是被改过状态的
|
||||
/// 历史版本),单靠它或本地工程迭代计数都会推出重复 / 回退的版本号。
|
||||
#[serde(default)]
|
||||
versions: Vec<OwnerGameVersion>,
|
||||
#[serde(default)]
|
||||
device_support: Option<GameDistributionDeviceSupport>,
|
||||
#[serde(default)]
|
||||
input_modes: Vec<String>,
|
||||
#[serde(default)]
|
||||
orientation: Option<String>,
|
||||
/// 游戏行当前生效的买断价(作者自有视图的 `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<String, String> {
|
||||
/// 发布资料摘要:随版本冻结的资料与买断价一起参与。
|
||||
///
|
||||
/// 价格必须进摘要:同一份包、同一版本标签只改价格时,幂等账本必须换键,否则会重放上一次
|
||||
/// 尝试的收据,把作者改后的价格静默丢掉。
|
||||
fn metadata_digest(
|
||||
metadata: &GameDistributionCreateGameRequest,
|
||||
price_mud_points: u64,
|
||||
) -> Result<String, String> {
|
||||
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<String>) -> GameDistributionPublicationRe
|
||||
refreshed: true,
|
||||
draft: None,
|
||||
visibility: None,
|
||||
// 首次发布会新建作品,平台自己也从 `1` 派发这一版的版本号。
|
||||
next_version_number: Some(1),
|
||||
message,
|
||||
}
|
||||
}
|
||||
@@ -752,15 +791,35 @@ fn unavailable_state(message: Option<String>) -> 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<u64>,
|
||||
idempotency_key: Option<String>,
|
||||
include_project_bundle: Option<bool>,
|
||||
// 买断价(整数泥点,`0` 表示免费)。旧前端不传时为 `None`,按免费提交,保持向后兼容。
|
||||
price_mud_points: Option<u64>,
|
||||
) -> Result<GameDistributionPublishResult, String> {
|
||||
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]
|
||||
|
||||
@@ -36,8 +36,8 @@ struct GamePublishAttemptRecord {
|
||||
/// 目标游戏身份:更新已有作品时非空;首次发布为空。
|
||||
#[serde(default)]
|
||||
target_game_id: Option<String>,
|
||||
/// 这次发布提交的项目版本标签(由清单 `versions[]` 的条数派生,不由 `projectVersion`
|
||||
/// 这种遗留兼容位提供):换了标签就是一次新尝试。
|
||||
/// 这次发布提交的平台版本号(作者视图里该作品的最大版本号 + 1,首次发布为 `1`;
|
||||
/// 不由清单 `versions[]` 的条数或遗留 `projectVersion` 位提供):换了版本号就是一次新尝试。
|
||||
#[serde(default)]
|
||||
target_version_number: Option<u64>,
|
||||
/// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。
|
||||
|
||||
@@ -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<u64>) -> Result<(), String> {
|
||||
if value == Some(0) {
|
||||
return Err("manifest projectVersion 必须是大于 0 的整数".to_string());
|
||||
|
||||
@@ -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(
|
||||
<div
|
||||
key={notice.id}
|
||||
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] w-max max-w-[min(680px,calc(100vw-2rem))] -translate-x-1/2"
|
||||
data-composer-model-toast="true"
|
||||
>
|
||||
<PlatformRuntimeStatusToast
|
||||
tone={notice.tone}
|
||||
surface="solid"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{notice.message}
|
||||
</PlatformRuntimeStatusToast>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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<number, () => 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` 表示这次由别的宿主渲染(调用方照样把 `<ComposerNoticeToast>` 挂上即可)。
|
||||
*/
|
||||
export function useComposerNoticeHost(): {
|
||||
notice: ComposerNotice | null;
|
||||
dismiss: () => void;
|
||||
} {
|
||||
const hostIdRef = useRef<number | null>(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 };
|
||||
}
|
||||
+91
-24
@@ -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<PlatformGamePricingMode>('free');
|
||||
const [priceInput, setPriceInput] = useState('');
|
||||
const [priceError, setPriceError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<GameDistributionPublishResult | null>(
|
||||
@@ -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<number | null>(
|
||||
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 ? (
|
||||
<label className="game-distribution-publish-panel__bundle-consent">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1000,30 +1047,49 @@ export function GameDistributionPublishPanel({
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{forkAuthorization !== 'forbidden' && !forkAuthorizationLocked ? (
|
||||
{forkAuthorization !== 'forbidden' &&
|
||||
!forkAuthorizationLocked ? (
|
||||
<p className="game-distribution-publish-panel__fork-hint">
|
||||
{'默认已勾选,可随时取消;不勾选则本次不上传工程源码。'}
|
||||
</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
<label>
|
||||
<div className="game-distribution-publish-panel__version-field">
|
||||
<span className="game-distribution-publish-panel__version-label">
|
||||
项目版本
|
||||
<span className="game-distribution-publish-panel__version-tag">
|
||||
{`v${projectVersion}`}
|
||||
{nextVersionNumber === null ? '—' : `v${nextVersionNumber}`}
|
||||
</span>
|
||||
</span>
|
||||
{/* 平台派发的版本号,作者不能填:只读 + 禁用观感,提交的也是同一个值。
|
||||
保留 readOnly 而不是 disabled:值仍可聚焦 / 可复制,读屏也能念出来。 */}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="game-distribution-publish-panel__version-value"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label="项目版本"
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
value={projectVersion}
|
||||
value={
|
||||
nextVersionNumber === null ? '' : String(nextVersionNumber)
|
||||
}
|
||||
placeholder="—"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<PlatformGamePricingField
|
||||
mode={priceMode}
|
||||
priceInput={priceInput}
|
||||
disabled={busy}
|
||||
error={priceError}
|
||||
onModeChange={(nextMode) => {
|
||||
setPriceMode(nextMode);
|
||||
setPriceError('');
|
||||
}}
|
||||
onPriceInputChange={(nextValue) => {
|
||||
setPriceInput(nextValue);
|
||||
setPriceError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="game-distribution-publish-panel__media"
|
||||
@@ -1240,6 +1306,7 @@ export function GameDistributionPublishPanel({
|
||||
Boolean(uploadingLabel) ||
|
||||
publicationState === 'checking' ||
|
||||
publicationState === 'unavailable' ||
|
||||
nextVersionNumber === null ||
|
||||
!title.trim() ||
|
||||
!summary.trim() ||
|
||||
!packageResult
|
||||
|
||||
+140
-72
@@ -1,5 +1,5 @@
|
||||
import { Check, ChevronDown, Lock, RefreshCcw } from 'lucide-react';
|
||||
import type { Ref } from 'react';
|
||||
import type { ReactNode, Ref } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { GameCreatorAppConfigView } from '../../app/types';
|
||||
import {
|
||||
publishComposerNotice,
|
||||
useComposerNoticeHost,
|
||||
} from '../../components/common/composerNotice';
|
||||
import { ComposerNoticeToast } from '../../components/common/ComposerNoticeToast';
|
||||
import {
|
||||
cachedLlmModelCatalog,
|
||||
type ClientLlmModel,
|
||||
@@ -70,12 +75,22 @@ export function ConversationModelSelect({
|
||||
onReady,
|
||||
projectPath,
|
||||
ref,
|
||||
strengthSection,
|
||||
}: {
|
||||
className?: string;
|
||||
disabled: boolean;
|
||||
onReady?: (ready: boolean) => void;
|
||||
projectPath?: string;
|
||||
ref?: Ref<ConversationModelSelectHandle>;
|
||||
/**
|
||||
* 菜单顶部的附加区(Direct 输入盒用来放「思维强度」滑块)。
|
||||
*
|
||||
* 传了它才会把弹层包一层 `.conversation-model-menu-shell`:外壳负责定位 / 描边 / 底色,
|
||||
* 强度区在上、模型列表(`role="listbox"`)在下,滑块**不进** listbox 子树
|
||||
* (参数控件放进菜单角色里会让读屏进菜单模式后读不到滑块)。
|
||||
* 不传时渲染与改动前完全一致,策划输入盒、首页、画布都不受影响。
|
||||
*/
|
||||
strengthSection?: ReactNode;
|
||||
}) {
|
||||
const initialCatalog = cachedLlmModelCatalog();
|
||||
const [models, setModels] = useState<ClientLlmModel[]>(
|
||||
@@ -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<HTMLDivElement | null>(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 = (
|
||||
<div
|
||||
className="conversation-model-menu"
|
||||
role="listbox"
|
||||
aria-label="对话模型"
|
||||
>
|
||||
{busy && models.length === 0 ? (
|
||||
<span className="conversation-model-menu-loading">正在读取模型</span>
|
||||
) : null}
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={model.id === selected}
|
||||
disabled={disabled || busy}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void select(model.id);
|
||||
}}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{model.displayName}</span>
|
||||
{model.id === defaultModelId ? (
|
||||
<em className="conversation-model-menu-default">默认</em>
|
||||
) : null}
|
||||
</span>
|
||||
{model.id === selected ? (
|
||||
<Check size={13} aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露,
|
||||
且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */}
|
||||
{unavailableModels.map((model) => {
|
||||
const reasonLabel = unavailableModelReasonLabel(model.reason);
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
aria-disabled="true"
|
||||
aria-label={`${model.displayName}:${reasonLabel}`}
|
||||
className="conversation-model-menu-option conversation-model-menu-option-locked"
|
||||
data-reason={model.reason}
|
||||
data-tooltip={reasonLabel}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{model.displayName}</span>
|
||||
</span>
|
||||
<Lock size={13} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="conversation-model-menu-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-menu-refresh"
|
||||
aria-label="刷新模型列表"
|
||||
disabled={disabled || busy || manualRefreshBusy}
|
||||
aria-busy={manualRefreshBusy}
|
||||
onClick={() => void syncCatalog(true, true)}
|
||||
>
|
||||
<RefreshCcw size={13} aria-hidden="true" />
|
||||
<span>{manualRefreshBusy ? '刷新中…' : '刷新模型列表'}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -416,12 +538,17 @@ export function ConversationModelSelect({
|
||||
: 'conversation-model-select'
|
||||
}
|
||||
>
|
||||
{error ? <span role="alert">{error}</span> : null}
|
||||
{notice ? <span role="status">{notice}</span> : null}
|
||||
{/* 失败与状态都只走浮层:控件里不再渲染常驻 `role="alert"` / `role="status"` 行。 */}
|
||||
<ComposerNoticeToast
|
||||
notice={composerNotice.notice}
|
||||
onDismiss={composerNotice.dismiss}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-trigger"
|
||||
aria-label="对话模型"
|
||||
/* 触发钮最窄可缩到 64px,模型名靠省略号收住:补 `title` 让被截断的名字仍可查看。 */
|
||||
title={triggerLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
@@ -437,79 +564,20 @@ export function ConversationModelSelect({
|
||||
/>
|
||||
<span className="conversation-model-trigger-label">
|
||||
{/* 控件位只显示已选模型名;目录还没读完时用「模型」占位(加载提示留在菜单里)。 */}
|
||||
{models.find((model) => model.id === selected)?.displayName ?? '模型'}
|
||||
{triggerLabel}
|
||||
</span>
|
||||
<ChevronDown size={13} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="conversation-model-menu"
|
||||
role="listbox"
|
||||
aria-label="对话模型"
|
||||
>
|
||||
{busy && models.length === 0 ? (
|
||||
<span className="conversation-model-menu-loading">
|
||||
正在读取模型
|
||||
</span>
|
||||
) : null}
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={model.id === selected}
|
||||
disabled={disabled || busy}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void select(model.id);
|
||||
}}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{model.displayName}</span>
|
||||
{model.id === defaultModelId ? (
|
||||
<em className="conversation-model-menu-default">默认</em>
|
||||
) : null}
|
||||
</span>
|
||||
{model.id === selected ? (
|
||||
<Check size={13} aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露,
|
||||
且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */}
|
||||
{unavailableModels.map((model) => {
|
||||
const reasonLabel = unavailableModelReasonLabel(model.reason);
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
aria-disabled="true"
|
||||
aria-label={`${model.displayName}:${reasonLabel}`}
|
||||
className="conversation-model-menu-option conversation-model-menu-option-locked"
|
||||
data-reason={model.reason}
|
||||
data-tooltip={reasonLabel}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{model.displayName}</span>
|
||||
</span>
|
||||
<Lock size={13} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="conversation-model-menu-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-menu-refresh"
|
||||
aria-label="刷新模型列表"
|
||||
disabled={disabled || busy || manualRefreshBusy}
|
||||
aria-busy={manualRefreshBusy}
|
||||
onClick={() => void syncCatalog(true, true)}
|
||||
>
|
||||
<RefreshCcw size={13} aria-hidden="true" />
|
||||
<span>{manualRefreshBusy ? '刷新中…' : '刷新模型列表'}</span>
|
||||
</button>
|
||||
</div>
|
||||
strengthSection ? (
|
||||
/* 合并形态(Direct):强度区在上、模型列表在下,外壳负责定位与描边。 */
|
||||
<div className="conversation-model-menu-shell">
|
||||
{strengthSection}
|
||||
{modelMenu}
|
||||
</div>
|
||||
) : (
|
||||
modelMenu
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
+52
-37
@@ -114,6 +114,13 @@ type ResourceReferenceInputProps = {
|
||||
submitSuppressed?: boolean;
|
||||
/** 输入区操作排(编辑器右侧那一列)里宿主注入的内容,例如素材选择器的 `@` 触发钮。 */
|
||||
inputActions?: ReactNode;
|
||||
/**
|
||||
* 把「AI 润色 / 恢复原文」这一排**渲染到宿主指定的容器里**(`createPortal`)。
|
||||
*
|
||||
* Direct 输入区把它挪进下方那条工具条,让润色钮与 `@` / `+` / 模型 / 语音 / 发送 同一水平线
|
||||
* (否则它会自成一行,窄面板下看起来像"堆叠")。不传时仍然渲染在输入盒内(策划面等保持原样)。
|
||||
*/
|
||||
actionsPortalTarget?: HTMLElement | null;
|
||||
inputRef?: RefObject<HTMLDivElement | null>;
|
||||
/** 输入区容器元素:宿主定位自己的浮层时用它当锚点。 */
|
||||
containerRef?: RefObject<HTMLDivElement | null>;
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="resource-reference-input-actions">
|
||||
{inputActions}
|
||||
{showPolishAction ? (
|
||||
<>
|
||||
/**
|
||||
* 「输入区操作排」这一块:宿主注入的 `inputActions` + 内置的「AI 润色 / 恢复原文」。
|
||||
* 默认渲染在输入盒里;宿主给了 `actionsPortalTarget` 时改渲染到那个容器(Direct 把它放进
|
||||
* 下方工具条,与 `@` / `+` / 模型 / 语音 / 发送 同一行)。
|
||||
*/
|
||||
const inputActionsBlock = (
|
||||
<div className="resource-reference-input-actions">
|
||||
{inputActions}
|
||||
{showPolishAction ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="resource-reference-input-polish"
|
||||
aria-label="AI 润色"
|
||||
title="AI 润色"
|
||||
aria-busy={polishing}
|
||||
disabled={disabled || polishing || draftText.trim() === ''}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void polishPrompt()}
|
||||
>
|
||||
{polishing ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{polishedOriginalText !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="resource-reference-input-polish"
|
||||
aria-label="AI 润色"
|
||||
title="AI 润色"
|
||||
aria-busy={polishing}
|
||||
disabled={disabled || polishing || draftText.trim() === ''}
|
||||
className="resource-reference-input-restore"
|
||||
aria-label="恢复原文"
|
||||
title="恢复原文"
|
||||
disabled={disabled || polishing}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void polishPrompt()}
|
||||
onClick={restoreOriginalPrompt}
|
||||
>
|
||||
{polishing ? (
|
||||
<Loader2
|
||||
size={15}
|
||||
className="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
)}
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{polishedOriginalText !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="resource-reference-input-restore"
|
||||
aria-label="恢复原文"
|
||||
title="恢复原文"
|
||||
disabled={disabled || polishing}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={restoreOriginalPrompt}
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionsPortalTarget
|
||||
? createPortal(inputActionsBlock, actionsPortalTarget)
|
||||
: inputActionsBlock}
|
||||
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
|
||||
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
|
||||
{showPolishAction &&
|
||||
|
||||
@@ -22,7 +22,7 @@ export type ClientLlmModelCatalog = LlmModelsResponse;
|
||||
|
||||
export async function loadClientLlmModels(): Promise<ClientLlmModelCatalog> {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) throw new Error('本地模型服务不可用');
|
||||
if (!invoke) throw new Error('模型服务不可用');
|
||||
// Rust typed command 已经把响应解析成 `LlmModelsResponse`(含 unavailableModels)。
|
||||
return invoke<ClientLlmModelCatalog>('load_game_creator_llm_models');
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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({
|
||||
<ConversationModelSelect
|
||||
className="home-input-model-select"
|
||||
disabled={homeCreationBusy || creationBusy}
|
||||
/* 与工作台同一形态:思维强度区在模型菜单顶部(同一个组件、同一份配置通道)。
|
||||
首页没有浮层宿主,所以状态文案沿用控件内的 `inline`(Direct 那边是 toast)。 */
|
||||
strengthSection={
|
||||
<ComposerReasoningEffortSelect
|
||||
disabled={homeCreationBusy || creationBusy}
|
||||
presentation="section"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="grid size-7 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) transition-transform hover:scale-105 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
|
||||
+276
-66
@@ -16,7 +16,7 @@ import {
|
||||
Square,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { CSSProperties, RefObject } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
@@ -25,11 +25,17 @@ import type {
|
||||
GameCreatorAppConfigView,
|
||||
GameCreatorLlmReasoningEffort,
|
||||
} from '../../../../../app/types';
|
||||
import {
|
||||
publishComposerNotice,
|
||||
useComposerNoticeHost,
|
||||
} from '../../../../../components/common/composerNotice';
|
||||
import { ComposerNoticeToast } from '../../../../../components/common/ComposerNoticeToast';
|
||||
import {
|
||||
composerReasoningEffortOptions,
|
||||
DEFAULT_COMPOSER_REASONING_EFFORT,
|
||||
normalizeComposerReasoningEffort,
|
||||
} from '../../../../../features/project-workspace/composerReasoningEffort';
|
||||
import { LLM_CONFIG_CHANGED_EVENT } from '../../../../../services/llmModelCatalog';
|
||||
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
|
||||
import {
|
||||
resolveSpeechRecognitionCtor,
|
||||
@@ -310,8 +316,15 @@ export function ComposerVoiceButton({
|
||||
*/
|
||||
export function ComposerReasoningEffortSelect({
|
||||
disabled,
|
||||
presentation = 'dropdown',
|
||||
}: {
|
||||
disabled: boolean;
|
||||
/**
|
||||
* `dropdown`(默认)保持原来的下拉列表,供策划输入盒沿用;
|
||||
* `section` 只渲染强度本体(标题 + 滑块 + 档位点),供 Direct 输入盒嵌进
|
||||
* 模型菜单的顶部——底行因此只留一颗控件(模型触发钮),不再额外占位。
|
||||
*/
|
||||
presentation?: 'dropdown' | 'section';
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
|
||||
@@ -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<GameCreatorLlmReasoningEffort | null>(null);
|
||||
const previewEffortRef = useRef<GameCreatorLlmReasoningEffort | null>(null);
|
||||
const writeChainRef = useRef<Promise<unknown>>(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<HTMLInputElement | null>(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<GameCreatorAppConfigView>('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<GameCreatorLlmReasoningEffort | null>(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 = (
|
||||
<div className="conversation-model-select">
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-trigger"
|
||||
aria-label="推理档"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span
|
||||
className="conversation-model-trigger-status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="conversation-model-trigger-label">{activeLabel}</span>
|
||||
<ChevronDown size={13} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="conversation-model-menu"
|
||||
role="listbox"
|
||||
aria-label="推理档"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === effort}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
selectEffort(option.value);
|
||||
}}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
{option.value === effort ? (
|
||||
<Check size={13} aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
/*
|
||||
强度区(Direct 输入盒):嵌在模型菜单顶部,底行因此只留一颗控件。
|
||||
原生 `<input type="range">` 自带 `role="slider"` 与 ←/→/Home/End 键盘行为,
|
||||
点轨道(含拖拽)按 `step` 吸附到档位,`aria-valuetext` 报中文档位名;
|
||||
浮层的开合与 Esc / 点外部关闭由模型菜单自己负责(本区不再自带触发钮与浮层)。
|
||||
*/
|
||||
const strengthSection = (
|
||||
<span
|
||||
className="project-chat-reasoning-effort is-section"
|
||||
role="group"
|
||||
aria-label="思维强度"
|
||||
>
|
||||
<span className="project-chat-composer-strength-head">
|
||||
<span className="project-chat-composer-strength-title">思维强度</span>
|
||||
<span className="project-chat-composer-strength-value">
|
||||
{`当前:${activeLabel}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="project-chat-composer-strength-slider">
|
||||
{/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是
|
||||
中文档位名,读屏念的是「最高」而不是「4」。 */}
|
||||
<input
|
||||
ref={rangeRef}
|
||||
type="range"
|
||||
className="project-chat-composer-strength-range"
|
||||
min={0}
|
||||
max={options.length - 1}
|
||||
step={1}
|
||||
value={activeIndex}
|
||||
style={
|
||||
{
|
||||
/* 档位比例(0~1,无单位):CSS 用它算出**圆钮中心**,已选段终点就落在那里。 */
|
||||
'--strength-ratio': String(activeIndex / (options.length - 1)),
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-label="思维强度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={options.length - 1}
|
||||
aria-valuenow={activeIndex}
|
||||
aria-valuetext={activeLabel}
|
||||
/* 拖动中保持可用(写盘只在松手后发生),视觉靠预览值跟手。
|
||||
`onInput`(浏览器里连续触发)与 `onChange`(React 对 range 的常规映射)都接,
|
||||
两者更新同一个预览,重复调用没有副作用。 */
|
||||
disabled={disabled}
|
||||
onChange={previewFromEvent}
|
||||
onInput={previewFromEvent}
|
||||
/* 松手 / 抬键 / 失焦才落盘:拖动中写盘会 disable 控件、原生拖拽当场中断。 */
|
||||
onPointerUp={commitPreview}
|
||||
/* 触屏取消(pointercancel)与"松手"同一口径:一并提交当前预览,
|
||||
否则预览值会滞留到下一次提交或卸载 flush。 */
|
||||
onPointerCancel={commitPreview}
|
||||
onKeyUp={commitPreview}
|
||||
onBlur={commitPreview}
|
||||
/>
|
||||
{/* 档位圆点已删除(用户口径「很丑」,第一选择就是删):轨道本身就是刻度,
|
||||
命中与按 `step` 吸附仍由原生 range 负责,`aria-valuetext` 报中文档位名。 */}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="project-chat-reasoning-effort">
|
||||
{/* 与模型选择器同一套观感:复用 `conversation-model-*` 的触发钮与浮层样式,
|
||||
不再用原生 `<select>`(原生下拉是操作系统菜单,圆角/描边/选中态都跟旁边不一致)。 */}
|
||||
<div className="conversation-model-select">
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-trigger"
|
||||
aria-label="推理档"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span
|
||||
className="conversation-model-trigger-status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="conversation-model-trigger-label">
|
||||
{options.find((option) => option.value === effort)?.label ?? effort}
|
||||
</span>
|
||||
<ChevronDown size={13} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="conversation-model-menu"
|
||||
role="listbox"
|
||||
aria-label="推理档"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === effort}
|
||||
disabled={disabled || saving}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
selectEffort(option.value);
|
||||
}}
|
||||
>
|
||||
<span className="conversation-model-menu-option-main">
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
{option.value === effort ? (
|
||||
<Check size={13} aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{notice ? (
|
||||
<span role="status" className="project-chat-composer-notice">
|
||||
{notice}
|
||||
</span>
|
||||
) : null}
|
||||
{presentation === 'section' ? strengthSection : dropdownSelect}
|
||||
{/* 状态文案只走浮层:控件里不再渲染常驻 `role="status"` 行。 */}
|
||||
<ComposerNoticeToast
|
||||
notice={composerNotice.notice}
|
||||
onDismiss={composerNotice.dismiss}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
+26
-2
@@ -133,6 +133,9 @@ export function DirectProjectComposer({
|
||||
}) {
|
||||
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
|
||||
const composerRootRef = useRef<HTMLDivElement | null>(null);
|
||||
/** 工具条里给「AI 润色 / 恢复原文」的挂点(`ResourceReferenceInput` 用 portal 渲染进来)。 */
|
||||
const [controlsActionsSlot, setControlsActionsSlot] =
|
||||
useState<HTMLSpanElement | null>(null);
|
||||
const pickerRef = useRef<ResourceReferencePickerHandle | null>(null);
|
||||
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
|
||||
const modelValidateInFlightRef = useRef(false);
|
||||
@@ -263,6 +266,9 @@ export function DirectProjectComposer({
|
||||
placeholder="描述你的想法,或 @ 引用素材"
|
||||
submitSuppressed={pickerOpen}
|
||||
containerRef={composerRootRef}
|
||||
/* 「AI 润色 / 恢复原文」排进下方工具条**发送钮左边**:与发送钮同为圆角矩形按钮、
|
||||
相邻成组;空输入态也不再自带一行(窄面板下那一行看起来像"堆叠")。 */
|
||||
actionsPortalTarget={controlsActionsSlot}
|
||||
/>
|
||||
<div className="project-chat-composer-controls">
|
||||
<div className="project-chat-composer-controls-left">
|
||||
@@ -283,18 +289,36 @@ export function DirectProjectComposer({
|
||||
/>
|
||||
</div>
|
||||
<div className="project-chat-composer-controls-right">
|
||||
<ComposerReasoningEffortSelect disabled={busy} />
|
||||
<ConversationModelSelect
|
||||
ref={modelSelectRef}
|
||||
disabled={busy}
|
||||
onReady={setModelReady}
|
||||
projectPath={projectPath ?? ''}
|
||||
/* 强度滑块并进模型菜单:底行只留这一颗控件(少占一个位)。 */
|
||||
strengthSection={
|
||||
<ComposerReasoningEffortSelect
|
||||
disabled={busy}
|
||||
presentation="section"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ComposerVoiceButton
|
||||
disabled={busy || modelValidating}
|
||||
onTranscript={(text) => composerRef.current?.insertText(text)}
|
||||
onTranscript={(text) => {
|
||||
/* 拿到转写就把语音的提示收掉:提示是「上一次动作的回执」,
|
||||
留着会变成输入区里的常驻文本(那一排容不下常驻行)。 */
|
||||
setVoiceNotice('');
|
||||
composerRef.current?.insertText(text);
|
||||
}}
|
||||
onNotice={setVoiceNotice}
|
||||
/>
|
||||
{/* 润色 / 恢复原文的挂点(portal 目标):放在**发送钮左边**——它与发送钮同为
|
||||
圆角矩形按钮,相邻成组更协调;DOM 顺序即 `模型 → 麦克风 → 润色 → 发送`,
|
||||
所以 Tab 序与读屏顺序与视觉一致。 */}
|
||||
<span
|
||||
className="project-chat-composer-controls-actions"
|
||||
ref={setControlsActionsSlot}
|
||||
/>
|
||||
{canStopTurn ? (
|
||||
<ComposerStopButton
|
||||
cancelling={turnCancelling}
|
||||
|
||||
@@ -34,10 +34,10 @@ export type ProjectVersionResourceSummary = GameIterationVersion & {
|
||||
childVersionIds: string[];
|
||||
/**
|
||||
* 只有**最新内部版本**(`manifest.versions` 最后一条)带它,值是内部版本序数
|
||||
* (= `versions.length`,也就是发布时会提交的那个标签,与发布面板的
|
||||
* `resolveGameCreationAppProjectVersion` 同源)。
|
||||
* (= `versions.length`)。
|
||||
*
|
||||
* 卡面据此只渲染**中性**的「最新」角标:发布固定取最新内部版本,卡面不把它写成
|
||||
* 它是 AGC 工程内部的迭代序数,**不是**发布时提交的平台版本号(后者取平台作者视图里
|
||||
* 该作品的最大版本号 + 1)。卡面据此只渲染**中性**的「最新」角标,不把它写成
|
||||
* 「发布标签」,免得诱导用户以为要挑一张卡才能发。
|
||||
*/
|
||||
publicationTag?: number;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<HTMLElement>(
|
||||
'[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('高');
|
||||
});
|
||||
|
||||
|
||||
@@ -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<HTMLElement>(
|
||||
'.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<HTMLInputElement>(
|
||||
'.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<string, string>, 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<HTMLElement>(
|
||||
'[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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, string>) {
|
||||
return { top: value(0), right: value(1), bottom: value(2), left: value(3) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开 `gap` 简写,只关心主/交叉两轴。
|
||||
*
|
||||
* 一值写四个方向同一个数;两值先 `row-gap` 后 `column-gap`(与 CSS 的 `gap: <row> <column>`
|
||||
* 一致)。三层输入区里两条行间距分别写在输入盒与输入盒自己身上,读值时必须分得清是哪一条。
|
||||
*/
|
||||
function gapBox(declarations: Map<string, string>) {
|
||||
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<string, string>, 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<string, string>, 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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user