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
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:
@@ -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]
|
||||
|
||||
+50
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user