From ce737f4e8bf91258cc1ffb5e41fc94e5b91dafd9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 20:20:53 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E5=8F=91=E5=B8=83=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E4=B9=B0=E6=96=AD=E5=88=B6=E5=AE=9A=E4=BB=B7?= =?UTF-8?q?=E5=B9=B6=E6=8A=BD=E5=8F=96=E5=85=B1=E4=BA=AB=E5=AE=9A=E4=BB=B7?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/shared/src/components:新增 PlatformGamePricingField(.tsx/.css/.test.tsx)与 platformGamePricingModel(上限常量与解析),并从 components/index.ts 导出;组件只表达付费方式单选、买断价输入、上限提示、禁用态与错误行,校验由使用方持有。 src/components/common/PlatformGamePricingField.tsx:按既有的 common 适配约定新增网页端再导出。 src/components/game-distribution/GamePublishPage.tsx:把内联的付费方式 fieldset 与买断价输入替换为共享组件,文案、校验、禁用逻辑与既有用例保持不变。 src/components/game-distribution/gamePublishMetadata.ts:买断价上限与解析改为复用共享常量与共享函数,不再各写一份。 apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx:首次发布与更新两条路径都加入共享定价组件,默认免费;提交前本地校验并透传价格。 apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts:发布命令载荷透传 priceMudPoints,并做 0..=1000000 兜底校验;发布草稿回读新增 priceMudPoints 字段。 apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:Tauri 命令新增可选 price_mud_points(旧前端不传按免费,向后兼容)并替换硬编码的 0;回读作者作品时解析 priceMudPoints,按「最近版本冻结价优先、回退游戏行当前价」预填,避免已上线付费作品被静默改成免费;发布摘要纳入价格,只改价格也会换幂等键,不会重放上一次收据。 apps/ai-game-creator-shell/tests:补充买断价透传、本地校验拦截、更新模式价格预填与免费兜底用例。 packages/shared/src/components/PlatformGamePricingField.test.tsx:覆盖免费/买断制切换、价格输入、上限提示、禁用态与错误行。 --- .../src/game_distribution_publish.rs | 97 +++++++++++-- .../GameDistributionPublishPanel.tsx | 51 ++++++- .../src/services/gameDistributionPublish.ts | 26 ++++ .../tests/gameDistributionPublish.test.ts | 39 +++++ .../gameDistributionPublishPanel.test.tsx | 99 +++++++++++++ .../components/PlatformGamePricingField.css | 82 +++++++++++ .../PlatformGamePricingField.test.tsx | 134 ++++++++++++++++++ .../components/PlatformGamePricingField.tsx | 85 +++++++++++ packages/shared/src/components/index.ts | 7 + .../components/platformGamePricingModel.ts | 33 +++++ .../common/PlatformGamePricingField.tsx | 2 + .../game-distribution/GamePublishPage.tsx | 48 ++----- .../game-distribution/gamePublishMetadata.ts | 37 ++--- 13 files changed, 665 insertions(+), 75 deletions(-) create mode 100644 packages/shared/src/components/PlatformGamePricingField.css create mode 100644 packages/shared/src/components/PlatformGamePricingField.test.tsx create mode 100644 packages/shared/src/components/PlatformGamePricingField.tsx create mode 100644 packages/shared/src/components/platformGamePricingModel.ts create mode 100644 src/components/common/PlatformGamePricingField.tsx diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index b8d627f11..5f22e6d7c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -48,6 +48,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 { @@ -101,6 +104,10 @@ pub(crate) struct GameDistributionPublicationDraft { pub(crate) input_modes: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) orientation: Option, + /// 线上作品当前生效的买断价(整数泥点,`0` 表示免费)。面板据此预填,作者不改就不会 + /// 把已上线的付费作品静默改成免费;旧原生响应缺该字段时按免费兜底。 + #[serde(default)] + pub(crate) price_mud_points: u64, } /// 发布面板打开时的发布状态回读。 @@ -235,6 +242,9 @@ struct OwnerGameEntry { input_modes: Vec, #[serde(default)] orientation: Option, + /// 游戏行当前生效的买断价(作者自有视图的 `priceMudPoints`)。 + #[serde(default)] + price_mud_points: u64, } #[derive(Clone, Debug, Default, Deserialize)] @@ -245,6 +255,9 @@ struct OwnerGameVersion { version_number: u64, #[serde(default)] status: String, + /// 该版本审核通过后冻结的买断价;历史版本缺省为 `0`。 + #[serde(default)] + price_mud_points: u64, } #[derive(Clone, Debug, Deserialize)] @@ -426,11 +439,19 @@ fn normalized_origin(value: &str) -> String { value.trim().trim_end_matches('/').to_string() } -fn metadata_digest(metadata: &GameDistributionCreateGameRequest) -> Result { +/// 发布资料摘要:随版本冻结的资料与买断价一起参与。 +/// +/// 价格必须进摘要:同一份包、同一版本标签只改价格时,幂等账本必须换键,否则会重放上一次 +/// 尝试的收据,把作者改后的价格静默丢掉。 +fn metadata_digest( + metadata: &GameDistributionCreateGameRequest, + price_mud_points: u64, +) -> Result { let bytes = serde_json::to_vec(metadata).map_err(|error| format!("计算发布资料摘要失败:{error}"))?; let mut hasher = Sha256::new(); hasher.update(&bytes); + hasher.update(price_mud_points.to_le_bytes()); Ok(format!("{:x}", hasher.finalize())) } @@ -781,9 +802,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, @@ -1237,6 +1270,8 @@ pub(crate) async fn publish_local_project_game( version_number: Option, expected_publication_revision: Option, idempotency_key: Option, + // 买断价(整数泥点,`0` 表示免费)。旧前端不传时为 `None`,按免费提交,保持向后兼容。 + price_mud_points: Option, ) -> Result { let snapshot = require_platform_session()?; let app_data_dir = app @@ -1283,7 +1318,12 @@ pub(crate) async fn publish_local_project_game( if target_game_id.is_some() && expected_publication_revision.is_none() { return Err("缺少公开修订号,请重新打开发布面板后再试".to_string()); } - 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)?; // 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」解析: // 同一份包重发、响应丢失后重试、进程重启后的分片续传都落在同一次服务端尝试上; // 包内容、目标版本标签或资料任一变化都换键,新标签的提交不会被幂等收据吞掉。 @@ -1360,8 +1400,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, - // AGC 发布面板不提供买断制定价入口;显式按免费提交,保持升级前行为。 - price_mud_points: 0, + // 买断价随版本冻结;面板已本地校验,这里透传服务端口径的整数泥点。 + price_mud_points, game_metadata: metadata, }; let version_value = request_json( @@ -1633,11 +1673,13 @@ mod tests { orientation: shared_contracts::game_distribution::GameDistributionOrientation::Responsive, }; - 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); } #[test] @@ -1649,20 +1691,59 @@ mod tests { "summary": "守住轨道城", "status": "published", "publicationRevision": 4, + "priceMudPoints": 240, "latestVersion": { "versionId": "gamever-2", "versionNumber": 2, - "status": "pending_review" + "status": "pending_review", + "priceMudPoints": 300 } })) .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); 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 publication_prefill_price_prefers_latest_frozen_price_and_falls_back() { + // 最新版本冻结价 > 0:与网页更新模式同源,直接用它。 + let paid = OwnerGameEntry { + price_mud_points: 240, + latest_version: Some(OwnerGameVersion { + price_mud_points: 300, + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(resolve_publication_prefill_price(&paid), 300); + + // 历史版本没有冻结价(0):回退游戏行当前价,避免把已上线的付费作品预填成免费。 + let legacy = OwnerGameEntry { + price_mud_points: 240, + latest_version: Some(OwnerGameVersion::default()), + ..Default::default() + }; + assert_eq!(resolve_publication_prefill_price(&legacy), 240); + + // 免费作品、以及完全缺该字段的旧响应,都按 0(免费)。 + assert_eq!( + resolve_publication_prefill_price(&OwnerGameEntry::default()), + 0 + ); + let legacy_entry = parse_owner_game_entry(json!({ + "id": "game-2", + "title": "免费游戏", + "latestVersion": { "versionId": "v1", "versionNumber": 1, "status": "published" } + })) + .expect("legacy owner entry"); + assert_eq!(resolve_publication_prefill_price(&legacy_entry), 0); } #[test] diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 51dc44f27..f9ce9a26b 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -1,6 +1,11 @@ import { Eye, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { PlatformGamePricingField } from '../../../../../packages/shared/src/components/PlatformGamePricingField'; +import { + type PlatformGamePricingMode, + resolvePlatformGamePriceMudPoints, +} from '../../../../../packages/shared/src/components/platformGamePricingModel'; import { type GameCreationAppManifest, resolveGameCreationAppProjectVersion, @@ -237,6 +242,10 @@ export function GameDistributionPublishPanel({ const [summary, setSummary] = useState(''); const [category, setCategory] = useState('其他'); + /** 付费方式与买断价输入;校验口径与网页、服务端一致。 */ + const [priceMode, setPriceMode] = useState('free'); + const [priceInput, setPriceInput] = useState(''); + const [priceError, setPriceError] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState( @@ -304,6 +313,9 @@ export function GameDistributionPublishPanel({ setTitle(manifest.name.trim()); setSummary(fallbackSummary); setCategory('其他'); + setPriceMode('free'); + setPriceInput(''); + setPriceError(''); setCover(null); setScreenshots([]); setBusy(false); @@ -422,6 +434,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)); } @@ -686,6 +712,12 @@ export function GameDistributionPublishPanel({ setError('素材还在上传中,请稍候再发布'); return; } + const price = resolvePlatformGamePriceMudPoints(priceMode, priceInput); + if (price.error) { + setPriceError(price.error); + return; + } + setPriceError(''); setBusy(true); setError(''); setPublishPhase({ @@ -742,6 +774,7 @@ export function GameDistributionPublishPanel({ ? (publicationBinding?.gameId ?? null) : null, versionNumber: projectVersion, + priceMudPoints: price.priceMudPoints, expectedPublicationRevision: publicationState === 'update' ? (publicationBinding?.revision ?? null) @@ -909,6 +942,20 @@ export function GameDistributionPublishPanel({ value={projectVersion} /> + { + setPriceMode(nextMode); + setPriceError(''); + }} + onPriceInputChange={(nextValue) => { + setPriceInput(nextValue); + setPriceError(''); + }} + />
{cover ? null : ( - {'封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'} + { + '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。' + } )} {coverGenerationDetail ? ( diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index aa5fb0b9e..bdb809439 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -1,3 +1,4 @@ +import { PLATFORM_GAME_MAX_PRICE_MUD_POINTS } from '../../../../packages/shared/src/components/platformGamePricingModel'; import { type GameCreationAppManifest, type GameCreationAppPublicationBinding, @@ -74,6 +75,13 @@ export type GameDistributionPublicationDraft = { deviceSupport?: GameDistributionDeviceSupport | null; inputModes?: GameDistributionInputMode[]; orientation?: GameDistributionOrientation | null; + /** + * 线上该作品当前的买断价(整数泥点,`0` 表示免费)。 + * + * 更新面板据此预填价格与模式:作者不改就不会把已上线的付费作品静默改成免费。 + * 旧原生端不返回该字段,读取方按 `0`(免费)兜底。 + */ + priceMudPoints?: number | null; }; /** @@ -308,6 +316,13 @@ export async function publishLocalProjectGame(args: { versionNumber?: number | null; /** 更新送审所需的公开修订号 CAS;来自发布绑定。 */ expectedPublicationRevision?: number | null; + /** + * 本版本的买断价(整数泥点,`0` 表示免费)。 + * + * 缺省或为空按免费提交,兼容未接入定价的调用方;服务端仍会独立校验 + * `0..=1_000_000`。发布面板在提交前已按同一口径拦住非法输入。 + */ + priceMudPoints?: number | null; /** * 发布根幂等键。渲染层默认**不**生成也不持久化它:原生发布链路按 * 「账号身份 + 本地项目 + 发行包摘要 + 目标游戏/版本 + 资料摘要」在应用数据里维护发布 @@ -334,6 +349,16 @@ export async function publishLocalProjectGame(args: { 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 || @@ -353,6 +378,7 @@ export async function publishLocalProjectGame(args: { metadata: gameMetadata, ...(gameId ? { gameId } : {}), versionNumber, + priceMudPoints, ...(gameId ? { expectedPublicationRevision: args.expectedPublicationRevision } : {}), diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index 33ee4da1f..691b40443 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -79,6 +79,8 @@ describe('Rust 发布 facade', () => { projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', idempotencyKey: 'agc-publish-test', + // 未传价格时按免费提交,保持旧调用方向后兼容。 + priceMudPoints: 0, metadata: { localProjectId: 'local-proj-1', coverAssetId: 'asset_cover', @@ -163,6 +165,43 @@ describe('Rust 发布 facade', () => { expect(invoke).not.toHaveBeenCalled(); }); + test('买断价原样透传给 native 发布命令', async () => { + const { invoke, calls } = installNativeInvoke((command) => { + if (command === 'publish_local_project_game') return PUBLISH_RESULT; + throw new Error(`未预期的命令:${command}`); + }); + + await publishLocalProjectGame({ + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { coverAssetId: 'asset_cover' }, + priceMudPoints: 240, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.args).toMatchObject({ priceMudPoints: 240 }); + }); + + test('买断价越界或非整数时在 native command 前失败关闭', async () => { + const invoke = vi.fn() as unknown as TauriInvoke; + const base = { + invoke, + projectPath: '/tmp/project', + packageRelativePath: 'exports/playtest-package-1.zip', + manifest: MANIFEST, + metadata: { coverAssetId: 'asset_cover' }, + }; + await expect( + publishLocalProjectGame({ ...base, priceMudPoints: 1_000_001 }), + ).rejects.toThrow('买断价必须是 0 到 1000000 之间的整数泥点'); + await expect( + publishLocalProjectGame({ ...base, priceMudPoints: 1.5 }), + ).rejects.toThrow('买断价必须是 0 到 1000000 之间的整数泥点'); + expect(invoke).not.toHaveBeenCalled(); + }); + test('资料建议和价格均通过 native command', async () => { const { invoke, calls } = installNativeInvoke((command) => { if (command === 'suggest_game_distribution_publish_metadata') { diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx index f0f071c79..3ed014c1f 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx @@ -330,6 +330,8 @@ describe('GameDistributionPublishPanel', () => { // 根幂等键由原生发布链路按「账号 + 本地项目 + 包摘要」的账本解析并持久化, // 渲染层不再自己铸键,因此这里不带 idempotencyKey。 expect(args?.idempotencyKey).toBeUndefined(); + // 默认免费:不改付费方式就按 0 泥点提交。 + expect(args?.priceMudPoints).toBe(0); expect(invoke).toBeDefined(); expect(await screen.findByText('已提交审核')).not.toBeNull(); @@ -337,6 +339,47 @@ describe('GameDistributionPublishPanel', () => { expect(onPublished).toHaveBeenCalledTimes(1); }); + test('买断制先本地校验价格,合法时随版本提交', async () => { + installTauriInvoke(async () => undefined); + vi.mocked(publishLocalProjectGame).mockResolvedValue({ + gameId: 'game_1', + versionId: 'gamever_1', + versionNumber: 1, + status: 'pending_review', + packageSha256: 'a'.repeat(64), + packageSizeBytes: 3, + fileCount: 2, + }); + renderPanel(); + await selectCover(); + const submit = await screen.findByRole('button', { name: '发布游戏' }); + + fireEvent.click(screen.getByLabelText('买断制')); + // 空价格先被本地拦住,不发任何发布请求。 + fireEvent.click(submit); + expect(await screen.findByText('买断价必须是整数泥点')).not.toBeNull(); + expect(publishLocalProjectGame).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText(/买断价/u), { + target: { value: '1000001' }, + }); + fireEvent.click(submit); + expect( + await screen.findByText('买断价必须是 1 到 1000000 之间的整数泥点'), + ).not.toBeNull(); + expect(publishLocalProjectGame).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText(/买断价/u), { + target: { value: '120' }, + }); + fireEvent.click(submit); + await waitFor(() => + expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), + ); + const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0]; + expect(args?.priceMudPoints).toBe(120); + }); + test('连续点击只提交一次,上传中禁用动作按钮', async () => { installTauriInvoke(async () => undefined); let resolvePublish: (value: unknown) => void = () => undefined; @@ -756,6 +799,8 @@ describe('GameDistributionPublishPanel', () => { // 提交的就是派生出来的内部版本序数,与线上最近提交的 v2 无关。 expect(args?.versionNumber).toBe(1); expect(args?.expectedPublicationRevision).toBe(7); + // 该草稿是历史响应,没有回读价格:按免费预填,不填就提交 0。 + expect(args?.priceMudPoints).toBe(0); // 冻结素材 ID 直接复用,不要求作者重新上传封面/截图。 expect(args?.metadata?.coverAssetId).toBe('asset_cover_old'); expect(args?.metadata?.screenshots).toEqual(['asset_shot_old']); @@ -771,6 +816,60 @@ describe('GameDistributionPublishPanel', () => { expect(onPublished).toHaveBeenCalledTimes(1); }); + test('更新模式预填线上买断价,作者不改就按原价提交', async () => { + installTauriInvoke(async () => undefined); + vi.mocked(readGameDistributionPublication).mockResolvedValue({ + state: 'update', + binding: { + accountId: 'user-1', + apiBaseUrl: 'https://dev.test', + gameId: 'game_9', + revision: 7, + latestVersionId: 'gamever_2', + latestVersionNumber: 2, + status: 'published', + }, + refreshed: true, + draft: { + title: '星轨防线(线上)', + summary: '线上简介', + description: '线上详介', + category: '策略', + tags: ['塔防'], + cover: { + assetId: 'asset_cover_old', + previewUrl: 'https://assets.test/cover.png', + }, + screenshots: [], + priceMudPoints: 240, + }, + visibility: 'published', + }); + vi.mocked(publishLocalProjectGame).mockResolvedValue({ + gameId: 'game_9', + versionId: 'gamever_3', + versionNumber: 1, + status: 'pending_review', + packageSha256: 'a'.repeat(64), + packageSizeBytes: 3, + fileCount: 2, + }); + renderPanel(); + + const submit = await screen.findByRole('button', { name: '更新游戏' }); + // 预填线上价与模式:不改就按原价提交,避免审核通过后把付费作品静默改成免费。 + expect(screen.getByLabelText('买断制')).toHaveProperty('checked', true); + expect(screen.getByLabelText(/买断价/u)).toHaveProperty('value', '240'); + + fireEvent.click(submit); + await waitFor(() => + expect(publishLocalProjectGame).toHaveBeenCalledTimes(1), + ); + expect( + vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].priceMudPoints, + ).toBe(240); + }); + test('项目版本由工程内部版本条数派生,线上版本号与遗留字段都不参与', async () => { installTauriInvoke(async () => undefined); vi.mocked(readGameDistributionPublication).mockResolvedValue({ diff --git a/packages/shared/src/components/PlatformGamePricingField.css b/packages/shared/src/components/PlatformGamePricingField.css new file mode 100644 index 000000000..7cf807904 --- /dev/null +++ b/packages/shared/src/components/PlatformGamePricingField.css @@ -0,0 +1,82 @@ +/* + * 买断制定价字段:网页发布页与 AGC 发布面板共用同一份表现与文案。 + * + * 选择器统一写成「根类 + 子类」而不是裸类:AGC 发布面板的 + * `.game-distribution-publish-panel__fields label/input` 是后代选择器,裸类会被它 + * 覆盖。提高一档特异性后,无论宿主在前后引入样式,组件外观都由这里唯一决定。 + */ +.platform-game-pricing-field { + display: grid; + gap: 0.55rem; + min-width: 0; + margin: 0; + border: 0; + padding: 0; + font-size: 0.76rem; + color: var(--platform-text-strong, #3d1f10); +} + +.platform-game-pricing-field > legend { + padding: 0 0.3rem; + font-size: 0.72rem; + font-weight: 700; +} + +.platform-game-pricing-field .platform-game-pricing-field__modes { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; +} + +.platform-game-pricing-field .platform-game-pricing-field__option { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.76rem; + font-weight: 500; +} + +.platform-game-pricing-field .platform-game-pricing-field__option input { + width: auto; + min-width: 0; + margin: 0; +} + +.platform-game-pricing-field .platform-game-pricing-field__price { + display: grid; + gap: 0.3rem; + font-weight: 500; +} + +.platform-game-pricing-field .platform-game-pricing-field__price input { + box-sizing: border-box; + width: 100%; + min-width: 0; + border: 1px solid var(--platform-surface-border, rgba(226, 203, 184, 0.72)); + border-radius: 0.7rem; + padding: 0.55rem 0.7rem; + background: var(--platform-panel-fill, #fffdfa); + color: var(--platform-text-strong, #3d1f10); + font: inherit; + font-weight: 500; +} + +.platform-game-pricing-field + .platform-game-pricing-field__price + input:focus-visible { + border-color: var(--platform-accent, #c7653d); + outline: 2px solid rgba(199, 101, 61, 0.2); + outline-offset: 1px; +} + +.platform-game-pricing-field .platform-game-pricing-field__hint { + color: var(--platform-text-soft, #988476); + font-size: 0.7rem; + font-weight: 500; +} + +.platform-game-pricing-field .platform-game-pricing-field__error { + color: var(--platform-button-danger-text, #a6402f); + font-size: 0.7rem; + font-weight: 600; +} diff --git a/packages/shared/src/components/PlatformGamePricingField.test.tsx b/packages/shared/src/components/PlatformGamePricingField.test.tsx new file mode 100644 index 000000000..29e4a5712 --- /dev/null +++ b/packages/shared/src/components/PlatformGamePricingField.test.tsx @@ -0,0 +1,134 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { expect, test, vi } from 'vitest'; + +import { + PLATFORM_GAME_MAX_PRICE_MUD_POINTS, + PlatformGamePricingField, + resolvePlatformGamePriceMudPoints, +} from './index'; + +function PricingHarness({ + disabled = false, + error = '', +}: { + disabled?: boolean; + error?: string; +}) { + const [mode, setMode] = useState<'free' | 'paid'>('free'); + const [priceInput, setPriceInput] = useState(''); + return ( + + ); +} + +test('默认免费只渲染付费方式,不渲染买断价输入', () => { + render(); + + expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty( + 'checked', + true, + ); + expect(screen.getByRole('radio', { name: '买断制' })).toHaveProperty( + 'checked', + false, + ); + expect(screen.queryByLabelText(/买断价/u)).toBeNull(); +}); + +test('切到买断制后显示价格输入、上限提示,并回传输入值', () => { + render(); + + fireEvent.click(screen.getByRole('radio', { name: '买断制' })); + + const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement; + expect(priceInput.value).toBe(''); + expect(screen.getByText(/最多 1000000 泥点/u)).toBeTruthy(); + + fireEvent.change(priceInput, { target: { value: '240' } }); + expect(priceInput.value).toBe('240'); + + fireEvent.click(screen.getByRole('radio', { name: '免费' })); + expect(screen.queryByLabelText(/买断价/u)).toBeNull(); +}); + +test('禁用态锁住单选与价格输入', () => { + render(); + + expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty( + 'disabled', + true, + ); + expect(screen.getByRole('radio', { name: '买断制' })).toHaveProperty( + 'disabled', + true, + ); +}); + +test('使用方传入的校验错误在字段内以 alert 呈现', () => { + render(); + + expect(screen.getByRole('alert').textContent).toBe('买断价必须是整数泥点'); +}); + +test('价格解析与提交口径一致:免费为 0,买断制要求 1..=1000000 整数', () => { + expect(resolvePlatformGamePriceMudPoints('free', 'abc')).toEqual({ + priceMudPoints: 0, + error: '', + }); + expect(resolvePlatformGamePriceMudPoints('paid', '1000000')).toEqual({ + priceMudPoints: PLATFORM_GAME_MAX_PRICE_MUD_POINTS, + error: '', + }); + expect(resolvePlatformGamePriceMudPoints('paid', '')).toEqual({ + priceMudPoints: 0, + error: '买断价必须是整数泥点', + }); + expect(resolvePlatformGamePriceMudPoints('paid', '1.5')).toEqual({ + priceMudPoints: 0, + error: '买断价必须是整数泥点', + }); + expect(resolvePlatformGamePriceMudPoints('paid', '1000001')).toEqual({ + priceMudPoints: 0, + error: '买断价必须是 1 到 1000000 之间的整数泥点', + }); +}); + +test('onModeChange 与 onPriceInputChange 只回传使用方状态', () => { + const onModeChange = vi.fn(); + const onPriceInputChange = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('radio', { name: '买断制' })); + expect(onModeChange).toHaveBeenCalledWith('paid'); + expect(screen.queryByLabelText(/买断价/u)).toBeNull(); + + rerender( + , + ); + fireEvent.change(screen.getByLabelText(/买断价/u), { + target: { value: '12' }, + }); + expect(onPriceInputChange).toHaveBeenCalledWith('12'); +}); diff --git a/packages/shared/src/components/PlatformGamePricingField.tsx b/packages/shared/src/components/PlatformGamePricingField.tsx new file mode 100644 index 000000000..ab3fc3d94 --- /dev/null +++ b/packages/shared/src/components/PlatformGamePricingField.tsx @@ -0,0 +1,85 @@ +import './PlatformGamePricingField.css'; + +import { + PLATFORM_GAME_MAX_PRICE_MUD_POINTS, + type PlatformGamePricingMode, +} from './platformGamePricingModel'; + +export type PlatformGamePricingFieldProps = { + /** 付费方式;`free` 等价于 0 泥点。 */ + mode: PlatformGamePricingMode; + /** 买断价原始输入(受控字符串),校验由使用方持有。 */ + priceInput: string; + disabled?: boolean; + /** 使用方算出的校验结果;为空串或省略时不渲染错误行。 */ + error?: string; + onModeChange: (mode: PlatformGamePricingMode) => void; + onPriceInputChange: (value: string) => void; + /** 同页存在多组定价字段时的单选组名;省略时用默认值。 */ + radioName?: string; +}; + +/** + * 平台游戏买断制定价字段。 + * + * 网页发布页与 AGC 发布面板共用同一份表现和文案;组件只表达输入 UI,价格校验、提交 + * 与业务状态由使用方持有。上限取自共享常量,两端不各写一份。 + */ +export function PlatformGamePricingField({ + mode, + priceInput, + disabled = false, + error = '', + onModeChange, + onPriceInputChange, + radioName = 'platform-game-pricing-mode', +}: PlatformGamePricingFieldProps) { + return ( +
+ 付费方式 +
+ + +
+ {mode === 'paid' ? ( + + ) : null} + {error ? ( + + {error} + + ) : null} +
+ ); +} diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts index c60cc131a..cc8365e97 100644 --- a/packages/shared/src/components/index.ts +++ b/packages/shared/src/components/index.ts @@ -44,6 +44,13 @@ export type { PlatformFilterToolbarTabItem, } from './PlatformFilterToolbar'; export { PlatformFilterToolbar } from './PlatformFilterToolbar'; +export type { PlatformGamePricingFieldProps } from './PlatformGamePricingField'; +export { PlatformGamePricingField } from './PlatformGamePricingField'; +export type { PlatformGamePricingMode } from './platformGamePricingModel'; +export { + PLATFORM_GAME_MAX_PRICE_MUD_POINTS, + resolvePlatformGamePriceMudPoints, +} from './platformGamePricingModel'; export type { PlatformIconBadgeProps, PlatformIconBadgeShape, diff --git a/packages/shared/src/components/platformGamePricingModel.ts b/packages/shared/src/components/platformGamePricingModel.ts new file mode 100644 index 000000000..cdeb09c3a --- /dev/null +++ b/packages/shared/src/components/platformGamePricingModel.ts @@ -0,0 +1,33 @@ +/** 买断价上限,与服务端 `MAX_GAME_PRICE_MUD_POINTS` 保持一致。 */ +export const PLATFORM_GAME_MAX_PRICE_MUD_POINTS = 1_000_000; + +export type PlatformGamePricingMode = 'free' | 'paid'; + +/** + * 解析付费方式与买断价输入。 + * + * 口径与服务端一致:免费等价于 `0`;买断制必须是 `1..=1_000_000` 的整数泥点。 + * 返回的 `error` 为空字符串表示可用,否则 `priceMudPoints` 无意义。 + */ +export function resolvePlatformGamePriceMudPoints( + mode: PlatformGamePricingMode, + rawValue: string, +): { priceMudPoints: number; error: string } { + if (mode === 'free') return { priceMudPoints: 0, error: '' }; + const value = rawValue.trim(); + if (!/^\d+$/u.test(value)) { + return { priceMudPoints: 0, error: '买断价必须是整数泥点' }; + } + const priceMudPoints = Number(value); + if ( + !Number.isSafeInteger(priceMudPoints) || + priceMudPoints < 1 || + priceMudPoints > PLATFORM_GAME_MAX_PRICE_MUD_POINTS + ) { + return { + priceMudPoints: 0, + error: `买断价必须是 1 到 ${PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 之间的整数泥点`, + }; + } + return { priceMudPoints, error: '' }; +} diff --git a/src/components/common/PlatformGamePricingField.tsx b/src/components/common/PlatformGamePricingField.tsx new file mode 100644 index 000000000..8940d5792 --- /dev/null +++ b/src/components/common/PlatformGamePricingField.tsx @@ -0,0 +1,2 @@ +export type { PlatformGamePricingFieldProps } from '@genarrative/shared/components'; +export { PlatformGamePricingField } from '@genarrative/shared/components'; diff --git a/src/components/game-distribution/GamePublishPage.tsx b/src/components/game-distribution/GamePublishPage.tsx index 9173c67fb..0b7a027ab 100644 --- a/src/components/game-distribution/GamePublishPage.tsx +++ b/src/components/game-distribution/GamePublishPage.tsx @@ -20,6 +20,7 @@ import { } from '../../services/gameDistributionClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformGamePricingField } from '../common/PlatformGamePricingField'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { GAME_METADATA_CATEGORIES, @@ -40,7 +41,6 @@ import { applyVersionMediaDefaults } from './gamePublishMediaDefaults'; import { GamePublishMediaFieldset } from './GamePublishMediaFieldset'; import { type GamePublishPriceMode, - MAX_GAME_PRICE_MUD_POINTS, resolveGamePublishPriceMudPoints, resolvePublishMetadataError, } from './gamePublishMetadata'; @@ -804,45 +804,13 @@ export function GamePublishPage({ ))} -
- 付费方式 - - -
- {priceMode === 'paid' ? ( - - ) : null} + MAX_GAME_PRICE_MUD_POINTS - ) { - return { - priceMudPoints: 0, - error: `买断价必须是 1 到 ${MAX_GAME_PRICE_MUD_POINTS} 之间的整数泥点`, - }; - } - return { priceMudPoints, error: '' }; -} +export const resolveGamePublishPriceMudPoints = + resolvePlatformGamePriceMudPoints; /** 作者侧元数据校验;口径与服务端 `validate_game_metadata` 保持一致。 */ export function resolvePublishMetadataError(input: {