AGC 发布面板接入买断制定价并抽取共享定价组件
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m0s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m47s
Project CI / Repository checks (pull_request) Failing after 56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m28s
Project CI / Frontend tests (pull_request) Successful in 4m18s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m7s
Project CI / Native shell tests (pull_request) Successful in 9m27s

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:覆盖免费/买断制切换、价格输入、上限提示、禁用态与错误行。
This commit is contained in:
2026-10-05 20:20:53 +08:00
parent 93de2a0d6c
commit ce737f4e8b
13 changed files with 665 additions and 75 deletions
@@ -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<GameDistributionInputMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) orientation: Option<GameDistributionOrientation>,
/// 线上作品当前生效的买断价(整数泥点,`0` 表示免费)。面板据此预填,作者不改就不会
/// 把已上线的付费作品静默改成免费;旧原生响应缺该字段时按免费兜底。
#[serde(default)]
pub(crate) price_mud_points: u64,
}
/// 发布面板打开时的发布状态回读。
@@ -235,6 +242,9 @@ struct OwnerGameEntry {
input_modes: Vec<String>,
#[serde(default)]
orientation: Option<String>,
/// 游戏行当前生效的买断价(作者自有视图的 `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<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()))
}
@@ -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<u64>,
expected_publication_revision: Option<u64>,
idempotency_key: Option<String>,
// 买断价(整数泥点,`0` 表示免费)。旧前端不传时为 `None`,按免费提交,保持向后兼容。
price_mud_points: Option<u64>,
) -> Result<GameDistributionPublishResult, String> {
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]
@@ -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<GameDistributionPublishMetadata['category']>('其他');
/** 付费方式与买断价输入;校验口径与网页、服务端一致。 */
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>(
@@ -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}
/>
</label>
<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"
@@ -968,7 +1015,9 @@ export function GameDistributionPublishPanel({
</div>
{cover ? null : (
<span className="game-distribution-publish-panel__hint">
{'封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
{
'封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'
}
</span>
)}
{coverGenerationDetail ? (
@@ -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 }
: {}),
@@ -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') {
@@ -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({
@@ -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;
}
@@ -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 (
<PlatformGamePricingField
mode={mode}
priceInput={priceInput}
disabled={disabled}
error={error}
onModeChange={setMode}
onPriceInputChange={setPriceInput}
/>
);
}
test('默认免费只渲染付费方式,不渲染买断价输入', () => {
render(<PricingHarness />);
expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty(
'checked',
true,
);
expect(screen.getByRole('radio', { name: '买断制' })).toHaveProperty(
'checked',
false,
);
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
});
test('切到买断制后显示价格输入、上限提示,并回传输入值', () => {
render(<PricingHarness />);
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(<PricingHarness disabled />);
expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('radio', { name: '买断制' })).toHaveProperty(
'disabled',
true,
);
});
test('使用方传入的校验错误在字段内以 alert 呈现', () => {
render(<PricingHarness error="买断价必须是整数泥点" />);
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(
<PlatformGamePricingField
mode="free"
priceInput=""
onModeChange={onModeChange}
onPriceInputChange={onPriceInputChange}
/>,
);
fireEvent.click(screen.getByRole('radio', { name: '买断制' }));
expect(onModeChange).toHaveBeenCalledWith('paid');
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
rerender(
<PlatformGamePricingField
mode="paid"
priceInput="0"
onModeChange={onModeChange}
onPriceInputChange={onPriceInputChange}
/>,
);
fireEvent.change(screen.getByLabelText(/买断价/u), {
target: { value: '12' },
});
expect(onPriceInputChange).toHaveBeenCalledWith('12');
});
@@ -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 (
<fieldset className="platform-game-pricing-field" disabled={disabled}>
<legend>付费方式</legend>
<div className="platform-game-pricing-field__modes">
<label className="platform-game-pricing-field__option">
<input
type="radio"
name={radioName}
checked={mode === 'free'}
disabled={disabled}
onChange={() => onModeChange('free')}
/>
免费
</label>
<label className="platform-game-pricing-field__option">
<input
type="radio"
name={radioName}
checked={mode === 'paid'}
disabled={disabled}
onChange={() => onModeChange('paid')}
/>
买断制
</label>
</div>
{mode === 'paid' ? (
<label className="platform-game-pricing-field__price">
买断价(泥点)
<input
type="number"
inputMode="numeric"
value={priceInput}
disabled={disabled}
onChange={(event) => onPriceInputChange(event.target.value)}
/>
<span className="platform-game-pricing-field__hint">
买断一次永久拥有;价格随本次版本审核通过后生效,最多{' '}
{PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 泥点
</span>
</label>
) : null}
{error ? (
<span className="platform-game-pricing-field__error" role="alert">
{error}
</span>
) : null}
</fieldset>
);
}
+7
View File
@@ -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,
@@ -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: '' };
}
@@ -0,0 +1,2 @@
export type { PlatformGamePricingFieldProps } from '@genarrative/shared/components';
export { PlatformGamePricingField } from '@genarrative/shared/components';
@@ -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({
</label>
))}
</fieldset>
<fieldset className="game-publish-fieldset">
<legend>付费方式</legend>
<label className="game-publish-checkbox">
<input
type="radio"
name="game-price-mode"
checked={priceMode === 'free'}
disabled={isResumingDraft}
onChange={() => setPriceMode('free')}
/>
免费
</label>
<label className="game-publish-checkbox">
<input
type="radio"
name="game-price-mode"
checked={priceMode === 'paid'}
disabled={isResumingDraft}
onChange={() => setPriceMode('paid')}
/>
买断制
</label>
</fieldset>
{priceMode === 'paid' ? (
<label className="game-publish-field">
买断价(泥点)
<input
type="number"
inputMode="numeric"
value={priceInput}
disabled={isResumingDraft}
onChange={(event) => setPriceInput(event.target.value)}
/>
<span className="game-publish-hint">
买断一次永久拥有;价格随本次版本审核通过后生效,最多{' '}
{MAX_GAME_PRICE_MUD_POINTS} 泥点
</span>
</label>
) : null}
<PlatformGamePricingField
mode={priceMode}
priceInput={priceInput}
disabled={isResumingDraft}
onModeChange={setPriceMode}
onPriceInputChange={setPriceInput}
/>
<GamePublishMediaFieldset
cover={coverAsset}
screenshots={screenshotAssets}
@@ -1,39 +1,24 @@
import {
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
type PlatformGamePricingMode,
resolvePlatformGamePriceMudPoints,
} from '../../../packages/shared/src/components/platformGamePricingModel';
import type { GameDistributionDeviceSupport } from '../../../packages/shared/src/contracts/gameDistribution';
import { MAX_GAME_SCREENSHOTS } from './gamePublishAssets';
/** 买断价上限,与服务端 `MAX_GAME_PRICE_MUD_POINTS` 保持一致。 */
export const MAX_GAME_PRICE_MUD_POINTS = 1_000_000;
/** 买断价上限,复用共享定价组件常量,网页与 AGC 及服务端保持同一口径。 */
export const MAX_GAME_PRICE_MUD_POINTS = PLATFORM_GAME_MAX_PRICE_MUD_POINTS;
export type GamePublishPriceMode = 'free' | 'paid';
export type GamePublishPriceMode = PlatformGamePricingMode;
/**
* 解析发布表单的付费方式与买断价输入。
*
* 口径与服务端一致:免费等价于 `0`;买断制必须是 `1..=1_000_000` 的整数泥点。
* 返回的 `error` 为空字符串表示可用,否则 `priceMudPoints` 无意义。
* 实现落在共享 `platformGamePricingModel`,网页与 AGC 发布面板共用同一份校验。
*/
export function resolveGamePublishPriceMudPoints(
mode: GamePublishPriceMode,
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 > 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: {