项目版本改用平台派发的下一个版本号:AGC 只读展示 + 提交同一个值
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / Repository checks (pull_request) Failing after 33s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m39s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Successful in 9m1s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / Repository checks (pull_request) Failing after 33s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m39s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Successful in 9m1s
- packages/shared:定价字段提示句补 `id` 并挂进 `aria-describedby`(与错误行并列),恢复「最多 1000000 泥点」的程序化关联;补两条断言(有错/无误时描述里都含上限)。 - packages/shared:分组标题改用 `PlatformFieldLabel variant="field"`(12px/700/弱化色),与网页「支持设备 / 封面与截图」的 legend 同号;价格输入中和原生 number spinner(`appearance: textfield` + 隐藏 `::-webkit-inner-spin-button`),不再压住「泥点」单位,`inputMode="numeric"` 保留。 - packages/shared:非法态去掉组件级 `!important`,改由网页宿主 remap 的 `[aria-invalid='true']` 分支给出错误色(`src/index.css`,排在 `:focus` 之后),组件侧保留正常权重同名声明供 AGC 生效。 - AGC 壳 Rust(`game_distribution_publish.rs`):作者作品条目解析 `versions[]`;新增 `resolve_owner_next_version_number`(已有版本最大 `versionNumber + 1`,对齐平台 `resolve_version_number(None)`);发布状态回读下发 `nextVersionNumber`(首次发布 `1`;刷新成功的更新 `max+1`;`unavailable` 与「有绑定但没刷新成功」为 `None`);补单测两条。 - AGC 服务层(`gameDistributionPublish.ts`):`versionNumber` 改为必填,删除按清单 `versions[]` 条数的回落;回读结果类型补 `nextVersionNumber`。 - AGC 发布面板:删除本地派生的 `projectVersion`,`项目版本` 显示并提交同一个平台版本号;字段改为只读 + 禁用观感(未知时显示 `—` 占位);平台版本未知时提交按钮禁用并提示重试;「项目版本」不再可从 AGC 手填。 - AGC 样式:新增 `__version-field` / `__version-value`(与相邻字段同形 + 灰底只读观感),并更新版本标签注释为平台口径。 - 测试:AGC 面板三条新口径断言(显示值==提交值==平台下一个版本号;本地迭代计数与遗留字段不参与;平台数据不可得时显示占位/提交禁用/不调用发布)、非 Tauri 宿主用例改为断言禁用、服务层用例改为「只认调用方传入值 + 缺省失败关闭」、live 用例补显式版本号;陈旧注释同步(`game_publish_attempt.rs`、`project/manifest.rs`、`resourceProjectionModel.ts`)。
This commit is contained in:
@@ -130,6 +130,13 @@ pub(crate) struct GameDistributionPublicationReadResult {
|
||||
pub(crate) draft: Option<GameDistributionPublicationDraft>,
|
||||
#[serde(default)]
|
||||
pub(crate) visibility: Option<String>,
|
||||
/// 平台侧这次发布实际会用的版本号(作者视图已有版本的最大 `versionNumber + 1`)。
|
||||
///
|
||||
/// 首次发布会新建作品,服务端从 `1` 开始派发,所以这里也是 `1`。拿不到平台数据的路径
|
||||
/// (`unavailable`、以及「有本地绑定但没能刷新线上状态」)不下发:渲染层不得回落到本地工程
|
||||
/// 迭代计数,只能据此禁用提交。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) next_version_number: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) message: Option<String>,
|
||||
}
|
||||
@@ -238,6 +245,13 @@ struct OwnerGameEntry {
|
||||
publication_revision: u64,
|
||||
#[serde(default)]
|
||||
latest_version: Option<OwnerGameVersion>,
|
||||
/// 该作者作品的版本列表(作者自有视图按最近更新倒序、单游戏最多 10 条)。
|
||||
///
|
||||
/// 平台只在客户端不传版本号时按 `max_existing + 1` 自动派发,所以「下一个版本号」必须取这里
|
||||
/// 已下发版本的最大 `versionNumber`;`latestVersion` 是最近**更新**的那条(可能是被改过状态的
|
||||
/// 历史版本),单靠它或本地工程迭代计数都会推出重复 / 回退的版本号。
|
||||
#[serde(default)]
|
||||
versions: Vec<OwnerGameVersion>,
|
||||
#[serde(default)]
|
||||
device_support: Option<GameDistributionDeviceSupport>,
|
||||
#[serde(default)]
|
||||
@@ -531,6 +545,8 @@ fn first_publish_state(message: Option<String>) -> GameDistributionPublicationRe
|
||||
refreshed: true,
|
||||
draft: None,
|
||||
visibility: None,
|
||||
// 首次发布会新建作品,平台自己也从 `1` 派发这一版的版本号。
|
||||
next_version_number: Some(1),
|
||||
message,
|
||||
}
|
||||
}
|
||||
@@ -542,15 +558,35 @@ fn unavailable_state(message: Option<String>) -> GameDistributionPublicationRead
|
||||
refreshed: false,
|
||||
draft: None,
|
||||
visibility: None,
|
||||
// 拿不到平台数据就不给版本号:渲染层据此禁用提交,不得回落到本地工程迭代计数。
|
||||
next_version_number: None,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1`。
|
||||
///
|
||||
/// 与平台自身的派发口径一致(`module-game-distribution::resolve_version_number` 的
|
||||
/// `max_existing + 1`)。`latestVersion` 只是最近更新的一条,必须与版本列表一起取最大值。
|
||||
/// 作者视图单游戏最多下发 10 条版本(`GAME_DISTRIBUTION_MAX_OWNER_VERSIONS_PER_GAME`):
|
||||
/// 号最大的那条被挤出列表时会低估一个已经存在的版本号,这是当前作者视图的取数边界。
|
||||
fn resolve_owner_next_version_number(entry: &OwnerGameEntry) -> u64 {
|
||||
entry
|
||||
.versions
|
||||
.iter()
|
||||
.chain(entry.latest_version.iter())
|
||||
.map(|version| version.version_number)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1)
|
||||
}
|
||||
|
||||
/// 回写发布绑定;本地遗留的 `projectVersion` 一并清空。
|
||||
///
|
||||
/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不再从绑定推导(它由
|
||||
/// AGC 工程内部版本 `versions[]` 派生、只读),所以这里只落绑定,并把存量清单里可能残留的
|
||||
/// 遗留标签置空——`skip_serializing_if` 会让该键随下一次落盘消失。
|
||||
/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不从绑定推导,也不来自
|
||||
/// AGC 工程内部版本:它取平台作者视图里该作品的最大版本号 + 1(发布时显式提交同一个值),
|
||||
/// 所以这里只落绑定,并把存量清单里可能残留的遗留标签置空——`skip_serializing_if` 会让该键
|
||||
/// 随下一次落盘消失。
|
||||
fn write_publication_binding(
|
||||
root: &Path,
|
||||
binding: GameCreationAppPublicationBinding,
|
||||
@@ -855,6 +891,7 @@ async fn bound_publication_result(
|
||||
refreshed: true,
|
||||
draft: Some(draft),
|
||||
visibility: Some(entry.status.clone()).filter(|status| !status.is_empty()),
|
||||
next_version_number: Some(resolve_owner_next_version_number(&entry)),
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
@@ -907,12 +944,14 @@ pub(crate) async fn read_game_distribution_publication(
|
||||
}
|
||||
Err(GameDistributionReadError::Unavailable(message)) => {
|
||||
// 结果未知:保留本地绑定,按更新模式返回但标注未刷新,由发布时的 CAS 兜底。
|
||||
// 这一路没有平台版本数据,所以不给版本号:渲染层禁用提交,等下一次刷新成功。
|
||||
Ok(GameDistributionPublicationReadResult {
|
||||
state: "update".to_string(),
|
||||
binding: Some(binding),
|
||||
refreshed: false,
|
||||
draft: None,
|
||||
visibility: None,
|
||||
next_version_number: None,
|
||||
message: Some(format!("无法刷新线上状态:{message}")),
|
||||
})
|
||||
}
|
||||
@@ -1526,7 +1565,8 @@ pub(crate) async fn publish_local_project_game(
|
||||
}
|
||||
});
|
||||
// 发布成功:把平台作品绑定和最近版本状态写回本地清单。项目版本标签不在这里对齐——它由
|
||||
// 工程内部版本派生,这次提交的平台版本号不回写本地(遗留 `projectVersion` 一并清空)。
|
||||
// 平台作者视图的最大版本号 + 1 推出、在提交前已经决定了下一次要用的值(遗留 `projectVersion`
|
||||
// 一并清空)。
|
||||
// 写回失败不回滚已经成功的发布,但会把错误交给作者——否则下次打开项目会退回首次发布,
|
||||
// 甚至可能重复建作品。
|
||||
let binding = GameCreationAppPublicationBinding {
|
||||
@@ -1539,7 +1579,7 @@ pub(crate) async fn publish_local_project_game(
|
||||
status: Some(status.clone()),
|
||||
};
|
||||
let manifest = crate::project::mutate_manifest_at(root, |manifest| {
|
||||
// 项目版本标签由工程内部版本派生,不由这次提交的平台版本号回写;遗留位一并清空。
|
||||
// 项目版本标签由平台作者视图派生并在提交前传下去,不由这次提交的平台版本号回写;遗留位一并清空。
|
||||
manifest.project_version = None;
|
||||
manifest.publication = Some(binding.clone());
|
||||
Ok(manifest.clone())
|
||||
@@ -1699,13 +1739,28 @@ mod tests {
|
||||
"versionNumber": 2,
|
||||
"status": "pending_review",
|
||||
"priceMudPoints": 300
|
||||
}
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"versionId": "gamever-2",
|
||||
"versionNumber": 2,
|
||||
"status": "pending_review",
|
||||
"priceMudPoints": 300
|
||||
},
|
||||
{
|
||||
"versionId": "gamever-5",
|
||||
"versionNumber": 5,
|
||||
"status": "published",
|
||||
"priceMudPoints": 0
|
||||
}
|
||||
]
|
||||
}))
|
||||
.expect("owner entry");
|
||||
assert_eq!(entry.id, "game-1");
|
||||
assert_eq!(entry.local_project_id.as_deref(), Some("proj-1"));
|
||||
assert_eq!(entry.publication_revision, 4);
|
||||
assert_eq!(entry.price_mud_points, 240);
|
||||
assert_eq!(entry.versions.len(), 2);
|
||||
let latest = entry.latest_version.expect("latest version");
|
||||
assert_eq!(latest.version_id, "gamever-2");
|
||||
assert_eq!(latest.version_number, 2);
|
||||
@@ -1713,6 +1768,47 @@ mod tests {
|
||||
assert_eq!(latest.price_mud_points, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_version_number_follows_platform_maximum_not_latest_or_local_count() {
|
||||
// 作者视图按最近更新倒序:`latestVersion` 是 v2(最近被改过状态),列表里号最大的是 v5。
|
||||
// 平台派发口径是 max_existing + 1,所以下一个版本号必须是 6,不能是 2 + 1。
|
||||
let entry = OwnerGameEntry {
|
||||
latest_version: Some(OwnerGameVersion {
|
||||
version_number: 2,
|
||||
..Default::default()
|
||||
}),
|
||||
versions: vec![
|
||||
OwnerGameVersion {
|
||||
version_number: 2,
|
||||
..Default::default()
|
||||
},
|
||||
OwnerGameVersion {
|
||||
version_number: 5,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(resolve_owner_next_version_number(&entry), 6);
|
||||
|
||||
// 没有任何版本记录的作品:平台会从 1 开始派发。
|
||||
assert_eq!(
|
||||
resolve_owner_next_version_number(&OwnerGameEntry::default()),
|
||||
1
|
||||
);
|
||||
|
||||
// 列表被条数上限截断、只带回旧版本时也只能按已下发的最大值推出(取数边界见函数注释)。
|
||||
let truncated = OwnerGameEntry {
|
||||
latest_version: None,
|
||||
versions: vec![OwnerGameVersion {
|
||||
version_number: 1,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(resolve_owner_next_version_number(&truncated), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_prefill_price_prefers_latest_frozen_price_and_falls_back() {
|
||||
// 最新版本冻结价 > 0:与网页更新模式同源,直接用它。
|
||||
|
||||
@@ -36,8 +36,8 @@ struct GamePublishAttemptRecord {
|
||||
/// 目标游戏身份:更新已有作品时非空;首次发布为空。
|
||||
#[serde(default)]
|
||||
target_game_id: Option<String>,
|
||||
/// 这次发布提交的项目版本标签(由清单 `versions[]` 的条数派生,不由 `projectVersion`
|
||||
/// 这种遗留兼容位提供):换了标签就是一次新尝试。
|
||||
/// 这次发布提交的平台版本号(作者视图里该作品的最大版本号 + 1,首次发布为 `1`;
|
||||
/// 不由清单 `versions[]` 的条数或遗留 `projectVersion` 位提供):换了版本号就是一次新尝试。
|
||||
#[serde(default)]
|
||||
target_version_number: Option<u64>,
|
||||
/// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。
|
||||
|
||||
@@ -207,9 +207,9 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin
|
||||
|
||||
/// 遗留字段 `projectVersion` 的取值范围:缺省合法(旧清单),写了就必须是正整数。
|
||||
///
|
||||
/// 它**不参与**发布版本标签的推导(标签由 `versions[]` 的条数派生),只是为了让存量清单继续
|
||||
/// 可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值,所以在读
|
||||
/// 写两侧都失败关闭,避免坏值被静默传播。
|
||||
/// 它**不参与**发布版本号的推导(版本号取平台作者视图里该作品的最大版本号 + 1),只是为了让
|
||||
/// 存量清单继续可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值,
|
||||
/// 所以在读写两侧都失败关闭,避免坏值被静默传播。
|
||||
fn validate_manifest_project_version(value: Option<u64>) -> Result<(), String> {
|
||||
if value == Some(0) {
|
||||
return Err("manifest projectVersion 必须是大于 0 的整数".to_string());
|
||||
|
||||
+39
-21
@@ -4,12 +4,9 @@ import {
|
||||
resolvePlatformGamePriceMudPoints,
|
||||
} from '@genarrative/shared/components';
|
||||
import { Eye, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type GameCreationAppManifest,
|
||||
resolveGameCreationAppProjectVersion,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { type GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution';
|
||||
import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
@@ -279,14 +276,15 @@ export function GameDistributionPublishPanel({
|
||||
/** 线上最近提交的版本号;首次发布为 0(没有线上版本)。 */
|
||||
const [latestVersionNumber, setLatestVersionNumber] = useState(0);
|
||||
/**
|
||||
* 面板展示并提交的「项目版本」:AGC 工程内部版本序数,唯一来源是清单的 `versions[]`。
|
||||
* 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1`
|
||||
* (首次发布为 `1`),由原生回读下发。
|
||||
*
|
||||
* 只读派生值,不是可编辑 state:用户不能改,平台版本号也改不了它。每次智能体修订追加一条
|
||||
* 内部版本,这个值随之推进。
|
||||
* `null` = 还没拿到平台数据(恢复失败、或更新模式没能刷新线上状态):这时不允许提交,
|
||||
* 也不回落到 AGC 工程内部版本 `versions[]` 的条数——那是本地迭代计数,会提交重复 /
|
||||
* 回退的平台版本号。面板展示的值与提交的值都只取这一个来源。
|
||||
*/
|
||||
const projectVersion = useMemo(
|
||||
() => resolveGameCreationAppProjectVersion(manifest),
|
||||
[manifest],
|
||||
const [nextVersionNumber, setNextVersionNumber] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const metadataSuggestionRequestRef = useRef(0);
|
||||
const publicationRequestRef = useRef(0);
|
||||
@@ -332,6 +330,7 @@ export function GameDistributionPublishPanel({
|
||||
setPublicationRefreshed(false);
|
||||
setPublicationMessage('');
|
||||
setLatestVersionNumber(0);
|
||||
setNextVersionNumber(null);
|
||||
summaryTouchedRef.current = false;
|
||||
categoryTouchedRef.current = false;
|
||||
publicationDraftAppliedRef.current = false;
|
||||
@@ -371,7 +370,8 @@ export function GameDistributionPublishPanel({
|
||||
// 面板据此禁用提交而不是退化成首次发布。
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
// 非 Tauri 宿主:没有可恢复的原生状态,交给提交时的失败提示兜底。
|
||||
// 非 Tauri 宿主:没有可恢复的原生状态,也就拿不到平台版本号——按首次发布展示表单,
|
||||
// 提交按钮因为版本号未知保持禁用(发布本身也需要原生宿主)。
|
||||
setPublicationState('first-publish');
|
||||
return;
|
||||
}
|
||||
@@ -387,8 +387,15 @@ export function GameDistributionPublishPanel({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 版本号只认原生按平台作者视图推出来的值;缺字段(旧原生端 / 没刷新成功)就保持未知。
|
||||
const platformNextVersion = recovery.nextVersionNumber;
|
||||
setNextVersionNumber(
|
||||
typeof platformNextVersion === 'number' && platformNextVersion > 0
|
||||
? platformNextVersion
|
||||
: null,
|
||||
);
|
||||
if (recovery.state === 'update') {
|
||||
// 线上最近提交的版本号只用于展示平台侧事实;面板的项目版本由内部版本派生,与它无关。
|
||||
// 线上最近提交的版本号只用于展示平台侧事实,与这次要用的版本号无关。
|
||||
const latestVersionNumber = recovery.binding.latestVersionNumber ?? 0;
|
||||
setPublicationState('update');
|
||||
setPublicationBinding(recovery.binding);
|
||||
@@ -682,6 +689,11 @@ export function GameDistributionPublishPanel({
|
||||
setError('请先导出有效的试玩包');
|
||||
return;
|
||||
}
|
||||
// 版本号必须来自平台作者视图;拿不到就不发请求,也不猜本地工程迭代计数。
|
||||
if (nextVersionNumber === null) {
|
||||
setError('无法确认项目版本,请重新打开发布面板后再试');
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setError('需要在 Tauri App 内发布');
|
||||
@@ -773,7 +785,8 @@ export function GameDistributionPublishPanel({
|
||||
publicationState === 'update'
|
||||
? (publicationBinding?.gameId ?? null)
|
||||
: null,
|
||||
versionNumber: projectVersion,
|
||||
// 展示与提交同一个平台版本号(作者视图最大版本号 + 1,首次发布为 1)。
|
||||
versionNumber: nextVersionNumber,
|
||||
priceMudPoints: price.priceMudPoints,
|
||||
expectedPublicationRevision:
|
||||
publicationState === 'update'
|
||||
@@ -924,24 +937,28 @@ export function GameDistributionPublishPanel({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<div className="game-distribution-publish-panel__version-field">
|
||||
<span className="game-distribution-publish-panel__version-label">
|
||||
项目版本
|
||||
<span className="game-distribution-publish-panel__version-tag">
|
||||
{`v${projectVersion}`}
|
||||
{nextVersionNumber === null ? '—' : `v${nextVersionNumber}`}
|
||||
</span>
|
||||
</span>
|
||||
{/* 平台派发的版本号,作者不能填:只读 + 禁用观感,提交的也是同一个值。
|
||||
保留 readOnly 而不是 disabled:值仍可聚焦 / 可复制,读屏也能念出来。 */}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className="game-distribution-publish-panel__version-value"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label="项目版本"
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
value={projectVersion}
|
||||
value={
|
||||
nextVersionNumber === null ? '' : String(nextVersionNumber)
|
||||
}
|
||||
placeholder="—"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<PlatformGamePricingField
|
||||
mode={priceMode}
|
||||
priceInput={priceInput}
|
||||
@@ -1172,6 +1189,7 @@ export function GameDistributionPublishPanel({
|
||||
Boolean(uploadingLabel) ||
|
||||
publicationState === 'checking' ||
|
||||
publicationState === 'unavailable' ||
|
||||
nextVersionNumber === null ||
|
||||
!title.trim() ||
|
||||
!summary.trim() ||
|
||||
!packageResult
|
||||
|
||||
@@ -3,7 +3,6 @@ import { PLATFORM_GAME_MAX_PRICE_MUD_POINTS } from '@genarrative/shared/componen
|
||||
import {
|
||||
type GameCreationAppManifest,
|
||||
type GameCreationAppPublicationBinding,
|
||||
resolveGameCreationAppProjectVersion,
|
||||
} from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type {
|
||||
GameDistributionCategory,
|
||||
@@ -90,6 +89,9 @@ export type GameDistributionPublicationDraft = {
|
||||
*
|
||||
* `state` 三态必须分开处理:`unavailable` 表示恢复被网络/401/5xx 挡住,绝不能当成首次发布;
|
||||
* `first-publish` 只在服务端给出确定答案(无绑定且作者名下没有匹配作品)时出现。
|
||||
*
|
||||
* `nextVersionNumber` 是平台侧这次发布会用的版本号(作者视图已有版本的最大 `versionNumber + 1`;
|
||||
* 首次发布为 `1`)。取不到平台数据时缺省,调用方不得回落到本地工程迭代计数,只能禁用提交。
|
||||
*/
|
||||
export type GameDistributionPublicationReadResult =
|
||||
| {
|
||||
@@ -98,6 +100,7 @@ export type GameDistributionPublicationReadResult =
|
||||
refreshed: boolean;
|
||||
draft: null;
|
||||
visibility: null;
|
||||
nextVersionNumber?: number | null;
|
||||
message?: string | null;
|
||||
}
|
||||
| {
|
||||
@@ -106,6 +109,7 @@ export type GameDistributionPublicationReadResult =
|
||||
refreshed: boolean;
|
||||
draft: GameDistributionPublicationDraft | null;
|
||||
visibility?: string | null;
|
||||
nextVersionNumber?: number | null;
|
||||
message?: string | null;
|
||||
}
|
||||
| {
|
||||
@@ -114,6 +118,7 @@ export type GameDistributionPublicationReadResult =
|
||||
refreshed: false;
|
||||
draft: null;
|
||||
visibility: null;
|
||||
nextVersionNumber?: number | null;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
@@ -308,13 +313,14 @@ export async function publishLocalProjectGame(args: {
|
||||
*/
|
||||
gameId?: string | null;
|
||||
/**
|
||||
* 这次提交使用的项目版本标签。
|
||||
* 这次提交使用的平台版本号。
|
||||
*
|
||||
* 缺省时按 AGC 工程内部版本 `versions[]` 派生(见 `resolveGameCreationAppProjectVersion`):
|
||||
* 标签等于当前存活的内部版本条数,发布面板展示的只读值就是它,不存在第二份版本来源,也不
|
||||
* 读取平台绑定里的版本号。同一个版本标签可以反复提交,每次提交生成新的 `versionId`。
|
||||
* **必填**:只接受调用方从平台作者视图推出的值(该作品已有版本的最大 `versionNumber + 1`,
|
||||
* 首次发布为 `1`),也就是发布面板展示给作者的同一个数。这里刻意不回落到 AGC 工程内部版本
|
||||
* `versions[]` 的条数——那是本地迭代计数,与平台版本序列无关,会提交重复 / 回退的版本号。
|
||||
* 同一个版本号可以反复提交(每次提交生成新的 `versionId`)。
|
||||
*/
|
||||
versionNumber?: number | null;
|
||||
versionNumber: number;
|
||||
/** 更新送审所需的公开修订号 CAS;来自发布绑定。 */
|
||||
expectedPublicationRevision?: number | null;
|
||||
/**
|
||||
@@ -343,10 +349,10 @@ export async function publishLocalProjectGame(args: {
|
||||
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
|
||||
}
|
||||
const gameId = args.gameId?.trim() ?? '';
|
||||
// 版本标签只有一个来源:调用方显式传入的值优先(发布面板展示的派生值),否则回落到按 AGC
|
||||
// 工程内部版本派生的同一个值。服务端的自动递增路径只服务不传版本号的老客户端。
|
||||
const versionNumber =
|
||||
args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest);
|
||||
// 版本号只有一个来源:调用方按平台作者视图推出的值(发布面板展示的同一个只读值)。
|
||||
// 不回落本地工程迭代计数:那是「本地有几条迭代」而不是「平台版本序列的下一个号」,
|
||||
// 提交重复 / 回退的版本号时平台不报错,版本序列会乱。
|
||||
const versionNumber = args.versionNumber;
|
||||
if (!Number.isSafeInteger(versionNumber) || versionNumber <= 0) {
|
||||
throw new Error('项目版本必须是大于 0 的安全整数');
|
||||
}
|
||||
|
||||
@@ -3761,7 +3761,32 @@ textarea {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 项目版本标签:值就是 AGC 工程内部版本序数(资源总览「项目版本」栏目里最新那张卡的「版本 N」)。 */
|
||||
/* 项目版本字段:值是平台按作者视图派发的下一个版本号,作者不能填。
|
||||
外壳与 `.game-distribution-publish-panel__fields label` 同形(grid + 6px 间距),
|
||||
值用禁用观感(灰底 + 弱化文字 + not-allowed),与同组其它可编辑输入一眼分得开。 */
|
||||
.game-distribution-publish-panel__version-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields
|
||||
.game-distribution-publish-panel__version-value {
|
||||
background: rgb(244 238 232 / 88%);
|
||||
color: var(--platform-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields
|
||||
.game-distribution-publish-panel__version-value:focus-visible {
|
||||
border-color: var(--platform-subpanel-border);
|
||||
outline: 2px solid rgb(168 102 61 / 20%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 项目版本标签:值是**平台**这次发布会用的版本号(作者视图最大版本号 + 1)。 */
|
||||
.game-distribution-publish-panel__version-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -34,10 +34,10 @@ export type ProjectVersionResourceSummary = GameIterationVersion & {
|
||||
childVersionIds: string[];
|
||||
/**
|
||||
* 只有**最新内部版本**(`manifest.versions` 最后一条)带它,值是内部版本序数
|
||||
* (= `versions.length`,也就是发布时会提交的那个标签,与发布面板的
|
||||
* `resolveGameCreationAppProjectVersion` 同源)。
|
||||
* (= `versions.length`)。
|
||||
*
|
||||
* 卡面据此只渲染**中性**的「最新」角标:发布固定取最新内部版本,卡面不把它写成
|
||||
* 它是 AGC 工程内部的迭代序数,**不是**发布时提交的平台版本号(后者取平台作者视图里
|
||||
* 该作品的最大版本号 + 1)。卡面据此只渲染**中性**的「最新」角标,不把它写成
|
||||
* 「发布标签」,免得诱导用户以为要挑一张卡才能发。
|
||||
*/
|
||||
publicationTag?: number;
|
||||
|
||||
@@ -69,6 +69,8 @@ describe('Rust 发布 facade', () => {
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: ['asset_shot_1', 'asset_shot_2'],
|
||||
},
|
||||
// 平台版本号必填:调用方按作者视图推出的值,这里直接透传。
|
||||
versionNumber: 4,
|
||||
idempotencyKey: 'agc-publish-test',
|
||||
});
|
||||
|
||||
@@ -104,6 +106,7 @@ describe('Rust 发布 facade', () => {
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
versionNumber: 4,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
@@ -126,6 +129,7 @@ describe('Rust 发布 facade', () => {
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
versionNumber: 4,
|
||||
}),
|
||||
).rejects.toThrow('发布需要本地项目标识');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
@@ -140,6 +144,7 @@ describe('Rust 发布 facade', () => {
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: ' ' },
|
||||
versionNumber: 4,
|
||||
}),
|
||||
).rejects.toThrow('请先选择游戏封面');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
@@ -160,6 +165,7 @@ describe('Rust 发布 facade', () => {
|
||||
(_, index) => `asset_${index}`,
|
||||
),
|
||||
},
|
||||
versionNumber: 4,
|
||||
}),
|
||||
).rejects.toThrow('游戏截图最多 6 张');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
@@ -177,6 +183,7 @@ describe('Rust 发布 facade', () => {
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
versionNumber: 4,
|
||||
priceMudPoints: 240,
|
||||
});
|
||||
|
||||
@@ -192,6 +199,7 @@ describe('Rust 发布 facade', () => {
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
versionNumber: 4,
|
||||
};
|
||||
await expect(
|
||||
publishLocalProjectGame({ ...base, priceMudPoints: 1_000_001 }),
|
||||
@@ -351,21 +359,23 @@ describe('Rust 发布 facade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('未显式给出项目版本时按工程内部版本条数派生', async () => {
|
||||
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
|
||||
test('项目版本只认调用方传入的平台版本号:缺省失败关闭,本地迭代计数不参与', async () => {
|
||||
// 缺省:不回落本地工程迭代计数(那是「本地有几条迭代」,不是平台版本序列的下一个号),
|
||||
// 直接失败关闭,避免提交重复 / 回退的版本号。
|
||||
const missingInvoke = vi.fn() as unknown as TauriInvoke;
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: missingInvoke,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
} as unknown as Parameters<typeof publishLocalProjectGame>[0]),
|
||||
).rejects.toThrow('项目版本必须是大于 0 的安全整数');
|
||||
expect(missingInvoke).not.toHaveBeenCalled();
|
||||
|
||||
// 没有任何内部版本记录:首版回落 1,不把「没写」变成服务端自动递增。
|
||||
await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
});
|
||||
expect(calls[0]?.args).toMatchObject({ versionNumber: 1 });
|
||||
|
||||
// 4 条内部版本 → 标签就是最新那张卡的序数 4;平台绑定里的版本号不参与派生。
|
||||
const withVersions = {
|
||||
// 本地有 4 条内部版本、还有遗留 projectVersion 与绑定里的版本号:都不能改写传下去的值。
|
||||
const withLocalHistory = {
|
||||
...MANIFEST,
|
||||
versions: [
|
||||
{ versionId: 'initial-13', parentVersionId: null, projectRevision: 13 },
|
||||
@@ -388,20 +398,17 @@ describe('Rust 发布 facade', () => {
|
||||
projectVersion: 6,
|
||||
publication: { latestVersionNumber: 6 },
|
||||
} as unknown as GameCreationAppManifest;
|
||||
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
|
||||
await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: withVersions,
|
||||
manifest: withLocalHistory,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
gameId: 'game_9',
|
||||
expectedPublicationRevision: 7,
|
||||
});
|
||||
expect(calls[1]?.args).toMatchObject({
|
||||
gameId: 'game_9',
|
||||
versionNumber: 4,
|
||||
expectedPublicationRevision: 7,
|
||||
versionNumber: 7,
|
||||
});
|
||||
expect(calls[0]?.args).toMatchObject({ versionNumber: 7 });
|
||||
expect(calls[0]?.args).not.toMatchObject({ versionNumber: 4 });
|
||||
});
|
||||
|
||||
test('更新发布缺少公开修订号时在 native command 前失败关闭', async () => {
|
||||
|
||||
@@ -639,6 +639,8 @@ liveTest(
|
||||
packageRelativePath: 'dist/game.zip',
|
||||
manifest,
|
||||
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
|
||||
// 平台侧接下来会用的版本号由作者视图推出:这个项目刚建,平台从 1 开始派发。
|
||||
versionNumber: 1,
|
||||
});
|
||||
expect(first.status).toBe('pending_review');
|
||||
expect(first.versionNumber).toBe(1);
|
||||
@@ -676,6 +678,8 @@ liveTest(
|
||||
packageRelativePath: 'dist/game.zip',
|
||||
manifest,
|
||||
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
|
||||
// 同一作品第二次发布:作者视图已有 v1 → 这次用 2。
|
||||
versionNumber: 2,
|
||||
});
|
||||
expect(second.gameId).toBe(first.gameId);
|
||||
expect(second.versionNumber).toBe(first.versionNumber + 1);
|
||||
|
||||
@@ -129,12 +129,14 @@ beforeEach(() => {
|
||||
() => new Promise<never>(() => undefined),
|
||||
);
|
||||
// 默认按「首次发布」恢复,保持既有用例的提交路径不变;更新模式用例单独覆盖。
|
||||
// `nextVersionNumber` 是原生按平台作者视图推出来的值:首次发布没有线上作品,平台从 1 派发。
|
||||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||||
state: 'first-publish',
|
||||
binding: null,
|
||||
refreshed: true,
|
||||
draft: null,
|
||||
visibility: null,
|
||||
nextVersionNumber: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -713,8 +715,10 @@ describe('GameDistributionPublishPanel', () => {
|
||||
|
||||
test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
expect(await screen.findByText('需要在 Tauri App 内发布')).not.toBeNull();
|
||||
// 没有原生宿主就没有平台版本号:不猜本地迭代计数,提交按钮保持禁用。
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '发布游戏' }),
|
||||
).toHaveProperty('disabled', true);
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
@@ -726,7 +730,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
|
||||
});
|
||||
|
||||
test('已有绑定时进入更新模式,回填线上资料并按派生项目版本提交原 gameId', async () => {
|
||||
test('已有绑定时进入更新模式,回填线上资料并提交平台下一个版本号', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||||
state: 'update',
|
||||
@@ -740,6 +744,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
status: 'pending_review',
|
||||
},
|
||||
refreshed: true,
|
||||
nextVersionNumber: 3,
|
||||
draft: {
|
||||
title: '星轨防线(线上)',
|
||||
summary: '线上简介',
|
||||
@@ -773,8 +778,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
});
|
||||
const { onPublished } = renderPanel();
|
||||
|
||||
// 回填线上冻结资料;项目版本按工程内部版本派生(本 fixture 没有 versions → 首版 1),
|
||||
// 平台绑定的线上版本号只用于展示,不参与派生。
|
||||
// 回填线上冻结资料;项目版本取平台作者视图的下一个版本号(已有 v2 → 这次用 3)。
|
||||
await screen.findByRole('button', { name: '更新游戏' });
|
||||
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
|
||||
'value',
|
||||
@@ -784,10 +788,11 @@ describe('GameDistributionPublishPanel', () => {
|
||||
'value',
|
||||
'线上简介',
|
||||
);
|
||||
const versionInput = screen.getByLabelText('项目版本');
|
||||
expect(versionInput).toHaveProperty('value', '1');
|
||||
expect(versionInput).toHaveProperty('readOnly', true);
|
||||
expect(screen.getByText('v1')).not.toBeNull();
|
||||
const versionInput = screen.getByLabelText('项目版本') as HTMLInputElement;
|
||||
expect(versionInput.value).toBe('3');
|
||||
expect(versionInput.readOnly).toBe(true);
|
||||
expect(versionInput.getAttribute('aria-readonly')).toBe('true');
|
||||
expect(screen.getByText('v3')).not.toBeNull();
|
||||
expect(screen.getByText(/线上最近提交 v2 · 审核中/u)).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
|
||||
@@ -796,8 +801,8 @@ describe('GameDistributionPublishPanel', () => {
|
||||
);
|
||||
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
|
||||
expect(args?.gameId).toBe('game_9');
|
||||
// 提交的就是派生出来的内部版本序数,与线上最近提交的 v2 无关。
|
||||
expect(args?.versionNumber).toBe(1);
|
||||
// 展示给作者的值与提交的值是同一个:平台已有 v2 → 提交 3,与本地迭代计数无关。
|
||||
expect(args?.versionNumber).toBe(3);
|
||||
expect(args?.expectedPublicationRevision).toBe(7);
|
||||
// 该草稿是历史响应,没有回读价格:按免费预填,不填就提交 0。
|
||||
expect(args?.priceMudPoints).toBe(0);
|
||||
@@ -830,6 +835,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
status: 'published',
|
||||
},
|
||||
refreshed: true,
|
||||
nextVersionNumber: 3,
|
||||
draft: {
|
||||
title: '星轨防线(线上)',
|
||||
summary: '线上简介',
|
||||
@@ -870,7 +876,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
).toBe(240);
|
||||
});
|
||||
|
||||
test('项目版本由工程内部版本条数派生,线上版本号与遗留字段都不参与', async () => {
|
||||
test('项目版本取平台作者视图的下一个版本号:本地迭代计数与遗留字段都不参与', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||||
state: 'update',
|
||||
@@ -879,17 +885,19 @@ describe('GameDistributionPublishPanel', () => {
|
||||
apiBaseUrl: 'https://dev.test',
|
||||
gameId: 'game_9',
|
||||
revision: 7,
|
||||
latestVersionId: 'gamever_5',
|
||||
latestVersionNumber: 5,
|
||||
latestVersionId: 'gamever_3',
|
||||
latestVersionNumber: 3,
|
||||
status: 'published',
|
||||
},
|
||||
refreshed: true,
|
||||
draft: null,
|
||||
visibility: 'published',
|
||||
// 原生按作者视图算出「已有版本号 1..3 → 这次用 4」。
|
||||
nextVersionNumber: 4,
|
||||
});
|
||||
vi.mocked(publishLocalProjectGame).mockResolvedValue({
|
||||
gameId: 'game_9',
|
||||
versionId: 'gamever_6',
|
||||
versionId: 'gamever_4',
|
||||
versionNumber: 4,
|
||||
status: 'pending_review',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
@@ -899,48 +907,96 @@ describe('GameDistributionPublishPanel', () => {
|
||||
renderPanel({
|
||||
manifest: {
|
||||
...MANIFEST,
|
||||
// 4 条内部版本 → 标签 4;遗留 projectVersion 6 与线上 v5 都不得影响它。
|
||||
// 本地只有 1 条内部版本、遗留 projectVersion 6:按旧口径会提交 1(或 2),
|
||||
// 这次必须采用平台推出的 4。
|
||||
versions: [
|
||||
{
|
||||
versionId: 'initial-13',
|
||||
parentVersionId: null,
|
||||
projectRevision: 13,
|
||||
},
|
||||
{
|
||||
versionId: 'agent-22',
|
||||
parentVersionId: 'initial-13',
|
||||
projectRevision: 22,
|
||||
},
|
||||
{
|
||||
versionId: 'agent-42',
|
||||
parentVersionId: 'agent-22',
|
||||
projectRevision: 42,
|
||||
},
|
||||
{
|
||||
versionId: 'agent-46',
|
||||
parentVersionId: 'agent-42',
|
||||
projectRevision: 46,
|
||||
},
|
||||
],
|
||||
projectVersion: 6,
|
||||
} as unknown as GameCreationAppManifest,
|
||||
});
|
||||
await screen.findByRole('button', { name: '更新游戏' });
|
||||
|
||||
const input = screen.getByLabelText('项目版本');
|
||||
expect(input).toHaveProperty('value', '4');
|
||||
expect(input).toHaveProperty('readOnly', true);
|
||||
const input = screen.getByLabelText('项目版本') as HTMLInputElement;
|
||||
expect(input.value).toBe('4');
|
||||
expect(input.readOnly).toBe(true);
|
||||
expect(screen.getByText('v4')).not.toBeNull();
|
||||
expect(screen.getByText(/线上最近提交 v5 · 已公开/u)).not.toBeNull();
|
||||
expect(screen.getByText(/线上最近提交 v3 · 已公开/u)).not.toBeNull();
|
||||
await selectCover();
|
||||
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const submittedVersion = vi.mocked(publishLocalProjectGame).mock
|
||||
.calls[0]?.[0].versionNumber;
|
||||
// 显示值 == 提交值 == 平台下一个版本号。
|
||||
expect(submittedVersion).toBe(4);
|
||||
// 本地迭代计数(1 条)与「本地计数 + 1」都不得成为提交值。
|
||||
expect(submittedVersion).not.toBe(1);
|
||||
expect(submittedVersion).not.toBe(2);
|
||||
});
|
||||
|
||||
test('平台版本号不可得时显示占位、提交禁用,且不回落本地迭代计数', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||||
state: 'update',
|
||||
binding: {
|
||||
accountId: 'user-1',
|
||||
apiBaseUrl: 'https://dev.test',
|
||||
gameId: 'game_9',
|
||||
revision: 7,
|
||||
latestVersionId: 'gamever_2',
|
||||
latestVersionNumber: 2,
|
||||
status: 'pending_review',
|
||||
},
|
||||
// 有本地绑定但没能刷新线上状态:这一路没有平台版本数据。
|
||||
refreshed: false,
|
||||
draft: null,
|
||||
visibility: null,
|
||||
message: '无法刷新线上状态:无法连接登录服务',
|
||||
});
|
||||
renderPanel({
|
||||
manifest: {
|
||||
...MANIFEST,
|
||||
// 本地 3 条内部版本:不得被当成版本号提交。
|
||||
versions: [
|
||||
{ versionId: 'a', parentVersionId: null, projectRevision: 1 },
|
||||
{ versionId: 'b', parentVersionId: 'a', projectRevision: 2 },
|
||||
{ versionId: 'c', parentVersionId: 'b', projectRevision: 3 },
|
||||
],
|
||||
} as unknown as GameCreationAppManifest,
|
||||
});
|
||||
|
||||
const versionInput = (await screen.findByLabelText(
|
||||
'项目版本',
|
||||
)) as HTMLInputElement;
|
||||
expect(versionInput.value).toBe('');
|
||||
expect(versionInput.placeholder).toBe('—');
|
||||
expect(
|
||||
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].versionNumber,
|
||||
).toBe(4);
|
||||
screen.getByText('—', {
|
||||
selector: '.game-distribution-publish-panel__version-tag',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText('无法刷新线上状态:无法连接登录服务'),
|
||||
).not.toBeNull();
|
||||
|
||||
// 其余必填项补齐后,提交仍因版本号未知保持禁用。
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '星轨防线' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('一句话简介'), {
|
||||
target: { value: '守住轨道城' },
|
||||
});
|
||||
const submit = screen.getByRole('button', { name: '更新游戏' });
|
||||
expect(submit).toHaveProperty('disabled', true);
|
||||
fireEvent.click(submit);
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('恢复失败(网络/5xx)时禁用提交,不按首次发布继续', async () => {
|
||||
|
||||
@@ -109,17 +109,31 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 错误态。网页宿主对子面板内的输入框做了统一 remap,且带 `!important`
|
||||
(`src/index.css` 的 `.platform-theme :where(.platform-subpanel …) :where(input…)` 规则,
|
||||
用来把弹窗/子面板里的输入框remap成平台输入框底色)。错误态是必须压过它的例外,所以同权重声明。
|
||||
不改 `box-shadow`:那是宿主的输入高光与焦点环,压掉会让非法字段失去键盘焦点提示。 */
|
||||
/* 错误态。网页宿主对子面板内的输入框做了统一 remap(`src/index.css` 的
|
||||
`.platform-theme :where(.platform-subpanel …) :where(input…)`,带 `!important`),
|
||||
非法态在那边由宿主同族的 `[aria-invalid='true']` 分支给出;这里保留**正常权重**的同色声明,
|
||||
AGC 侧没有那套 remap 时就靠这条生效。不改 `box-shadow`:那是宿主的输入高光与焦点环,
|
||||
压掉会让非法字段失去键盘焦点提示。 */
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__price-input[aria-invalid='true'] {
|
||||
border-color: var(--platform-button-danger-text, #a6402f) !important;
|
||||
background: var(
|
||||
--platform-button-danger-fill,
|
||||
rgba(255, 237, 229, 0.94)
|
||||
) !important;
|
||||
border-color: var(--platform-button-danger-text, #a6402f);
|
||||
background: var(--platform-button-danger-fill, rgba(255, 237, 229, 0.94));
|
||||
}
|
||||
|
||||
/* 原生 number 的上下箭头会画在「泥点」单位左边(Chrome 一直画,Firefox hover 才画),
|
||||
与单位挤在一起;这里隐藏箭头,保留 `type="number"` + `inputMode="numeric"`(移动端仍是数字键盘)。 */
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__price-input::-webkit-outer-spin-button,
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__price-input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__price-input {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__price-unit {
|
||||
|
||||
@@ -68,6 +68,7 @@ test('切到买断制后显示价格输入、上限提示,并回传输入值',
|
||||
test('禁用态锁住单选与买断价输入', () => {
|
||||
// 免费模式下价格输入根本不渲染,只断言两个 radio 等于没测到输入框;
|
||||
// 必须用「买断制 + 禁用」才能覆盖价格输入被锁住的路径。
|
||||
// 外壳已由 fieldset 换成 div,禁用不再级联:这里逐个断言三个控件都被锁住。
|
||||
render(
|
||||
<PricingHarness disabled initialMode="paid" initialPriceInput="120" />,
|
||||
);
|
||||
@@ -90,7 +91,7 @@ test('使用方传入的校验错误在字段内以 alert 呈现', () => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('买断价必须是整数泥点');
|
||||
});
|
||||
|
||||
test('买断价校验错误通过 aria-describedby 关联到价格输入', () => {
|
||||
test('买断价校验错误与上限提示一起通过 aria-describedby 关联到价格输入', () => {
|
||||
render(
|
||||
<PricingHarness
|
||||
initialMode="paid"
|
||||
@@ -103,10 +104,33 @@ test('买断价校验错误通过 aria-describedby 关联到价格输入', () =>
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert.textContent).toBe('买断价必须是整数泥点');
|
||||
expect(alert.id).toBeTruthy();
|
||||
expect(priceInput.getAttribute('aria-describedby')).toBe(alert.id);
|
||||
// 描述里必须同时有错误行与上限提示:只挂错误行会让「最多 1000000 泥点」读不到。
|
||||
const describedByIds = (priceInput.getAttribute('aria-describedby') ?? '')
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean);
|
||||
expect(describedByIds).toContain(alert.id);
|
||||
const describedText = describedByIds
|
||||
.map((id) => document.getElementById(id)?.textContent ?? '')
|
||||
.join(' ');
|
||||
expect(describedText).toContain('买断价必须是整数泥点');
|
||||
expect(describedText).toContain('1000000');
|
||||
expect(priceInput.getAttribute('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
test('没有校验错误时价格输入仍通过 aria-describedby 播报上限提示', () => {
|
||||
render(<PricingHarness initialMode="paid" initialPriceInput="240" />);
|
||||
|
||||
const priceInput = screen.getByLabelText(/买断价/u);
|
||||
const describedByIds = (priceInput.getAttribute('aria-describedby') ?? '')
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean);
|
||||
expect(describedByIds).toHaveLength(1);
|
||||
expect(
|
||||
document.getElementById(describedByIds[0] ?? '')?.textContent,
|
||||
).toContain('1000000');
|
||||
expect(priceInput.getAttribute('aria-invalid')).toBeNull();
|
||||
});
|
||||
|
||||
test('价格解析与提交口径一致:免费为 0,买断制要求 1..=1000000 整数', () => {
|
||||
expect(resolvePlatformGamePriceMudPoints('free', 'abc')).toEqual({
|
||||
priceMudPoints: 0,
|
||||
|
||||
@@ -3,6 +3,7 @@ import './PlatformGamePricingField.css';
|
||||
import { useId } from 'react';
|
||||
|
||||
import { getPlatformCategoryChipClassName } from './platformCategoryChipModel';
|
||||
import { PlatformFieldLabel } from './PlatformFieldLabel';
|
||||
import {
|
||||
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
|
||||
type PlatformGamePricingMode,
|
||||
@@ -47,6 +48,11 @@ export function PlatformGamePricingField({
|
||||
// 错误行与价格输入必须显式关联:读屏用户只听到「买断价(泥点)」时不知道哪里非法。
|
||||
const errorId = `${useId()}-price-error`;
|
||||
const priceInputId = `${useId()}-price-input`;
|
||||
// 提示句同样要挂在输入上:它是「最多 1000000 泥点」这类上限信息,光靠 label 念不到。
|
||||
const hintId = `${useId()}-price-hint`;
|
||||
const describedBy = [hintId, error ? errorId : null]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
@@ -54,7 +60,7 @@ export function PlatformGamePricingField({
|
||||
radius="xs"
|
||||
padding="xs"
|
||||
surface="platform"
|
||||
title="付费方式"
|
||||
title={<PlatformFieldLabel variant="field">付费方式</PlatformFieldLabel>}
|
||||
titleVariant="strong"
|
||||
>
|
||||
<div
|
||||
@@ -118,7 +124,7 @@ export function PlatformGamePricingField({
|
||||
value={priceInput}
|
||||
disabled={disabled}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
aria-describedby={describedBy}
|
||||
onChange={(event) => onPriceInputChange(event.target.value)}
|
||||
/>
|
||||
{/* 单位是价格的视觉附属:label 里已经写了「(泥点)」,这里对读屏重复一次没有意义。 */}
|
||||
@@ -129,7 +135,7 @@ export function PlatformGamePricingField({
|
||||
泥点
|
||||
</span>
|
||||
</div>
|
||||
<span className="platform-game-pricing-field__hint">
|
||||
<span id={hintId} className="platform-game-pricing-field__hint">
|
||||
买断一次永久拥有;价格随本次版本审核通过后生效,最多{' '}
|
||||
{PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 泥点
|
||||
</span>
|
||||
|
||||
@@ -13750,6 +13750,28 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
/* 非法态与正常态同一条 remap 家族:子面板里的输入框带 `aria-invalid="true"` 时用错误色,
|
||||
否则组件自己的错误态会被上面的 `!important` 正常态盖掉(共享输入组件的错误态也走这一条)。
|
||||
必须排在 `:focus` 之后:两者特异性相同,靠顺序保证非法字段聚焦时也是错误色(焦点环照旧)。 */
|
||||
.platform-theme
|
||||
:where(
|
||||
.platform-modal-shell,
|
||||
.platform-auth-card,
|
||||
.platform-subpanel,
|
||||
.platform-remap-surface,
|
||||
.platform-role-studio
|
||||
)
|
||||
:where(
|
||||
input:not([type='file']):not([type='range']):not([type='checkbox']):not(
|
||||
[type='radio']
|
||||
),
|
||||
textarea,
|
||||
select
|
||||
)[aria-invalid='true'] {
|
||||
border-color: var(--platform-button-danger-text) !important;
|
||||
background: var(--platform-button-danger-fill) !important;
|
||||
}
|
||||
|
||||
.platform-banner {
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
|
||||
Reference in New Issue
Block a user