完善游戏审核试玩与客户端版本更新

后台审核详情复用公开游戏详情样式,补齐发布者、冻结资料、素材换签和待审 ZIP 试玩。

AGC 保存已发布作品绑定,支持同一 gameId 更新版本并由用户填写目标版本号。

保留 SpacetimeDB 表结构不变,增加版本号请求校验、幂等边界和本地清单字段。
This commit is contained in:
2026-10-03 12:35:57 +08:00
parent 783ca014ef
commit 44f02ee19b
42 changed files with 3721 additions and 488 deletions
+28
View File
@@ -34,11 +34,13 @@ import type {
AdminFeatureGateConfigResponse,
AdminGameDistributionGameListResponse,
AdminGameDistributionGameStatus,
AdminGameDistributionPreviewSessionResponse,
AdminGameDistributionRestoreRequest,
AdminGameDistributionRestoreResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminGameDistributionVersionDetailResponse,
AdminGameReviewDetailResponse,
AdminGameReviewGamesQuery,
AdminGameReviewGamesResponse,
@@ -1251,6 +1253,32 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
);
}
export function getAdminGameDistributionVersion(
token: string,
versionId: string,
) {
return request<AdminGameDistributionVersionDetailResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}`,
{ token },
);
}
export function createAdminGameDistributionPreviewSession(
token: string,
versionId: string,
) {
return request<AdminGameDistributionPreviewSessionResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}/preview-session`,
{ token, method: 'POST' },
).then((response) => ({
...response,
previewUrl: new URL(
response.previewUrl,
ADMIN_API_BASE_URL || window.location.origin,
).toString(),
}));
}
/**
* 后台作品管理列表。
*
+46
View File
@@ -1110,6 +1110,52 @@ export interface AdminGameDistributionReviewEntry {
updatedAt: string;
}
export interface AdminGameDistributionGameDetail {
id: string;
title: string;
summary: string;
description: string;
category: string;
tags: string[];
coverObjectKey: string | null;
screenshots: string[];
author: {
id: string;
name: string;
avatarUrl: string | null;
};
deviceSupport: {
desktop: boolean;
mobile: boolean;
touch: boolean;
};
inputModes: string[];
orientation: string;
status: string;
publicationRevision: number;
playCount: number;
createdAt: string;
}
export interface AdminGameDistributionVersionDetail
extends AdminGameDistributionReviewEntry {
packageFileCount: number;
entryUrl: string | null;
recoveryAction: string;
frozenMetadata: Record<string, unknown> | null;
}
export interface AdminGameDistributionVersionDetailResponse {
game: AdminGameDistributionGameDetail;
version: AdminGameDistributionVersionDetail;
}
export interface AdminGameDistributionPreviewSessionResponse {
previewUrl: string;
expiresAt: string;
versionId: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
@@ -10,11 +10,16 @@ import {
import { beforeEach, expect, test, vi } from 'vitest';
import {
createAdminGameDistributionPreviewSession,
getAdminGameDistributionVersion,
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import type {
AdminGameDistributionReviewEntry,
AdminGameDistributionVersionDetailResponse,
} from '../api/adminApiTypes';
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
@@ -28,11 +33,13 @@ vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
createAdminGameDistributionPreviewSession: vi.fn(),
getAdminGameDistributionVersion: vi.fn(),
listAdminGameDistributionReviews: vi.fn(),
reviewAdminGameDistributionVersion: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
}));
const entry: AdminGameDistributionReviewEntry = {
versionId: 'version-1',
gameId: 'game_1',
@@ -46,10 +53,40 @@ const entry: AdminGameDistributionReviewEntry = {
updatedAt: '2026-09-20T08:00:00Z',
};
const detailResponse: AdminGameDistributionVersionDetailResponse = {
game: {
id: 'game_1',
title: '测试游戏',
summary: '测试简介',
description: '测试详细介绍',
category: '益智',
tags: ['测试'],
coverObjectKey: null,
screenshots: [],
author: { id: 'user-1', name: '测试作者', avatarUrl: null },
deviceSupport: { desktop: true, mobile: false, touch: true },
inputModes: ['keyboard'],
orientation: 'landscape',
status: 'unpublished',
publicationRevision: 4,
playCount: 0,
createdAt: '2026-09-20T08:00:00Z',
},
version: {
...entry,
packageFileCount: 3,
entryUrl: null,
recoveryAction: 'wait',
frozenMetadata: { title: '测试游戏' },
},
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionReviews).mockReset();
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
vi.mocked(suspendAdminGameDistributionGame).mockReset();
vi.mocked(getAdminGameDistributionVersion).mockReset();
vi.mocked(createAdminGameDistributionPreviewSession).mockReset();
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry],
nextCursor: null,
@@ -230,3 +267,30 @@ test('取消安全下架确认时不下架', async () => {
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
test('详情展示发布者和冻结资料,并试玩当前待审版本', async () => {
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({
previewUrl: 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
expiresAt: '2026-10-02T12:10:00Z',
versionId: 'version-1',
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
expect(screen.getByText('测试详细介绍')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
expect(frame.getAttribute('src')).toBe(
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
);
expect(frame.getAttribute('sandbox')).toBe('allow-scripts');
});
@@ -5,6 +5,7 @@ import {
AdminListPanel,
AdminPage,
AdminPageHeading,
GameDetailDisplay,
formatAdminDateTime,
Modal,
TextField,
@@ -13,11 +14,18 @@ import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
createAdminGameDistributionPreviewSession,
getAdminAssetReadUrl,
getAdminGameDistributionVersion,
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import type {
AdminGameDistributionGameDetail,
AdminGameDistributionReviewEntry,
AdminGameDistributionVersionDetailResponse,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
@@ -31,6 +39,48 @@ interface ReviewReasonPrompt {
entry: AdminGameDistributionReviewEntry;
}
function applyFrozenGameMetadata(
game: AdminGameDistributionGameDetail,
metadata: Record<string, unknown> | null,
): AdminGameDistributionGameDetail {
if (!metadata) return game;
const screenshots = Array.isArray(metadata.screenshots)
? metadata.screenshots
.map((item) =>
item && typeof item === 'object' && 'objectKey' in item
? item.objectKey
: null,
)
.filter((value): value is string => typeof value === 'string' && Boolean(value.trim()))
: game.screenshots;
const deviceSupport =
metadata.deviceSupport && typeof metadata.deviceSupport === 'object'
? { ...game.deviceSupport, ...metadata.deviceSupport }
: game.deviceSupport;
return {
...game,
title: typeof metadata.title === 'string' ? metadata.title : game.title,
summary: typeof metadata.summary === 'string' ? metadata.summary : game.summary,
description:
typeof metadata.description === 'string' ? metadata.description : game.description,
category: typeof metadata.category === 'string' ? metadata.category : game.category,
tags: Array.isArray(metadata.tags)
? metadata.tags.filter((value): value is string => typeof value === 'string')
: game.tags,
coverObjectKey:
typeof metadata.coverObjectKey === 'string'
? metadata.coverObjectKey
: game.coverObjectKey,
screenshots,
deviceSupport,
inputModes: Array.isArray(metadata.inputModes)
? metadata.inputModes.filter((value): value is string => typeof value === 'string')
: game.inputModes,
orientation:
typeof metadata.orientation === 'string' ? metadata.orientation : game.orientation,
};
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
@@ -75,6 +125,16 @@ export function AdminGameDistributionReviewPage({
const [reasonError, setReasonError] = useState('');
const [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
const [selectedEntry, setSelectedEntry] =
useState<AdminGameDistributionReviewEntry | null>(null);
const [detail, setDetail] =
useState<AdminGameDistributionVersionDetailResponse | null>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [previewError, setPreviewError] = useState('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [assetUrls, setAssetUrls] = useState<Record<string, string>>({});
const loadReviews = useCallback(async () => {
setIsLoading(true);
@@ -93,6 +153,77 @@ export function AdminGameDistributionReviewPage({
void loadReviews();
}, [loadReviews]);
async function openDetails(entry: AdminGameDistributionReviewEntry) {
setSelectedEntry(entry);
setDetail(null);
setDetailError('');
setPreviewUrl('');
setPreviewError('');
setAssetUrls({});
setIsDetailLoading(true);
try {
const response = await getAdminGameDistributionVersion(
token,
entry.versionId,
);
const reviewGame = applyFrozenGameMetadata(
response.game,
response.version.frozenMetadata,
);
const reviewDetail = { ...response, game: reviewGame };
setDetail(reviewDetail);
const objectKeys = [reviewGame.coverObjectKey, ...reviewGame.screenshots].filter(
(value): value is string => Boolean(value?.trim()),
);
const resolvedUrls = await Promise.all(
objectKeys.map(async (objectKey) => {
try {
const asset = await getAdminAssetReadUrl(token, {
objectKey,
expireSeconds: 300,
});
return [
objectKey,
asset.read?.signedUrl ?? asset.signedUrl ?? '',
] as const;
} catch {
return [objectKey, ''] as const;
}
}),
);
setAssetUrls(Object.fromEntries(resolvedUrls));
} catch (error) {
handlePageError(error, onUnauthorized, setDetailError);
} finally {
setIsDetailLoading(false);
}
}
function closeDetails() {
setSelectedEntry(null);
setDetail(null);
setPreviewUrl('');
setPreviewError('');
setAssetUrls({});
}
async function startPreview() {
if (!selectedEntry) return;
setIsPreviewLoading(true);
setPreviewError('');
try {
const response = await createAdminGameDistributionPreviewSession(
token,
selectedEntry.versionId,
);
setPreviewUrl(response.previewUrl);
} catch (error) {
handlePageError(error, onUnauthorized, setPreviewError);
} finally {
setIsPreviewLoading(false);
}
}
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
@@ -204,6 +335,30 @@ export function AdminGameDistributionReviewPage({
void suspendGame(entry, reason);
}
if (selectedEntry) {
return (
<AdminGameDistributionReviewDetailView
detail={detail}
detailError={detailError}
isDetailLoading={isDetailLoading}
isPreviewLoading={isPreviewLoading}
previewError={previewError}
previewUrl={previewUrl}
assetUrls={assetUrls}
onBack={closeDetails}
onPreview={() => void startPreview()}
onReject={() => {
closeDetails();
openReasonPrompt(selectedEntry, 'reject');
}}
onApprove={() => {
closeDetails();
void submitReview(selectedEntry, 'approve');
}}
/>
);
}
return (
<AdminPage wide>
<AdminPageHeading
@@ -269,7 +424,15 @@ export function AdminGameDistributionReviewPage({
</td>
<td>{formatAdminDateTime(entry.createdAt)}</td>
<td>
<AdminActionRow>
<AdminActionRow className="admin-review-actions">
<AdminButton
variant="secondary"
type="button"
disabled={busy}
onClick={() => void openDetails(entry)}
>
详情
</AdminButton>
<AdminButton
variant="primary"
type="button"
@@ -311,7 +474,7 @@ export function AdminGameDistributionReviewPage({
className="genarrative-ui"
footer={
<div
className="admin-confirm-actions"
className="admin-confirm-actions admin-review-actions"
style={{
width: '100%',
}}
@@ -353,3 +516,118 @@ export function AdminGameDistributionReviewPage({
</AdminPage>
);
}
function AdminGameDistributionReviewDetailView({
detail,
detailError,
isDetailLoading,
isPreviewLoading,
previewError,
previewUrl,
assetUrls,
onBack,
onPreview,
onReject,
onApprove,
}: {
detail: AdminGameDistributionVersionDetailResponse | null;
detailError: string;
isDetailLoading: boolean;
isPreviewLoading: boolean;
previewError: string;
previewUrl: string;
assetUrls: Record<string, string>;
onBack: () => void;
onPreview: () => void;
onReject: () => void;
onApprove: () => void;
}) {
if (!detail) {
return (
<AdminPage wide>
<AdminPageHeading title="审核详情" />
{isDetailLoading ? <AdminAlert>正在加载审核详情…</AdminAlert> : null}
{detailError ? <AdminAlert tone="warning">{detailError}</AdminAlert> : null}
<AdminButton variant="secondary" type="button" onClick={onBack}>
返回审核列表
</AdminButton>
</AdminPage>
);
}
return (
<AdminPage wide>
<AdminPageHeading
title={`${detail.game.title} · v${detail.version.versionNumber}`}
actions={
<AdminButton variant="secondary" type="button" onClick={onBack}>
返回审核列表
</AdminButton>
}
/>
{detailError ? <AdminAlert tone="warning">{detailError}</AdminAlert> : null}
<GameDetailDisplay
className="platform-theme platform-theme--light admin-game-review-detail"
game={detail.game}
coverObjectKey={detail.game.coverObjectKey}
screenshotObjectKeys={detail.game.screenshots}
assetUrls={assetUrls}
controlsLabel={detail.game.inputModes.join('、') || '未声明'}
versionLabel={`v${detail.version.versionNumber}`}
infoCards={[
{ label: '状态', value: detail.version.status },
{ label: '提交时间', value: formatAdminDateTime(detail.version.createdAt) },
{ label: '包大小', value: formatBytes(detail.version.packageBytes) },
{ label: '文件数', value: detail.version.packageFileCount },
{ label: 'SHA-256', value: <code>{detail.version.packageSha256}</code> },
]}
actions={
<AdminActionRow className="admin-review-actions">
<AdminButton
variant="primary"
type="button"
disabled={isPreviewLoading}
onClick={onPreview}
>
{isPreviewLoading ? '准备试玩…' : '试玩当前待审版本'}
</AdminButton>
<AdminButton variant="ghost" type="button" onClick={onReject}>
拒绝
</AdminButton>
<AdminButton variant="primary" type="button" onClick={onApprove}>
通过
</AdminButton>
</AdminActionRow>
}
>
<section>
<h2>版本审核</h2>
<p>
发布者:{detail.game.author.name} · <code>{detail.game.author.id}</code>
</p>
{detail.version.reviewReason ? (
<AdminAlert tone="warning">审核理由:{detail.version.reviewReason}</AdminAlert>
) : null}
{detail.version.frozenMetadata ? (
<details>
<summary>查看版本冻结资料</summary>
<pre className="admin-json-preview">
{JSON.stringify(detail.version.frozenMetadata, null, 2)}
</pre>
</details>
) : null}
{previewError ? <AdminAlert tone="warning">{previewError}</AdminAlert> : null}
{previewUrl ? (
<iframe
title={`${detail.game.title} 待审版本试玩`}
src={previewUrl}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
className="admin-game-review-preview"
/>
) : null}
</section>
</GameDetailDisplay>
</AdminPage>
);
}
+19
View File
@@ -3507,3 +3507,22 @@ button:disabled {
grid-template-columns: 1fr;
}
}
.admin-game-review-detail {
max-width: 70rem;
margin: 0 auto;
padding: clamp(1rem, 2.4vw, 2.75rem);
border-radius: 1.75rem;
background: var(--platform-body-fill);
color: var(--platform-text-strong);
}
.admin-game-review-preview {
display: block;
width: 100%;
min-height: 42rem;
margin-top: 1rem;
border: 1px solid var(--platform-surface-border);
border-radius: 1rem;
background: #fff;
}
+5
View File
@@ -46,6 +46,11 @@ export default defineConfig(({ command, mode }) => {
changeOrigin: true,
secure: false,
},
'/api/game-distribution/admin-previews': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
'/healthz': {
target: apiTarget,
changeOrigin: true,
@@ -134,6 +134,7 @@ const allowedUncalledTauriCommands = [
'prepare_local_project_game_package',
'read_game_cover_generation_price',
'suggest_game_distribution_publish_metadata',
'read_game_distribution_publication',
'upload_local_project_game_package',
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
// 里以字面量出现,静态扫描仍按共享注册表核验。
@@ -610,6 +610,7 @@ pub(super) fn run() {
suggest_game_distribution_publish_metadata,
read_game_cover_generation_price,
generate_game_distribution_cover,
read_game_distribution_publication,
publish_local_project_game,
read_game_creator_app_config,
clear_game_creator_acl_elevation_denials,
File diff suppressed because it is too large Load Diff
@@ -33,11 +33,35 @@ struct GamePublishAttemptRecord {
local_project_id: String,
/// 归一化发行包摘要;换包即换尝试。
package_sha256: String,
/// 目标游戏身份:更新已有作品时非空;首次发布为空。
#[serde(default)]
target_game_id: Option<String>,
/// 用户选择的目标版本号:更新发布必须记录,换了目标版本就是一次新尝试。
#[serde(default)]
target_version_number: Option<u64>,
/// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。
#[serde(default)]
metadata_digest: String,
/// 本次尝试的发布根幂等键。
root_key: String,
updated_at: u64,
}
/// 一次发布的意图:账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」复用。
///
/// 只要其中任何一项变化,就必须换根幂等键:否则「同一份 ZIP、但用户把目标版本从 3 改成 4」
/// 会命中上一次尝试的幂等收据,用户改的版本号静默失效。
#[derive(Clone, Copy, Debug)]
pub(crate) struct GamePublishAttemptIntent<'a> {
pub(crate) account_id: &'a str,
pub(crate) origin: &'a str,
pub(crate) local_project_id: &'a str,
pub(crate) package_sha256: &'a str,
pub(crate) target_game_id: Option<&'a str>,
pub(crate) target_version_number: Option<u64>,
pub(crate) metadata_digest: &'a str,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GamePublishAttemptFile {
@@ -88,11 +112,28 @@ fn normalize_record(record: GamePublishAttemptRecord) -> Option<GamePublishAttem
{
return None;
}
let target_game_id = record
.target_game_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if let Some(game_id) = target_game_id.as_deref() {
if !is_valid_attempt_identity(game_id) {
return None;
}
}
let metadata_digest = record.metadata_digest.trim().to_ascii_lowercase();
// 空摘要只属于升级前的旧记录;新记录必须带合法摘要,否则重发意图无法与历史尝试区分。
if !metadata_digest.is_empty() && !is_valid_package_sha256(&metadata_digest) {
return None;
}
Some(GamePublishAttemptRecord {
account_id: record.account_id.trim().to_string(),
origin: record.origin.trim().to_string(),
local_project_id: record.local_project_id.trim().to_string(),
package_sha256: record.package_sha256.trim().to_ascii_lowercase(),
target_game_id,
target_version_number: record.target_version_number,
metadata_digest,
root_key: record.root_key.trim().to_string(),
updated_at: record.updated_at,
})
@@ -184,20 +225,21 @@ fn attempts_capped(mut attempts: Vec<GamePublishAttemptRecord>) -> Vec<GamePubli
/// 解析本次发布要用的根幂等键。
///
/// 命中「同账号 + 同 origin + 同本地项目 + 同包摘要」时复用既有键(响应丢失、进程重启、
/// 分片续传都落在同一次服务端尝试上);否则铸一个新键并落盘。返回的第二个值表示是否复用。
/// 命中「同账号 + 同 origin + 同本地项目 + 同包摘要 + 同目标游戏/版本 + 同资料摘要」时复用
/// 既有键(响应丢失、进程重启、分片续传都落在同一次服务端尝试上);否则铸一个新键并落盘,
/// 并用新尝试替换同一项目下未收口的旧记录。返回的第二个值表示是否复用。
pub(crate) fn resolve_game_publish_root_key(
app_data_dir: &Path,
account_id: &str,
origin: &str,
local_project_id: &str,
package_sha256: &str,
intent: GamePublishAttemptIntent<'_>,
) -> Result<(String, bool), String> {
let candidate = normalize_record(GamePublishAttemptRecord {
account_id: account_id.to_string(),
origin: origin.to_string(),
local_project_id: local_project_id.to_string(),
package_sha256: package_sha256.to_string(),
account_id: intent.account_id.to_string(),
origin: intent.origin.to_string(),
local_project_id: intent.local_project_id.to_string(),
package_sha256: intent.package_sha256.to_string(),
target_game_id: intent.target_game_id.map(str::to_string),
target_version_number: intent.target_version_number,
metadata_digest: intent.metadata_digest.to_string(),
root_key: new_game_publish_root_key(),
updated_at: unix_timestamp(),
})
@@ -207,15 +249,14 @@ pub(crate) fn resolve_game_publish_root_key(
.lock()
.map_err(|_| "发布尝试账本锁不可用".to_string())?;
let mut attempts = read_attempts_at(&path);
if let Some(existing) = attempts.iter().find(|record| {
record.account_id == candidate.account_id
&& record.origin == candidate.origin
&& record.local_project_id == candidate.local_project_id
&& record.package_sha256 == candidate.package_sha256
}) {
if let Some(existing) = attempts
.iter()
.find(|record| record_matches_intent(record, &candidate))
{
return Ok((existing.root_key.clone(), true));
}
let root_key = candidate.root_key.clone();
// 同一项目只保留一个未收口尝试:用户改了目标版本或资料后,旧意图的半成品不再续用。
attempts.retain(|record| {
!(record.account_id == candidate.account_id
&& record.origin == candidate.origin
@@ -226,6 +267,19 @@ pub(crate) fn resolve_game_publish_root_key(
Ok((root_key, false))
}
fn record_matches_intent(
record: &GamePublishAttemptRecord,
candidate: &GamePublishAttemptRecord,
) -> bool {
record.account_id == candidate.account_id
&& record.origin == candidate.origin
&& record.local_project_id == candidate.local_project_id
&& record.package_sha256 == candidate.package_sha256
&& record.target_game_id == candidate.target_game_id
&& record.target_version_number == candidate.target_version_number
&& record.metadata_digest == candidate.metadata_digest
}
/// 一次发布在服务端收口后清掉记录:下一次同样内容的发布就是一个新版本。
///
/// 清理失败不影响已经成功的发布——重试只会命中服务端幂等收据而不会多建版本,
@@ -267,44 +321,101 @@ mod tests {
dir
}
fn intent<'a>(
account_id: &'a str,
origin: &'a str,
local_project_id: &'a str,
package_sha256: &'a str,
) -> GamePublishAttemptIntent<'a> {
GamePublishAttemptIntent {
account_id,
origin,
local_project_id,
package_sha256,
target_game_id: None,
target_version_number: None,
metadata_digest: "",
}
}
#[test]
fn same_package_reuses_root_key_and_new_package_rotates_it() {
let dir = fixture_dir("reuse");
let sha = "a".repeat(64);
let (first, reused) =
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
.expect("first key");
assert!(!reused);
assert!(first.starts_with("agc-publish-"));
let (again, reused) =
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
.expect("reused key");
assert!(reused, "同一份包重发必须复用同一个根幂等键");
assert_eq!(again, first);
// 重启不改变结论:换一个进程视角重新读盘仍然是同一个键。
let (after_restart, reused) =
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
.expect("restarted key");
assert!(reused);
assert_eq!(after_restart, first);
let other_sha = "b".repeat(64);
let (rotated, reused) =
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &other_sha)
.expect("rotated key");
let (rotated, reused) = resolve_game_publish_root_key(
&dir,
intent("user-1", "https://dev.test", "proj-1", &other_sha),
)
.expect("rotated key");
assert!(!reused, "换了包就不是同一次尝试");
assert_ne!(rotated, first);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn target_version_and_metadata_digest_are_part_of_the_attempt_identity() {
let dir = fixture_dir("intent");
let sha = "f".repeat(64);
let digest_a = "1".repeat(64);
let digest_b = "2".repeat(64);
let mut base = intent("user-1", "https://dev.test", "proj-1", &sha);
base.metadata_digest = &digest_a;
base.target_game_id = Some("game-1");
base.target_version_number = Some(3);
let (first, _) = resolve_game_publish_root_key(&dir, base).expect("first key");
// 同一份 ZIP、同一资料,只是用户把目标版本从 3 改成 4:必须换根键,
// 否则服务端幂等收据会把用户填的版本号静默吞掉。
let mut bumped_version = base;
bumped_version.target_version_number = Some(4);
let (bumped, reused) =
resolve_game_publish_root_key(&dir, bumped_version).expect("bumped version key");
assert!(!reused, "改目标版本号就是一次新尝试");
assert_ne!(bumped, first);
// 资料摘要变化同样换键:不能只按 ZIP 摘要合并不同发布意图。
let mut bumped_metadata = intent("user-1", "https://dev.test", "proj-1", &sha);
bumped_metadata.metadata_digest = &digest_b;
bumped_metadata.target_game_id = Some("game-1");
bumped_metadata.target_version_number = Some(4);
let (metadata_rotated, reused) = resolve_game_publish_root_key(&dir, bumped_metadata)
.expect("metadata rotated key");
assert!(!reused, "资料修订变化也必须换键");
assert_ne!(metadata_rotated, bumped);
let (reused_again, reused) =
resolve_game_publish_root_key(&dir, bumped_metadata).expect("reuse identical intent");
assert!(reused);
assert_eq!(reused_again, metadata_rotated);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn attempts_are_scoped_per_account_origin_and_project() {
let dir = fixture_dir("scope");
let sha = "c".repeat(64);
let (base, _) =
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
.expect("base key");
for (account, origin, project) in [
("user-2", "https://dev.test", "proj-1"),
@@ -312,7 +423,7 @@ mod tests {
("user-1", "https://dev.test", "proj-2"),
] {
let (scoped, reused) =
resolve_game_publish_root_key(&dir, account, origin, project, &sha)
resolve_game_publish_root_key(&dir, intent(account, origin, project, &sha))
.expect("scoped key");
assert!(!reused, "不同账号/origin/项目不得复用别人的发布尝试");
assert_ne!(scoped, base);
@@ -325,12 +436,12 @@ mod tests {
let dir = fixture_dir("clear");
let sha = "d".repeat(64);
let (first, _) =
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
.expect("first key");
clear_game_publish_attempt(&dir, "user-1", "https://dev.test", "proj-1")
.expect("clear attempt");
let (second, reused) =
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
.expect("second key");
assert!(!reused, "收口过的尝试不再复用,下一次发布是新版本");
assert_ne!(second, first);
@@ -344,8 +455,12 @@ mod tests {
fn ledger_only_holds_identifiers_and_summaries() {
let dir = fixture_dir("shape");
let sha = "e".repeat(64);
resolve_game_publish_root_key(&dir, "user-1", "https://dev.test", "proj-1", &sha)
.expect("key");
let digest = "a".repeat(64);
let mut shape = intent("user-1", "https://dev.test", "proj-1", &sha);
shape.metadata_digest = &digest;
shape.target_game_id = Some("game-1");
shape.target_version_number = Some(2);
resolve_game_publish_root_key(&dir, shape).expect("key");
let content =
fs::read_to_string(game_publish_attempt_path(&dir)).expect("read ledger file");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("ledger json");
@@ -364,6 +479,16 @@ mod tests {
.and_then(|value| value.as_str()),
Some("proj-1")
);
assert_eq!(
record.get("targetGameId").and_then(|value| value.as_str()),
Some("game-1")
);
assert_eq!(
record
.get("targetVersionNumber")
.and_then(serde_json::Value::as_u64),
Some(2)
);
// 账本不写 token、刷新凭据,也不写本地绝对路径。
for forbidden in [
"token",
+8
View File
@@ -1808,6 +1808,11 @@ export function App({
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
onPublished={(published) => {
// 发布成功会把平台作品绑定写回本地清单;用原生返回的整份 manifest 覆盖
// 工作台状态,两处面板挂载点(普通项目 / DirectProject)共享同一份状态。
if (published.manifest) setManifest(published.manifest);
}}
/>
<GamePublishProgressDialog
progress={publishProgress}
@@ -1939,6 +1944,9 @@ export function App({
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
onPublished={(published) => {
if (published.manifest) setManifest(published.manifest);
}}
/>
<GamePublishProgressDialog
progress={publishProgress}
@@ -8,12 +8,15 @@ import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectExportPackageResult } from '../../app/types';
import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload';
import {
type GameDistributionPublicationImageDraft,
type GameDistributionPublicationReadResult,
type GameDistributionPublishMetadata,
type GameDistributionPublishResult,
generateGameDistributionCover,
MAX_AGC_GAME_SCREENSHOTS,
publishLocalProjectGame,
readGameCoverGenerationPrice,
readGameDistributionPublication,
suggestGameDistributionPublishMetadata,
} from '../../services/gameDistributionPublish';
import {
@@ -46,9 +49,17 @@ type PanelScreenshotEntry = {
assetObjectId: string | null;
status: 'uploading' | 'ready' | 'failed';
error: string | null;
file: File;
/** 已恢复的历史截图没有本地文件;新建条目总是带文件。 */
file: File | null;
};
/**
* 发布面板的绑定状态:检查中 / 首次发布 / 更新 / 不可用。
*
* `unavailable` 时禁止提交:恢复失败(网络、401、5xx)不能退化成首次发布,否则会多建作品。
*/
type PanelPublicationState = 'checking' | 'first-publish' | 'update' | 'unavailable';
function resolvePanelImageLabel(kind: PanelImageKind) {
return kind === 'cover' ? '游戏封面' : '游戏截图';
}
@@ -91,6 +102,65 @@ function createPanelScreenshotId() {
return `screenshot-${randomUuid}`;
}
/** 版本状态到中文标签的静态映射;未知状态原样展示,不臆造。 */
const PUBLICATION_VERSION_STATUS_LABELS: Record<string, string> = {
awaiting_upload: '待上传',
uploaded: '已上传',
validating: '校验中',
pending_review: '审核中',
published: '已公开',
upload_failed: '上传失败',
validation_failed: '校验失败',
rejected: '已驳回',
cancelled: '已撤回',
revoked: '已撤销',
};
function publicationVersionStatusLabel(status: string | null | undefined) {
const normalized = status?.trim() ?? '';
if (!normalized) return '未知状态';
return PUBLICATION_VERSION_STATUS_LABELS[normalized] ?? normalized;
}
/** 目标版本号只接受正整数安全整数;非法输入返回 null,由调用方给出可读错误。 */
function parseTargetVersion(value: string): number | null {
const trimmed = value.trim();
if (!/^\d+$/u.test(trimmed)) return null;
const parsed = Number(trimmed);
if (!Number.isSafeInteger(parsed) || parsed <= 0) return null;
return parsed;
}
/** 已发布作品的封面恢复:预览用换签地址,提交仍复用冻结素材 ID。 */
function buildRestoredCover(
cover: GameDistributionPublicationImageDraft,
): PanelImageAsset {
const assetId = cover.assetId?.trim() ?? '';
return {
signature: `existing:${assetId || cover.previewUrl?.trim() || 'cover'}`,
name: '当前发布封面',
assetObjectId: assetId,
previewUrl: cover.previewUrl?.trim() ?? '',
};
}
/** 已发布作品的截图恢复;历史版本缺少冻结素材 ID 时标记为失败,要求作者重新选择。 */
function buildRestoredScreenshot(
screenshot: GameDistributionPublicationImageDraft,
index: number,
): PanelScreenshotEntry {
const assetId = screenshot.assetId?.trim() ?? '';
return {
id: `restored-screenshot-${index}-${assetId || 'missing'}`,
name: `当前截图 ${index + 1}`,
previewUrl: screenshot.previewUrl?.trim() ?? '',
assetObjectId: assetId || null,
status: assetId ? 'ready' : 'failed',
error: assetId ? null : '该截图缺少平台素材,请删除后重新选择',
file: null,
};
}
const COVER_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
const COVER_GENERATION_ASPECT_RATIO = '16:9';
const COVER_GENERATION_IMAGE_SIZE = '2K';
@@ -194,7 +264,26 @@ export function GameDistributionPublishPanel({
message: string;
percent: number | null;
} | null>(null);
/** 发布绑定状态:检查中 / 首次发布 / 更新 / 不可用。 */
const [publicationState, setPublicationState] =
useState<PanelPublicationState>('checking');
const [publicationBinding, setPublicationBinding] = useState<
GameDistributionPublicationReadResult['binding'] | null
>(null);
const [publicationRefreshed, setPublicationRefreshed] = useState(false);
const [publicationMessage, setPublicationMessage] = useState('');
const [targetVersion, setTargetVersion] = useState('1');
/** 已知最大版本号;首次发布为 0。 */
const [latestVersionNumber, setLatestVersionNumber] = useState(0);
const metadataSuggestionRequestRef = useRef(0);
const publicationRequestRef = useRef(0);
const publicationDraftAppliedRef = useRef(false);
/** 回读冻结资料里的设备/输入/方向声明:面板不编辑,但更新发布必须原样沿用。 */
const restoredDeclarationsRef = useRef<{
deviceSupport?: GameDistributionPublishMetadata['deviceSupport'];
inputModes?: GameDistributionPublishMetadata['inputModes'];
orientation?: GameDistributionPublishMetadata['orientation'];
} | null>(null);
const summaryTouchedRef = useRef(false);
const categoryTouchedRef = useRef(false);
const coverInputRef = useRef<HTMLInputElement>(null);
@@ -211,6 +300,8 @@ export function GameDistributionPublishPanel({
setTitle(manifest.name.trim());
setSummary(fallbackSummary);
setCategory('其他');
setCover(null);
setScreenshots([]);
setBusy(false);
setError('');
setResult(null);
@@ -222,10 +313,19 @@ export function GameDistributionPublishPanel({
setCoverGenerationDetail('');
setPreviewScreenshot(null);
setPublishPhase(null);
setPublicationState('checking');
setPublicationBinding(null);
setPublicationRefreshed(false);
setPublicationMessage('');
setTargetVersion('1');
setLatestVersionNumber(0);
summaryTouchedRef.current = false;
categoryTouchedRef.current = false;
publicationDraftAppliedRef.current = false;
restoredDeclarationsRef.current = null;
uploadedAssetCacheRef.current.clear();
const requestId = ++metadataSuggestionRequestRef.current;
const suggestionRequestId = ++metadataSuggestionRequestRef.current;
setMetadataSuggestionPending(true);
void suggestGameDistributionPublishMetadata({
name: manifest.name,
@@ -233,11 +333,20 @@ export function GameDistributionPublishPanel({
context: buildPublishProjectContext(manifest),
})
.then((suggestion) => {
if (requestId !== metadataSuggestionRequestRef.current) return;
if (!summaryTouchedRef.current && suggestion.summary.trim()) {
if (suggestionRequestId !== metadataSuggestionRequestRef.current) return;
// 更新发布以线上冻结资料为准:已有草稿回填时不再用 AI 建议覆盖。
if (
!publicationDraftAppliedRef.current &&
!summaryTouchedRef.current &&
suggestion.summary.trim()
) {
setSummary(suggestion.summary.trim().slice(0, 120));
}
if (!categoryTouchedRef.current && suggestion.category) {
if (
!publicationDraftAppliedRef.current &&
!categoryTouchedRef.current &&
suggestion.category
) {
setCategory(suggestion.category);
}
setMetadataSuggestionReady(true);
@@ -246,11 +355,91 @@ export function GameDistributionPublishPanel({
// 免费增强能力失败时保留本地兜底,不阻断发布。
})
.finally(() => {
if (requestId === metadataSuggestionRequestRef.current) {
if (suggestionRequestId === metadataSuggestionRequestRef.current) {
setMetadataSuggestionPending(false);
}
});
}, [manifest, open, packageResult?.packageRelativePath]);
// 绑定恢复:原生按当前会话(账号 + origin)隔离;网络/401/5xx 返回 unavailable,
// 面板据此禁用提交而不是退化成首次发布。
const invoke = resolveTauriInvoke();
if (!invoke) {
// 非 Tauri 宿主:没有可恢复的原生状态,交给提交时的失败提示兜底。
setPublicationState('first-publish');
return;
}
const publicationRequestId = ++publicationRequestRef.current;
void readGameDistributionPublication({ projectPath })
.then((recovery) => {
if (publicationRequestId !== publicationRequestRef.current) return;
if (recovery.state === 'unavailable') {
setPublicationState('unavailable');
setPublicationMessage(
recovery.message?.trim() ||
'无法确认该项目的线上发布状态,请稍后重试',
);
return;
}
if (recovery.state === 'update') {
const latest = recovery.binding.latestVersionNumber ?? null;
const max = latest ?? 0;
setPublicationState('update');
setPublicationBinding(recovery.binding);
setPublicationRefreshed(recovery.refreshed);
setPublicationMessage(recovery.message?.trim() ?? '');
setLatestVersionNumber(max);
setTargetVersion(String(max + 1));
if (recovery.draft) {
applyPublicationDraft(recovery.draft);
}
return;
}
setPublicationState('first-publish');
setPublicationMessage(recovery.message?.trim() ?? '');
})
.catch((recoveryError) => {
if (publicationRequestId !== publicationRequestRef.current) return;
setPublicationState('unavailable');
setPublicationMessage(
recoveryError instanceof Error
? recoveryError.message
: '无法确认该项目的线上发布状态,请稍后重试',
);
});
function applyPublicationDraft(
draft: NonNullable<
Extract<
GameDistributionPublicationReadResult,
{ state: 'update' }
>['draft']
>,
) {
publicationDraftAppliedRef.current = true;
restoredDeclarationsRef.current = {
deviceSupport: draft.deviceSupport ?? undefined,
inputModes: draft.inputModes?.length ? draft.inputModes : undefined,
orientation: draft.orientation ?? undefined,
};
if (draft.title.trim()) setTitle(draft.title.trim().slice(0, 40));
if (!summaryTouchedRef.current && draft.summary.trim()) {
setSummary(draft.summary.trim().slice(0, 120));
}
if (!categoryTouchedRef.current && draft.category) {
setCategory(draft.category);
}
if (draft.cover && (draft.cover.assetId || draft.cover.previewUrl)) {
setCover(buildRestoredCover(draft.cover));
}
if (draft.screenshots.length > 0) {
setScreenshots(
draft.screenshots.map((screenshot, index) =>
buildRestoredScreenshot(screenshot, index),
),
);
}
}
}, [manifest, open, packageResult?.packageRelativePath, projectPath]);
useEffect(() => {
if (!open) return;
@@ -428,7 +617,7 @@ export function GameDistributionPublishPanel({
// 每张截图独立上传、独立失败;某一张失败只标记该张,不打断同批其它截图。
await Promise.all(
entries.map(async (entry) => {
if (entry.status === 'failed') return;
if (entry.status === 'failed' || !entry.file) return;
try {
const assetObjectId = await resolveAssetObjectId(
entry.file,
@@ -478,10 +667,35 @@ export function GameDistributionPublishPanel({
setError('需要在 Tauri App 内发布');
return;
}
if (publicationState === 'checking') {
setError('正在确认项目发布状态,请稍候再试');
return;
}
if (publicationState === 'unavailable') {
setError(
publicationMessage || '无法确认该项目的线上发布状态,请稍后重试',
);
return;
}
const parsedTargetVersion = parseTargetVersion(targetVersion);
if (parsedTargetVersion === null) {
setError('目标版本号必须是大于 0 的整数');
return;
}
if (parsedTargetVersion <= latestVersionNumber) {
setError(
`目标版本号必须大于当前最大版本号 ${latestVersionNumber},不能覆盖已有版本`,
);
return;
}
if (!cover) {
setError('请先选择游戏封面(JPG/PNG/WebP)');
return;
}
if (!cover.assetObjectId.trim()) {
setError('当前发布封面缺少平台素材,请重新选择封面后再发布');
return;
}
if (
uploadingLabel ||
screenshots.some((item) => item.status === 'uploading')
@@ -537,7 +751,14 @@ export function GameDistributionPublishPanel({
} => item.status === 'ready' && Boolean(item.assetObjectId),
)
.map((item) => item.assetObjectId),
// 更新发布沿用线上冻结的设备/输入/方向声明;首次发布没有这些值,服务端默认。
...(restoredDeclarationsRef.current ?? {}),
},
gameId:
publicationState === 'update' ? publicationBinding?.gameId ?? null : null,
versionNumber: parsedTargetVersion,
expectedPublicationRevision:
publicationState === 'update' ? publicationBinding?.revision ?? null : null,
});
setResult(next);
onPublished?.(next);
@@ -607,6 +828,42 @@ export function GameDistributionPublishPanel({
</div>
) : (
<>
<div
className={`game-distribution-publish-panel__publication is-${publicationState}`}
role="status"
>
<strong>
{publicationState === 'update'
? '更新游戏'
: publicationState === 'unavailable'
? '无法确认发布状态'
: publicationState === 'checking'
? '正在检查发布绑定…'
: '首次发布'}
</strong>
{publicationState === 'update' && publicationBinding ? (
<span>
当前版本{' '}
{latestVersionNumber > 0 ? latestVersionNumber : '—'} ·{' '}
{publicationVersionStatusLabel(publicationBinding.status)}
{publicationRefreshed
? ''
: '(未能刷新线上状态,发布以服务端校验为准)'}
</span>
) : publicationState === 'first-publish' ? (
<span>这个项目还没有发布到游戏广场。</span>
) : publicationState === 'checking' ? (
<span>正在读取本地绑定与线上作品状态。</span>
) : (
<span>
{publicationMessage ||
'无法确认该项目的线上发布状态,请稍后重试。'}
</span>
)}
{publicationState === 'update' && publicationMessage ? (
<span>{publicationMessage}</span>
) : null}
</div>
<div className="game-distribution-publish-panel__fields">
<label>
游戏名称
@@ -647,6 +904,28 @@ export function GameDistributionPublishPanel({
))}
</select>
</label>
<label>
目标版本号
<input
type="number"
min={1}
step={1}
inputMode="numeric"
value={targetVersion}
disabled={
busy ||
generatingCover ||
publicationState === 'checking' ||
publicationState === 'unavailable'
}
onChange={(event) => setTargetVersion(event.target.value)}
/>
</label>
<span className="game-distribution-publish-panel__hint">
{latestVersionNumber > 0
? `当前最大版本号 ${latestVersionNumber},建议 ${latestVersionNumber + 1};允许跳号,但不能小于等于当前版本号。`
: '首次发布建议使用版本号 1;版本号必须是大于 0 的整数。'}
</span>
<span
className="game-distribution-publish-panel__metadata-hint"
role="status"
@@ -872,6 +1151,8 @@ export function GameDistributionPublishPanel({
generatingCover ||
screenshotsUploading ||
Boolean(uploadingLabel) ||
publicationState === 'checking' ||
publicationState === 'unavailable' ||
!title.trim() ||
!summary.trim() ||
!packageResult
@@ -885,7 +1166,13 @@ export function GameDistributionPublishPanel({
? '截图上传中…'
: uploadingLabel
? '素材上传中…'
: '发布游戏'}
: publicationState === 'update'
? '更新游戏'
: publicationState === 'checking'
? '正在检查发布状态…'
: publicationState === 'unavailable'
? '无法确认发布状态'
: '发布游戏'}
</button>
</div>
</>
@@ -1,7 +1,11 @@
import type { GameCreationAppManifest } from '../../../../packages/shared/src/contracts/gameCreationApp';
import type {
GameCreationAppManifest,
GameCreationAppPublicationBinding,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import type {
GameDistributionCategory,
GameDistributionCreateGameRequest,
GameDistributionDeviceSupport,
GameDistributionInputMode,
GameDistributionOrientation,
} from '../../../../packages/shared/src/contracts/gameDistribution';
@@ -46,8 +50,91 @@ export type GameDistributionPublishResult = {
packageSha256: string;
packageSizeBytes: number;
fileCount: number;
/** 发布成功并回写绑定后的整份 manifest;渲染层据此同步工作台状态。 */
manifest?: GameCreationAppManifest | null;
publication?: GameCreationAppPublicationBinding | null;
};
/** 更新发布时默认回填的封面/截图;`assetId` 缺失表示历史版本没有冻结素材,需重新选择。 */
export type GameDistributionPublicationImageDraft = {
assetId: string | null;
previewUrl: string | null;
};
export type GameDistributionPublicationDraft = {
title: string;
summary: string;
description: string;
category: GameDistributionCategory;
tags: string[];
cover: GameDistributionPublicationImageDraft | null;
screenshots: GameDistributionPublicationImageDraft[];
/** 随版本冻结的设备/输入/方向声明;更新发布必须原样沿用,避免被默认值改写。 */
deviceSupport?: GameDistributionDeviceSupport | null;
inputModes?: GameDistributionInputMode[];
orientation?: GameDistributionOrientation | null;
};
/**
* 发布面板打开时的发布状态回读。
*
* `state` 三态必须分开处理:`unavailable` 表示恢复被网络/401/5xx 挡住,绝不能当成首次发布;
* `first-publish` 只在服务端给出确定答案(无绑定且作者名下没有匹配作品)时出现。
*/
export type GameDistributionPublicationReadResult =
| {
state: 'first-publish';
binding: null;
refreshed: boolean;
draft: null;
visibility: null;
message?: string | null;
}
| {
state: 'update';
binding: GameCreationAppPublicationBinding;
refreshed: boolean;
draft: GameDistributionPublicationDraft | null;
visibility?: string | null;
message?: string | null;
}
| {
state: 'unavailable';
binding: null;
refreshed: false;
draft: null;
visibility: null;
message?: string | null;
};
/**
* 发布面板打开时由原生恢复本地绑定与线上作品状态。
*
* 原生按当前会话(账号 + origin)隔离绑定:切换账号/环境后不会接管他人作品;无本地绑定时
* 按作者作品的 `localProjectId` 精确匹配做一次性恢复。网络/401/5xx 返回 `unavailable`,
* 调用方不得据此按首次发布继续。
*/
export async function readGameDistributionPublication(
args: { projectPath: string },
invoke?: TauriInvoke,
): Promise<GameDistributionPublicationReadResult> {
const projectPath = args.projectPath.trim();
if (!projectPath) {
throw new Error('发布需要绑定本地项目');
}
const payload = await requireNativeInvoke(invoke)<
GameDistributionPublicationReadResult | null | undefined
>('read_game_distribution_publication', { projectPath });
const state = payload?.state;
if (
!payload ||
(state !== 'first-publish' && state !== 'update' && state !== 'unavailable')
) {
throw new Error('发布状态回读结果无效');
}
return payload;
}
export type GameDistributionPublishMetadataSuggestion = {
summary: string;
category: GameDistributionCategory;
@@ -205,11 +292,21 @@ export async function publishLocalProjectGame(args: {
packageRelativePath: string;
manifest: GameCreationAppManifest;
metadata?: Partial<GameDistributionPublishMetadata>;
/**
* 更新已有作品时的目标游戏身份(来自发布绑定)。提供时必须同时给出
* `versionNumber` 与 `expectedPublicationRevision`:原生会跳过 create game,
* 直接在原 gameId 上创建新版本。
*/
gameId?: string | null;
/** 用户选择的目标正整数版本号;首次发布默认 1。不传则退回服务端自动递增。 */
versionNumber?: number | null;
/** 更新送审所需的公开修订号 CAS;来自发布绑定。 */
expectedPublicationRevision?: number | null;
/**
* 发布根幂等键。渲染层默认**不**生成也不持久化它:原生发布链路按
* 「账号身份 + 本地项目 + 发行包摘要」在应用数据里维护发布尝试账本,同一份包
* 重发、响应丢失、进程重启与分片续传都落在同一次服务端尝试上。只有显式传入
* (测试或调用方已持有键)时才覆盖账本。
* 「账号身份 + 本地项目 + 发行包摘要 + 目标游戏/版本 + 资料摘要」在应用数据里维护发布
* 尝试账本,同一份包重发、响应丢失、进程重启与分片续传都落在同一次服务端尝试上。
* 只有显式传入(测试或调用方已持有键)时才覆盖账本。
*/
idempotencyKey?: string;
}): Promise<GameDistributionPublishResult> {
@@ -223,16 +320,42 @@ export async function publishLocalProjectGame(args: {
if (!localProjectId) {
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
}
const gameId = args.gameId?.trim() ?? '';
const versionNumber = args.versionNumber ?? null;
if (versionNumber !== null) {
if (
!Number.isSafeInteger(versionNumber) ||
versionNumber <= 0
) {
throw new Error('目标版本号必须是大于 0 的安全整数');
}
}
if (gameId) {
if (versionNumber === null) {
throw new Error('更新游戏必须填写目标版本号');
}
if (
args.expectedPublicationRevision === null ||
args.expectedPublicationRevision === undefined
) {
throw new Error('缺少公开修订号,请重新打开发布面板后再试');
}
}
const gameMetadata = toCreateGameRequest(metadata, localProjectId);
const rootKey = args.idempotencyKey?.trim() ?? '';
// 发布编排、认证、origin、分片续传与送审都在 Rust 完成;渲染进程只传递
// 已归一化的资料和本地项目相对包路径,不接触 token、base URL 或整包字节。
// 已归一化的资料、目标游戏/版本和本地项目相对包路径,不接触 token、base URL 或整包字节。
return args.invoke<GameDistributionPublishResult>(
'publish_local_project_game',
{
projectPath,
packageRelativePath,
metadata: gameMetadata,
...(gameId ? { gameId } : {}),
...(versionNumber !== null ? { versionNumber } : {}),
...(gameId
? { expectedPublicationRevision: args.expectedPublicationRevision }
: {}),
...(rootKey ? { idempotencyKey: rootKey } : {}),
},
);
+30
View File
@@ -3558,6 +3558,36 @@ textarea {
line-height: 1.5;
}
.game-distribution-publish-panel__publication {
display: grid;
gap: 4px;
margin-top: 16px;
padding: 10px 12px;
border: 1px solid rgb(104 77 57 / 16%);
border-radius: 10px;
background: rgb(255 246 238 / 72%);
color: var(--platform-text-muted);
font-size: 11px;
font-weight: 500;
line-height: 1.5;
}
.game-distribution-publish-panel__publication strong {
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 800;
}
.game-distribution-publish-panel__publication.is-unavailable {
border-color: rgb(180 35 24 / 28%);
background: rgb(255 240 238 / 78%);
color: #b42318;
}
.game-distribution-publish-panel__publication.is-unavailable strong {
color: #b42318;
}
.game-distribution-publish-panel__fields label {
display: grid;
gap: 6px;
@@ -7,6 +7,7 @@ import {
generateGameDistributionCover,
publishLocalProjectGame,
readGameCoverGenerationPrice,
readGameDistributionPublication,
suggestGameDistributionPublishMetadata,
} from '../src/services/gameDistributionPublish';
const MANIFEST = {
@@ -248,4 +249,109 @@ describe('Rust 发布 facade', () => {
},
]);
});
test('发布状态恢复通过单个 native command 传递项目路径与三态结果', async () => {
const updateResult = {
state: 'update',
binding: {
accountId: 'user-1',
apiBaseUrl: 'https://dev.test',
gameId: 'game_9',
revision: 7,
latestVersionId: 'gamever_2',
latestVersionNumber: 2,
status: 'pending_review',
},
refreshed: true,
draft: null,
visibility: 'published',
};
const { invoke, calls } = installNativeInvoke((command) => {
if (command === 'read_game_distribution_publication') {
return updateResult;
}
throw new Error(`未预期的命令:${command}`);
});
await expect(
readGameDistributionPublication({ projectPath: '/tmp/project' }, invoke),
).resolves.toEqual(updateResult);
expect(calls).toEqual([
{
command: 'read_game_distribution_publication',
args: { projectPath: '/tmp/project' },
},
]);
});
test('恢复结果缺少已知三态时失败关闭,不把未知结果当成首次发布', async () => {
const { invoke } = installNativeInvoke(() => undefined);
await expect(
readGameDistributionPublication({ projectPath: '/tmp/project' }, invoke),
).rejects.toThrow('发布状态回读结果无效');
});
test('更新发布会带上原 gameId、目标版本号与公开修订号', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
gameId: 'game_9',
versionNumber: 3,
expectedPublicationRevision: 7,
});
expect(calls[0]?.args).toMatchObject({
gameId: 'game_9',
versionNumber: 3,
expectedPublicationRevision: 7,
});
});
test('更新发布缺少目标版本号或公开修订号时在 native command 前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
await expect(
publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
gameId: 'game_9',
}),
).rejects.toThrow('更新游戏必须填写目标版本号');
await expect(
publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
gameId: 'game_9',
versionNumber: 3,
}),
).rejects.toThrow('缺少公开修订号');
expect(invoke).not.toHaveBeenCalled();
});
test('目标版本号不是安全整数时在 native command 前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
for (const versionNumber of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
await expect(
publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
versionNumber,
}),
).rejects.toThrow('目标版本号必须是大于 0 的安全整数');
}
expect(invoke).not.toHaveBeenCalled();
});
});
@@ -502,20 +502,31 @@ liveTest(
String(metadata.localProjectId),
staged.packageSha256,
);
const game = await unwrap<{
id: string;
publicationRevision?: number;
}>(
await realFetch(apiUrl('/api/game-distribution/games'), {
method: 'POST',
headers: {
...packageAuthHeaders(token),
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:game`,
},
body: JSON.stringify(metadata),
}),
);
// 更新模式:带原 gameId 时跳过 create game,直接在原游戏上创建新版本。
const requestedGameId = String(args?.gameId ?? '').trim();
const requestedVersionNumber =
typeof args?.versionNumber === 'number' ? args.versionNumber : undefined;
const game = requestedGameId
? {
id: requestedGameId,
publicationRevision: Number(
args?.expectedPublicationRevision ?? 0,
),
}
: await unwrap<{
id: string;
publicationRevision?: number;
}>(
await realFetch(apiUrl('/api/game-distribution/games'), {
method: 'POST',
headers: {
...packageAuthHeaders(token),
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:game`,
},
body: JSON.stringify(metadata),
}),
);
const version = await unwrap<{
versionId: string;
versionNumber: number;
@@ -536,6 +547,9 @@ liveTest(
packageBytes: staged.packageSizeBytes,
packageFileCount: staged.packageFileCount,
packageEntryPath: 'index.html',
...(requestedVersionNumber === undefined
? {}
: { versionNumber: requestedVersionNumber }),
gameMetadata: metadata,
}),
},
@@ -660,6 +674,29 @@ liveTest(
expect(second.gameId).toBe(first.gameId);
expect(second.versionNumber).toBe(first.versionNumber + 1);
// 更新发布:AGC 带原 gameId + 用户目标版本号,跳过 create game,只新增版本。
const ownerDetail = await unwrap<{
game: { publicationRevision: number };
}>(
await realFetch(
apiUrl(`/api/game-distribution/my-games/${second.gameId}`),
{ headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS } },
),
);
const targetVersionNumber = 7;
const updated = await publishLocalProjectGame({
invoke,
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
packageRelativePath: 'dist/game.zip',
manifest,
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
gameId: second.gameId,
versionNumber: targetVersionNumber,
expectedPublicationRevision: ownerDetail.game.publicationRevision,
});
expect(updated.gameId).toBe(second.gameId);
expect(updated.versionNumber).toBe(targetVersionNumber);
const myGames = await unwrap<{
games: Array<{
id: string;
@@ -671,8 +708,14 @@ liveTest(
}),
);
const published = myGames.games.find((game) => game.id === first.gameId);
expect(published?.latestVersion?.versionId).toBe(second.versionId);
expect(published?.latestVersion?.versionId).toBe(updated.versionId);
expect(published?.latestVersion?.status).toBe('pending_review');
// 更新只增加版本,不重复创建作品:该 localProjectId 在作者名下仍然只有一款游戏。
expect(
myGames.games.filter(
(game) => game.id === first.gameId || game.id === updated.gameId,
),
).toHaveLength(1);
},
120_000,
);
@@ -24,6 +24,7 @@ import {
generateGameDistributionCover,
publishLocalProjectGame,
readGameCoverGenerationPrice,
readGameDistributionPublication,
suggestGameDistributionPublishMetadata,
} from '../src/services/gameDistributionPublish';
import { GAME_PACKAGE_UPLOAD_PROGRESS_EVENT } from '../src/services/gamePackageUploadProgress';
@@ -41,6 +42,7 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
generateGameDistributionCover: vi.fn(),
publishLocalProjectGame: vi.fn(),
readGameCoverGenerationPrice: vi.fn(),
readGameDistributionPublication: vi.fn(),
suggestGameDistributionPublishMetadata: vi.fn(),
};
});
@@ -125,6 +127,14 @@ beforeEach(() => {
vi.mocked(suggestGameDistributionPublishMetadata).mockImplementation(
() => new Promise(() => undefined),
);
// 默认按「首次发布」恢复,保持既有用例的提交路径不变;更新模式用例单独覆盖。
vi.mocked(readGameDistributionPublication).mockResolvedValue({
state: 'first-publish',
binding: null,
refreshed: true,
draft: null,
visibility: null,
});
});
afterEach(() => {
@@ -134,6 +144,7 @@ afterEach(() => {
vi.mocked(generateGameDistributionCover).mockReset();
vi.mocked(publishLocalProjectGame).mockReset();
vi.mocked(readGameCoverGenerationPrice).mockReset();
vi.mocked(readGameDistributionPublication).mockReset();
vi.mocked(suggestGameDistributionPublishMetadata).mockReset();
vi.mocked(uploadPlatformMediaAsset).mockReset();
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
@@ -488,7 +499,10 @@ describe('GameDistributionPublishPanel', () => {
installTauriInvoke(async () => undefined);
renderPanel();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
// 绑定恢复完成前提交按钮显示「正在检查发布状态…」,等待回到首次发布后再点击。
fireEvent.click(
await screen.findByRole('button', { name: '发布游戏' }),
);
expect(
await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
@@ -662,10 +676,147 @@ describe('GameDistributionPublishPanel', () => {
cleanup();
installTauriInvoke(async () => undefined);
renderPanel({ packageResult: null });
expect(screen.getByRole('button', { name: '发布游戏' })).toHaveProperty(
'disabled',
true,
);
expect(
await screen.findByRole('button', { name: '发布游戏' }),
).toHaveProperty('disabled', true);
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
});
test('已有绑定时进入更新模式,回填线上资料并提交原 gameId 与更高版本号', 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: true,
draft: {
title: '星轨防线(线上)',
summary: '线上简介',
description: '线上详介',
category: '策略',
tags: ['塔防'],
cover: {
assetId: 'asset_cover_old',
previewUrl: 'https://assets.test/cover.png',
},
screenshots: [
{
assetId: 'asset_shot_old',
previewUrl: 'https://assets.test/shot.png',
},
],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
},
visibility: 'published',
});
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_9',
versionId: 'gamever_3',
versionNumber: 3,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
});
const { onPublished } = renderPanel();
// 回填线上冻结资料,目标版本默认「最大版本号 + 1」。
await screen.findByRole('button', { name: '更新游戏' });
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
'value',
'星轨防线(线上)',
);
expect(screen.getByLabelText('一句话简介')).toHaveProperty(
'value',
'线上简介',
);
expect(screen.getByLabelText('目标版本号')).toHaveProperty('value', '3');
expect(
screen.getByText(/当前最大版本号 2,建议 3/u),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
expect(args?.gameId).toBe('game_9');
expect(args?.versionNumber).toBe(3);
expect(args?.expectedPublicationRevision).toBe(7);
// 冻结素材 ID 直接复用,不要求作者重新上传封面/截图。
expect(args?.metadata?.coverAssetId).toBe('asset_cover_old');
expect(args?.metadata?.screenshots).toEqual(['asset_shot_old']);
// 设备/输入/方向声明沿用线上冻结值,不被创建请求默认值改写。
expect(args?.metadata?.deviceSupport).toEqual({
desktop: true,
mobile: false,
touch: false,
});
expect(args?.metadata?.inputModes).toEqual(['keyboard', 'mouse']);
expect(args?.metadata?.orientation).toBe('landscape');
expect(await screen.findByText('已提交审核')).not.toBeNull();
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_5',
latestVersionNumber: 5,
status: 'published',
},
refreshed: true,
draft: null,
visibility: 'published',
});
renderPanel();
await screen.findByRole('button', { name: '更新游戏' });
fireEvent.change(screen.getByLabelText('目标版本号'), {
target: { value: '5' },
});
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
expect(
await screen.findByText(/目标版本号必须大于当前最大版本号 5/u),
).not.toBeNull();
expect(publishLocalProjectGame).not.toHaveBeenCalled();
});
test('恢复失败(网络/5xx)时禁用提交,不按首次发布继续', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(readGameDistributionPublication).mockResolvedValue({
state: 'unavailable',
binding: null,
refreshed: false,
draft: null,
visibility: null,
message: '无法刷新线上状态:无法连接登录服务',
});
renderPanel();
const submit = await screen.findByRole('button', {
name: '无法确认发布状态',
});
expect(submit).toHaveProperty('disabled', true);
expect(
screen.getByText('无法刷新线上状态:无法连接登录服务'),
).not.toBeNull();
expect(publishLocalProjectGame).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
# 【实施计划】AGC 已发布游戏版本更新
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】AGC已发布游戏版本更新-2026-10-02.md` |
| Status | proposed |
| Owner | Genarrative Agent |
## 修改边界
- `packages/shared/src/contracts/gameCreationApp.ts`:本地 `publication` 绑定,不混入本地编辑迭代 `versions[]`。
- `packages/shared/src/contracts/gameDistribution.ts` 与 Rust shared-contracts:创建版本请求增加可选 `versionNumber`。
- `apps/ai-game-creator-shell`:绑定回读、首次发布/更新 UI、目标版本输入、原 gameId 新版本发布、结果回写。
- `api-server`、`spacetime-client`、`spacetime-module`:把可选目标版本号传入现有创建版本 procedure,事务内比较最大版本号并拒绝冲突。
- 现有创建版本 procedure 输入/生成绑定需要同步,但不修改持久表字段。
## 实现顺序
1. 契约先补 `versionNumber?: number` 和本地 `publication`,明确版本号校验与兼容。
2. 创建版本事务在未传时自动递增;传入时必须大于现有最大版本号,并在同事务内校验。
3. AGC 发布绑定保存 `gameId`、最新版本、状态和 revision;按账号和 API origin 隔离绑定。
4. AGC 打开项目回读已有作品;旧项目通过 `localProjectId` 恢复关联。
5. 发布面板区分首次发布/更新,给出当前版本和可编辑目标版本输入。
6. Rust 发布 facade 在更新模式跳过创建 game,使用原 gameId 创建新 version;幂等账本把目标版本号纳入发布意图,不能仅以 ZIP 摘要合并不同发布。
7. 验证重复提交、响应丢失、切换账号、版本冲突、旧作品恢复,以及旧公开版本在新版本审核中继续可玩。
## 数据库影响
- 不新增表。
- 不修改 `version_number` 字段类型或重排字段。
- 修改创建版本 procedure 输入 DTO 与生成绑定;按当前 SpacetimeDB 门禁验证。
- 老客户端不传版本号仍自动递增,已有版本和历史数据不需要回填。
## 验证
- 领域/数据库创建版本测试:默认递增、指定更高版本、重复/较小/零/负数/非整数/越界输入、并发相同目标号。
- AGC 发布面板测试:首发、更新、默认目标版本、用户改号和恢复失败。
- AGC Rust 发布测试:跳过 create game、原 gameId、新 version、结果持久化、幂等意图区分。
- `npm run typecheck`、定向 Rust/Vitest、schema/生成绑定检查、编码与 diff。
- 真机发布:首次发布 → 重开项目 → 修改游戏 → 指定更高版本 → 更新送审 → 审核后原公开链接运行新包。
@@ -0,0 +1,52 @@
# 【实施计划】后台游戏审核详情与待审版本试玩
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】后台游戏审核详情与待审版本试玩-2026-10-02.md` |
| Status | in-progress |
| Owner | Genarrative Agent |
## 修改边界
### 允许修改
- `server-rs/crates/api-server`:管理员版本详情/预览会话/待审版本资源读取,以及现有发行资源公共逻辑的安全复用。
- `server-rs/crates/shared-contracts`、`apps/admin-web/src/api`:审核详情、预览会话和资料字段 DTO。
- `apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx` 及其测试:详情工作台、试玩入口、审核动作刷新。
- 现有 admin 权限映射、发行 sandbox/CSP/storage 兼容逻辑的必要扩展。
- 当前玩法主规范、里程碑证据和实施计划。
### 明确不修改
- `spacetime-module` 持久表结构、`migration.rs`、SpacetimeDB schema 和现有生成绑定,除非调研发现当前字段不足并重新获得迁移确认。
- `/api/external/v1`、作者发布页面、公开游戏详情和用户评价数据模型。
- 审核员分派、批量审核、精选推荐和后台游戏资料编辑。
## 实现顺序
1. 固化现有详情响应的字段映射与权限边界,先补 admin DTO 和契约测试。
2. 定义版本绑定的短期预览会话与错误语义,复用发行 ZIP 路径白名单、大小限制、HTML 注入、CSP 和响应头。
3. 实现管理员待审版本预览资源读取,确认公开发行路径不能读取未公开版本。
4. 将审核列表升级为详情工作台:资料分组、发布者、版本摘要、封面/截图、试玩状态和审核动作。
5. 补齐正常、拒绝、过期、越权、Cookie、缺文件、资源 404、CAS 冲突和幂等重放测试。
6. 运行真实本地审核 smoke,确认打开的是待审 `versionId`,不是当前公开版本;完成后回写主规范并删除临时计划。
## 验证命令
1. `npm run typecheck`
2. `npm exec -- vitest run apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx apps/admin-web/src/api/adminApiClient.test.ts --root .`
3. `cargo test -p api-server game_distribution --manifest-path server-rs/Cargo.toml`
4. `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`
5. `npm run check:encoding`
6. `npm run check:doc-index`
7. `git diff --check`
8. 本地 `npm run dev` + 管理员登录 + 待审版本详情/试玩 smoke。
## 风险与回滚点
- **预览越权**:Token 必须绑定 `versionId` 和过期时间;错误实现会把私有待审包变成可枚举资源。回滚点是关闭预览会话路由,不影响公开发行网关。
- **版本串播**:预览 URL 不能复用 `game.currentVersion.entryUrl`;详情和资源读取都必须携带并校验待审版本。回滚点是隐藏试玩按钮,保留资料审核。
- **sandbox 放宽**:不得添加 `allow-same-origin`;如 storage 兼容层回归,回滚新增注入逻辑而不放宽 sandbox。
- **资料漂移**:详情必须展示版本冻结快照;若快照缺失,只显示明确缺失状态,不静默用作者当前资料替代。
- **审核并发**:继续使用 `publicationRevision` 和幂等键;CAS 冲突只刷新,不自动重放决定。
- **API 兼容**:新字段可选,旧后台响应不能因缺少冻结资料而崩溃;预览接口失败不能影响公开游戏游玩。
@@ -0,0 +1,38 @@
# 【里程碑】AGC 已发布游戏版本更新
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | proposed |
| Date | 2026-10-02 |
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md#AGC 游戏分发与在线游玩合同` |
## 目标
让 AGC 区分首次发布与已有作品更新:已有作品继续使用原 `gameId`,创建不可覆盖的新版本;发布时用户可以决定目标正整数版本号。
## 范围
- 本地项目清单保存平台作品绑定和最近版本状态。
- 打开项目时回读绑定作品;旧清单按 `localProjectId` 做一次性恢复。
- 发布面板显示首次发布/更新模式、当前版本和建议目标版本。
- 用户在发布时填写目标版本号,默认最大版本号加一。
- 服务端可选接收 `versionNumber`;旧客户端不传时继续自动递增。
- 同一 `gameId` 的新版本上传、校验、送审和结果回写。
## 不在范围内
- AGC 完整版本历史页、版本回滚、版本删除、版本对比。
- 覆盖已有版本号或原地替换发行包。
- 新增 SpacetimeDB 持久表或修改 `game_distribution_version.version_number` 类型。
- 改变公开 URL、审核状态机、发行网关或后台审核权限。
## 验收标准
- [ ] 首次发布完成后项目清单保存 `gameId`、`versionId`、版本号、状态和 `publicationRevision`。
- [ ] 重新打开同一项目显示“更新游戏”,不再显示“创建平台游戏”。
- [ ] 默认目标版本为最大版本号加一,用户可改为更大的正整数。
- [ ] 目标版本不大于已有最大版本号时服务端拒绝,不能覆盖旧版本。
- [ ] 更新使用同一 `gameId`,只增加一个新 `versionId`,公开地址保持不变。
- [ ] 旧项目没有发布绑定时能按作者和 `localProjectId` 恢复;恢复失败时明确回到首次发布。
- [ ] 上传响应丢失、重复提交、版本号冲突和切换账号不会创建重复游戏或错误接管作品。
@@ -0,0 +1,51 @@
# 【里程碑】后台游戏审核详情与待审版本试玩
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-10-02 |
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏审核详情与待审版本试玩合同` |
## 目标
把后台游戏版本审核从“列表操作”补成可复核的审核详情工作台:审核员能确认发布者和冻结游戏资料,能试玩当前待审版本,并能在同一详情上下达审核决定。
## 范围
- 待审版本详情回读。
- 发布者、游戏资料、版本包摘要和冻结资料展示。
- 单个待审版本的短期隔离试玩会话。
- 试玩资源、Cookie、sandbox、CSP 和版本绑定边界。
- 通过、拒绝、安全下架动作与现有 CAS/幂等规则对齐。
- admin-web、api-server、shared-contracts 和相关测试的契约同步。
## 不在范围内
- 新增游戏审核、发布者、试玩会话或游戏资料持久化表。
- 审核员分派、SLA、批量审核和精选推荐。
- 后台修改游戏资料、重新构建或重新上传发行包。
- 公开发行接口、作者发布链路和 `/api/external/v1`。
## 依赖与前置条件
- 现有管理员版本详情接口继续返回游戏投影和版本冻结资料。
- 现有 SpacetimeDB `game_distribution_game` 与 `game_distribution_version` 字段满足详情展示。
- 现有发行 ZIP 校验、静态资源白名单和 HTML storage 兼容层可以抽取复用。
- 现有游戏审核权限映射作为第一版预览权限,不新增权限角色。
## 验收标准
- [ ] 审核详情显示发布者、完整游戏资料、版本摘要和审核状态。
- [ ] 详情使用待审版本冻结资料,不被后续 game 行资料修改污染。
- [ ] 审核员可以打开当前 `versionId` 试玩,且 JS/CSS/图片/音频等同包资源可加载。
- [ ] 预览 Token 绑定版本、短期有效、过期和越权读取均失败。
- [ ] 预览不读取平台 Cookie,不授予 `allow-same-origin`,保留发行 sandbox/CSP/storage 约束。
- [ ] 通过、拒绝、安全下架继续执行理由、CAS、幂等和刷新规则。
- [ ] 无 SpacetimeDB schema、migration、公开契约破坏性变化。
## 证据要求
- 自动化:admin-web 详情/动作测试、api-server 预览会话和权限测试、DTO/类型检查、编码和 diff 检查。
- 运行时:本地管理员打开待审版本,确认资料、封面/截图和待审包试玩;确认旧公开版本不会被误播。
- 边界:过期 Token、错误 versionId、Cookie、越权管理员、缺入口、资源 404、CAS 冲突和幂等重放。
@@ -94,14 +94,14 @@
### 身份、状态、审核与更新
- `gameId` 是服务端分配的稳定游戏身份;`ownerUserId` 只从当前认证主体派生。AGC 的本地 `projectId` 只能作为作者名下的关联提示,不能证明云端游戏所有权。网页上传和 AGC 发布使用相同游戏、版本与上传记录,不建立两套发行系统。
- 同一作者用相同 `localProjectId` 再次发布时复用既有 `gameId` 并只新增版本;游戏身份、版本号和服务端校验都不依赖客户端传来的路径或 ID 可信度。缺少 `localProjectId` 的旧客户端仍可发布,但会被视为新建游戏。
- 每次发行分配唯一 `versionId` 与游戏内递增 `versionNumber`。版本的游戏归属、包摘要、已确认字节和送审资料冻结后不可变;改包或改送审资料必须创建新版本。版本状态可以流转,内容不能原地覆盖。
- `gameId` 是服务端分配的稳定游戏身份;`ownerUserId` 只从当前认证主体派生。AGC 本地 `projectId` 只用于关联提示,不能证明云端游戏所有权。AGC 发布成功后必须把 `gameId`、最近 `versionId`、`versionNumber`、`publicationRevision` 和状态写入项目清单;网页上传和 AGC 发布使用相同游戏、版本与上传记录,不建立两套发行系统。
- 同一作者用相同 `localProjectId` 再次发布时复用既有 `gameId` 并只新增版本;AGC 如果清单缺少发布记录,首次更新前按作者作品回读和 `localProjectId` 做一次性恢复。缺少 `localProjectId` 的旧客户端仍可发布,但会被视为新建游戏。
- 每次发行分配唯一 `versionId`,`version_number` 已存在于版本表。服务端保留“未传版本号时自动取最大值加一”的旧客户端兼容行为;AGC 更新发布必须让用户填写正整数版本号,默认建议为当前最大版本号加一。版本号必须严格大于该游戏已有最大版本号,不能覆盖、重用或静默改写旧版本;允许跳号但发布前明确展示当前版本与目标版本。
- 游戏单独保存 `publicationRevision`、`activeVersionId` 和可见性 `unpublished | published | suspended`;正式可见性由服务端持久化事实决定。未通过审核时 `activeVersionId` 为空;`suspended` 是管理员安全下架,作者不能自行解除。
- 版本状态为 `awaiting_upload → uploaded → validating → pending_review → published`。上传确定失败进入 `upload_failed`,验证失败进入 `validation_failed`,人工拒绝进入 `rejected`;尚未公开版本可以撤回为 `cancelled`,已公开版本可撤销为 `revoked`。已成功上传的相同字节可重新校验;内容改变或审核拒绝后的修改必须新建版本。
- 首版建议人工审核。审核员检查游戏资料、真实桌面运行、声明移动适配、内容与外部请求被阻断的行为;自动包校验通过只进入 `pending_review`,不自动公开。审核记录保存审核者、目标版本、结论、理由和时间。后台只授权现有管理员身份,不让普通作者调用审核动作。
- 更新送审时旧 `activeVersionId` 继续服务目录、详情与游玩。审核通过并完成对象可读验证后,一次事务切换当前公开版本和公开资料;新版本上传、校验、审核或对象安装失败均不改变旧版本。
- 发布页先自动填入可用标题和封面,首次需要确认必需资料,此后复用上次资料;用户一次“提交发布”动作串联校验、上传和送审,并显示真实阶段。等待审核不能显示“已发布”;成功后提供查看详情和复制公开链接。
- AGC 发布面板在无绑定时显示“首次发布”,存在绑定时显示“更新游戏”,回填当前版本号、状态和建议目标版本号。首版不做完整版本历史/回滚管理,只提供目标版本号选择、当前状态和新版本发布。
### 幂等、并发与恢复
@@ -464,3 +464,75 @@
2026-10-01 按用户后续实现授权完成公开快照、共享契约、后端响应和共享评分文本接入。绑定以仓库固定 SpacetimeDB 2.8.3 生成,持久化表未变;只为列表最终返回游戏聚合评价。隔离库没有既有评分 fixture,本次复用既有 helper 临时创建两个账号、两款游戏和两条评价,未新增仓库 E2E 脚本或完整管理矩阵。
本地验证通过,尚待用户验收和生产部署。未跑完整仓库测试、完整评价/后台管理 E2E、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分和实际发行包游玩;返回刷新使用真实 API 改分验证,登录用户公开列表已在 API smoke 对照。
## 后台游戏审核详情与待审版本试玩合同
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-10-02 |
| 适用范围 | admin-web 游戏版本审核、作者/游戏资料回读、待审版本隔离试玩 |
| 数据事实源 | `game_distribution_game`、`game_distribution_version` 及现有版本冻结资料 |
### 目标
- 审核员在一个详情工作台内看到发布者、游戏资料、待审版本包信息、审核状态和审核历史。
- 审核员可以实际试玩当前待审 `versionId`,不能误播旧的公开版本或另一个待审版本。
- 试玩保持发行网关的 opaque sandbox、Cookie 隔离、CSP、路径白名单和运行期 storage 兼容边界。
- 通过、拒绝、安全下架继续使用现有 `publicationRevision`、认证管理员和幂等语义。
### 非目标
- 不新增平行游戏审核表、游戏资料表、发布者表或公开发行通道。
- 不把待审包提前写入公开发行网关路径,不改变未审核版本的可见性。
- 不在审核页提供游戏资料编辑、重新打包、重新上传或替作者发布。
- 不把管理员 Access Token 放进 iframe URL,也不授予 `allow-same-origin`。
### 详情数据与权限
- 审核列表使用现有版本审核列表;点击版本后回读现有管理员版本详情。
- 详情必须展示:发布者 ID/名称/头像、游戏标题、简介、详细介绍、分类、标签、操作方式、支持设备、输入方式、方向、封面、截图、版本号、包大小、文件数、SHA-256、提交时间、审核时间、审核理由和 `publicationRevision`。
- 展示的游戏资料优先使用该版本冻结的 `metadata_json`;不能用审核期间作者后来修改的 game 行资料替代待审快照。
- 发布者和游戏资料仅通过管理员受保护接口读取;公开目录不得因此增加作者私有字段或待审版本字段。
- 封面与截图通过现有后台素材换签接口读取 Object Key,不把私有 Object Key 当作浏览器直链。
### 待审版本试玩
- 管理员先创建绑定单个 `versionId` 的短期预览会话,再得到 `previewUrl` 与过期时间。
- 预览会话不持久化到 SpacetimeDB;Token 仅能读取绑定版本包,过期后不可继续读取。
- 预览资源读取必须拒绝平台 Cookie,继续返回 `nosniff`、跨来源静态资源头、最小权限 CSP,并复用发行 ZIP 路径和大小白名单。
- HTML 入口必须保留 `sandbox="allow-scripts"`,继续注入隔离的运行期 `localStorage/sessionStorage` 兼容层;不得添加 `allow-same-origin`。
- 试玩入口、JS、CSS、图片、音频和其它包内资源都必须来自同一个绑定的待审版本;读取失败显示明确资源/入口错误,不降级到公开版本。
### 审核动作
- 详情页保留通过、拒绝、安全下架;列表页保留快捷动作。
- 拒绝和下架继续要求理由;所有写操作携带 `publicationRevision` 和 `Idempotency-Key`。
- 并发修订返回冲突时只提示刷新并重新读取,不覆盖其他审核员的决定。
- 审核操作成功后刷新详情和待审列表,不能只修改前端本地状态。
### 契约与数据库边界
- 第一版不新增 SpacetimeDB 表、字段、migration 或生成绑定。
- 复用现有 `game_distribution_game`、`game_distribution_version`、`metadata_json`、审核人/时间/理由和 `publicationRevision` 字段。
- 预览会话属于 api-server 短期运行态;如果未来需要审核任务分派或完整试玩审计,另开规范评估是否增加持久化表。
- 管理员预览接口属于后台契约,必须同步 admin DTO、权限映射、API 测试和前端类型;不改变 `/api/external/v1`。
### 验收标准
| 条款 | 验收方式 | 证据 |
| --- | --- | --- |
| 发布者和资料 | 详情回读 + admin-web 页面检查 | admin-web 详情测试通过,显示作者、资料、版本摘要和冻结 JSON |
| 待审快照 | 修改 game 行资料后仍显示待审版本冻结资料 | 详情响应和页面已接入 `frozenMetadata`;真实数据验证待补 |
| 版本隔离试玩 | 用两个版本验证预览 URL 不能串读,待审包资源全部可加载 | 预览会话路由和版本绑定已实现;真实审核试玩待补 |
| 安全边界 | Cookie、过期 Token、越权 versionId、公开路径访问拒绝 | sandbox、Cookie 拒绝和短期 Token 已实现;HTTP 边界测试待补 |
| 审核动作 | 通过/拒绝/下架、理由、CAS 冲突、幂等重放 | 既有审核动作保留,详情页已接入;回归测试沿用现有 6 项审核页面测试 |
| 工程门禁 | admin-web 测试、api-server 定向测试、类型、编码、diff | admin-web typecheck、详情测试、api-server cargo check、Rust fmt、编码和 diff 通过 |
2026-10-02 已完成第一版审核详情工作台与待审版本预览接线:后台详情回读复用现有 game/version 投影,预览 Token 只绑定待审 versionId,预览 iframe 保持 `sandbox="allow-scripts"`。尚未完成真实管理员登录、待审包资源全量加载和 Token 越权 HTTP smoke。
### 未决问题
- 管理员权限继续复用现有游戏审核权限映射,还是单独增加 `game-review-preview` 动作权限;第一里程碑默认复用现有审核权限,不扩大权限模型。
- 预览 Token 的单次 HTML 读取与同包静态资源多次读取需要统一 TTL 和失效规则;第一里程碑使用短 TTL 的版本绑定会话。
@@ -0,0 +1,349 @@
/*
* 游戏详情展示层样式:公开游戏详情与后台审核详情共用同一套 hero、封面/截图、正文与资料侧栏。
*
* 这里只放表现。游戏数据加载、游玩入口、评价与审核动作都留在各自宿主里,
* 组件本身不取数、不鉴权。类名统一用 `game-detail-` 前缀,避免与宿主自己的
* 游戏卡片(`.game-cover` / `.game-eyebrow` / `.game-avatar` 等)互相影响。
*/
.game-detail-display {
min-width: 0;
color: var(--platform-text-strong);
font-family: var(--platform-font-family, system-ui, sans-serif);
font-weight: 400;
}
.game-detail-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.38rem;
color: var(--platform-accent-strong);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.game-detail-eyebrow svg {
width: 0.9rem;
height: 0.9rem;
}
/* 封面:素材未就绪时用渐变 + 图标占位,换签成功后图片覆盖在占位之上。 */
.game-detail-cover {
position: relative;
display: flex;
min-height: 12rem;
align-items: center;
justify-content: center;
overflow: hidden;
isolation: isolate;
}
.game-detail-cover__grid {
position: absolute;
inset: 0;
opacity: 0.22;
background-image: linear-gradient(
rgba(255, 255, 255, 0.2) 1px,
transparent 1px
),
linear-gradient(90deg, rgba(255, 255, 255, 0.2) 1px, transparent 1px);
background-size: 2rem 2rem;
transform: perspective(12rem) rotateX(58deg) scale(1.7) translateY(25%);
}
.game-detail-cover__glow {
position: absolute;
width: 70%;
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(255, 236, 204, 0.5),
transparent 66%
);
filter: blur(0.6rem);
}
.game-detail-cover__icon {
position: relative;
z-index: 1;
display: grid;
width: 5.4rem;
aspect-ratio: 1;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.13);
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.2);
color: #fff5dc;
font-size: 3rem;
backdrop-filter: blur(8px);
}
.game-detail-cover > img {
position: absolute;
inset: 0;
z-index: 2;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.game-detail-showcase {
display: grid;
gap: 0.7rem;
min-width: 0;
}
.game-detail-thumbs {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.15rem;
scroll-snap-type: x proximity;
scrollbar-width: thin;
}
.game-detail-thumb {
position: relative;
flex: 0 0 auto;
width: 5.4rem;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 0.7rem;
padding: 0;
background: linear-gradient(
135deg,
rgba(241, 159, 95, 0.24),
rgba(91, 49, 42, 0.16)
);
cursor: pointer;
scroll-snap-align: start;
transition: 160ms ease;
}
.game-detail-thumb:hover {
border-color: rgba(199, 101, 61, 0.45);
}
.game-detail-thumb[aria-pressed='true'] {
border-color: rgba(199, 101, 61, 0.62);
box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.22);
}
.game-detail-thumb img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.game-detail-thumb__placeholder {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.game-detail-hero {
display: grid;
grid-template-columns: minmax(13rem, 21rem) minmax(0, 1fr);
align-items: center;
gap: clamp(1.4rem, 4vw, 3.5rem);
margin-top: 1.5rem;
}
.game-detail-hero .game-detail-cover {
border-radius: 1.7rem;
box-shadow: 0 1.25rem 2.5rem rgba(70, 42, 34, 0.18);
}
.game-detail-hero h1 {
margin: 0.65rem 0 0;
font-size: clamp(2rem, 4.8vw, 3.7rem);
font-weight: 400;
line-height: 1.5;
overflow-wrap: anywhere;
letter-spacing: -0.055em;
}
.game-detail-hero__summary {
max-width: 32rem;
margin: 0.8rem 0 0;
color: var(--platform-text-base);
font-size: 1rem;
line-height: 1.75;
}
.game-detail-hero__author {
display: flex;
align-items: center;
gap: 0.55rem;
margin-top: 1.15rem;
color: var(--platform-text-soft);
font-size: 0.78rem;
font-weight: 700;
}
.game-detail-avatar {
display: grid;
width: 1.8rem;
height: 1.8rem;
place-items: center;
border-radius: 50%;
background: rgba(199, 101, 61, 0.18);
color: var(--platform-accent-strong);
font-weight: 900;
}
.game-detail-hero__actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1.4rem;
}
.game-detail-hero__play-count {
color: var(--platform-text-soft);
font-size: 0.75rem;
}
.game-detail-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 17rem;
gap: 3rem;
margin-top: 3rem;
border-top: 1px solid var(--platform-surface-border);
padding-top: 2rem;
}
.game-detail-main h2 {
margin: 0;
font-size: 1.25rem;
}
.game-detail-main p {
max-width: 45rem;
margin: 0.75rem 0 0;
color: var(--platform-text-base);
line-height: 1.9;
}
.game-detail-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 1.3rem;
}
.game-detail-tag-list span {
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.35rem 0.65rem;
color: var(--platform-text-soft);
font-size: 0.7rem;
font-weight: 700;
}
.game-detail-side {
display: grid;
gap: 0.7rem;
align-content: start;
}
.game-detail-info-card {
display: grid;
gap: 0.3rem;
border: 1px solid var(--platform-surface-border);
border-radius: 1rem;
padding: 0.8rem 0.9rem;
background: var(--platform-panel-fill);
}
.game-detail-info-card strong {
font-size: 0.72rem;
}
.game-detail-info-card span {
display: inline-flex;
align-items: center;
gap: 0.28rem;
color: var(--platform-text-soft);
min-width: 0;
overflow-wrap: anywhere;
font-size: 0.72rem;
line-height: 1.45;
}
.game-detail-info-card svg {
width: 0.85rem;
}
.game-detail-footnote {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 2.2rem;
color: var(--platform-text-soft);
font-size: 0.7rem;
}
.game-detail-footnote svg {
width: 0.9rem;
}
@media (max-width: 820px) {
.game-detail-hero {
grid-template-columns: 9rem minmax(0, 1fr);
gap: 1.15rem;
}
.game-detail-hero .game-detail-cover {
min-height: 9rem;
}
.game-detail-cover__icon {
width: 3.7rem;
border-radius: 1rem;
font-size: 2rem;
}
.game-detail-body {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.game-detail-side {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.game-detail-thumb {
width: 4.6rem;
}
.game-detail-hero {
display: block;
}
.game-detail-hero .game-detail-cover {
min-height: 13rem;
margin-top: 1.2rem;
}
.game-detail-hero h1 {
margin-top: 1.4rem;
}
.game-detail-side {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,64 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { GameDetailDisplay } from './index';
describe('GameDetailDisplay', () => {
test('renders host-provided data and switches the hero through asset urls', async () => {
render(
<GameDetailDisplay
game={{
title: '星轨防线',
summary: '守住最后一条航线。',
description: '一款测试游戏。',
category: '动作',
tags: ['测试'],
author: { name: '陶泥儿' },
deviceSupport: { desktop: true, mobile: false, touch: false },
playCount: 1200,
}}
coverObjectKey="cover/alpha.png"
screenshotObjectKeys={['screenshot/one.png']}
assetUrls={{
'cover/alpha.png': 'https://assets.example.test/cover/alpha.png',
'screenshot/one.png': 'https://assets.example.test/screenshot/one.png',
}}
controlsLabel="键盘"
versionLabel="v2"
rating={<span>评分 9.1</span>}
actions={<button type="button">试玩当前待审版本</button>}
infoCards={[{ label: '发行包', value: '2.0 MiB' }]}
footnoteText="游戏运行在独立的安全容器中。"
/>,
);
expect(screen.getByRole('heading', { name: '星轨防线' })).toBeTruthy();
expect(screen.getByText('由 陶泥儿 制作')).toBeTruthy();
expect(screen.getByText(/次游玩$/u)).toBeTruthy();
expect(screen.getByText('评分 9.1')).toBeTruthy();
expect(
screen.getByRole('button', { name: '试玩当前待审版本' }),
).toBeTruthy();
expect(screen.getByText('键盘')).toBeTruthy();
expect(screen.getByText('v2')).toBeTruthy();
expect(screen.getByText('发行包').parentElement?.textContent).toBe(
'发行包2.0 MiB',
);
expect(screen.getByText('游戏运行在独立的安全容器中。')).toBeTruthy();
const heroImage = () =>
document.querySelector('.game-detail-cover img')?.getAttribute('src');
expect(heroImage()).toBe('https://assets.example.test/cover/alpha.png');
const thumbs = screen.getAllByRole('button', { name: /(查看大图)/u });
expect(thumbs).toHaveLength(2);
fireEvent.click(thumbs[1]!);
await waitFor(() =>
expect(heroImage()).toBe(
'https://assets.example.test/screenshot/one.png',
),
);
});
});
@@ -0,0 +1,295 @@
import './index.css';
import { Maximize2, Smartphone, Sparkles } from 'lucide-react';
import { useState, type ReactElement, type ReactNode } from 'react';
/** 详情展示里素材出现的位置:hero 大图与缩略图条。 */
export type GameDetailAssetRole = 'cover' | 'thumb';
export interface GameDetailAssetRenderInput {
/** 素材 objectKey,宿主据此换签。 */
objectKey: string;
role: GameDetailAssetRole;
/** 该素材当前是否为 hero 大图 / 已选中的缩略图。 */
active: boolean;
/** 素材名(封面、截图 1…),用于缩略图无障碍标签。 */
label: string;
}
export interface GameDetailShowcaseProps {
/** 封面 objectKey;为空时只展示彩色占位。 */
coverObjectKey?: string | null;
/** 截图 objectKey(不含封面),最多展示 6 张。 */
screenshotObjectKeys?: string[];
/** 占位渐变的起始色,缺省用平台默认橙色。 */
coverColor?: string;
/** 占位图标,缺省 ✦。 */
icon?: string;
/** objectKey → 已换签的展示地址;缺项按占位处理。 */
assetUrls?: Readonly<Record<string, string | null | undefined>>;
/**
* 覆盖默认的图片渲染:宿主可以用自己的取址钩子逐项渲染 `<img>`(后台则是已取到的地址)。
* 返回空节点即保留占位视觉;返回的图片无需类名,样式由展示层提供。
*/
renderAsset?: (input: GameDetailAssetRenderInput) => ReactElement | null;
}
export interface GameDetailDisplayInfoCard {
/** 资料卡标题,如「发行包」。 */
label: string;
value: ReactNode;
}
export interface GameDetailDisplayProps extends GameDetailShowcaseProps {
game: {
title: string;
summary: string;
description: string;
category: string;
tags: string[];
author: { name: string };
deviceSupport: {
desktop: boolean;
mobile: boolean;
touch: boolean;
};
/** 缺省时不显示游玩次数。 */
playCount?: number;
};
/** 「游玩方式」文案;缺省「未标注操作方式」。 */
controlsLabel?: string;
/** 「当前版本」文案;缺省 —。 */
versionLabel?: string;
/** 追加到资料侧栏的卡片(宿主自定义信息)。 */
infoCards?: GameDetailDisplayInfoCard[];
/** hero 里的评分摘要(公开详情传评分,后台可省略)。 */
rating?: ReactNode;
/** hero 里的主行动作区(公开详情是「立即玩」,后台是「试玩待审版本」)。 */
actions?: ReactNode;
/** hero 与正文之间的内容(作者作品状态、后台版本与审核信息等)。 */
children?: ReactNode;
/** 正文之后的脚注文案,带全屏提示图标;缺省不渲染。 */
footnoteText?: string;
className?: string;
}
const DEFAULT_COVER_COLOR = '#d77a51';
const DEFAULT_COVER_ICON = '✦';
const MAX_SCREENSHOTS = 6;
/**
* 素材渲染:宿主给了 `renderAsset` 就交给宿主(公开详情按 objectKey 逐个换签),
* 否则用宿主传入的 `assetUrls` 直接渲染 `<img>`;没有地址时返回空节点,由展示层占位兜底。
*/
function GameDetailAsset({
objectKey,
role,
active,
label,
assetUrls,
renderAsset,
}: GameDetailAssetRenderInput & {
assetUrls?: GameDetailShowcaseProps['assetUrls'];
renderAsset?: GameDetailShowcaseProps['renderAsset'];
}): ReactElement | null {
if (renderAsset) return renderAsset({ objectKey, role, active, label });
const url = (assetUrls?.[objectKey] ?? '').trim();
if (!url) return null;
return (
<img
src={url}
alt=""
loading={role === 'cover' ? 'eager' : 'lazy'}
decoding="async"
/>
);
}
/**
* 封面区:默认展示封面,存在截图时在下方给出可点击的缩略图条。
*
* 缩略图第一项固定是封面本身,方便从截图切回封面;只有封面时整条不渲染。
* 素材地址由宿主提供(`assetUrls` 或 `renderAsset`),组件自身不取数。
*/
export function GameDetailShowcase({
coverObjectKey,
screenshotObjectKeys,
coverColor,
icon,
assetUrls,
renderAsset,
}: GameDetailShowcaseProps) {
const coverKey = (coverObjectKey ?? '').trim();
const screenshotKeys = (screenshotObjectKeys ?? [])
.map((key) => key.trim())
.filter(Boolean)
.slice(0, MAX_SCREENSHOTS);
const items = [
...(coverKey ? [{ key: coverKey, label: '封面' }] : []),
...screenshotKeys.map((key, index) => ({
key,
label: `截图 ${index + 1}`,
})),
];
const [activeIndex, setActiveIndex] = useState(0);
const activeKey = items[activeIndex]?.key ?? coverKey;
return (
<div className="game-detail-showcase">
<div
className="game-detail-cover"
style={{
background: `linear-gradient(145deg, ${coverColor ?? DEFAULT_COVER_COLOR}, #16151c)`,
}}
aria-hidden="true"
>
<span className="game-detail-cover__glow" />
<span className="game-detail-cover__icon">
{icon ?? DEFAULT_COVER_ICON}
</span>
<span className="game-detail-cover__grid" />
<GameDetailAsset
objectKey={activeKey}
role="cover"
active
label="封面"
assetUrls={assetUrls}
renderAsset={renderAsset}
/>
</div>
{items.length > 1 ? (
<div className="game-detail-thumbs" role="group" aria-label="游戏截图">
{items.map((item, index) => (
<button
key={item.key}
type="button"
className="game-detail-thumb"
aria-pressed={index === activeIndex}
aria-label={`${item.label}(查看大图)`}
onClick={() => setActiveIndex(index)}
>
<span className="game-detail-thumb__placeholder" />
<GameDetailAsset
objectKey={item.key}
role="thumb"
active={index === activeIndex}
label={item.label}
assetUrls={assetUrls}
renderAsset={renderAsset}
/>
</button>
))}
</div>
) : null}
</div>
);
}
/**
* 游戏详情展示:hero、封面区、正文与资料侧栏。
*
* 纯受控展示组件——游戏数据、素材地址、评分、主行动作与附加区块全部由宿主传入,
* 组件不加载数据、不鉴权、不接管游玩或评价逻辑,公开详情与后台审核详情共用同一套视觉。
*/
export function GameDetailDisplay({
game,
coverObjectKey,
screenshotObjectKeys,
coverColor,
icon,
assetUrls,
renderAsset,
controlsLabel,
versionLabel,
infoCards,
rating,
actions,
children,
footnoteText,
className,
}: GameDetailDisplayProps) {
const controls = (controlsLabel ?? '').trim();
const version = (versionLabel ?? '').trim();
return (
<div
className={['game-detail-display', className].filter(Boolean).join(' ')}
>
<section className="game-detail-hero">
<GameDetailShowcase
coverObjectKey={coverObjectKey}
screenshotObjectKeys={screenshotObjectKeys}
coverColor={coverColor}
icon={icon}
assetUrls={assetUrls}
renderAsset={renderAsset}
/>
<div className="game-detail-hero__copy">
<span className="game-detail-eyebrow">
<Sparkles aria-hidden="true" /> {game.category}
</span>
<h1>{game.title}</h1>
<p className="game-detail-hero__summary">{game.summary}</p>
{rating ? (
<div className="game-detail-hero__rating">{rating}</div>
) : null}
<div className="game-detail-hero__author">
<span className="game-detail-avatar">
{game.author.name.slice(0, 1)}
</span>
<span>由 {game.author.name} 制作</span>
</div>
<div className="game-detail-hero__actions">
{actions}
{typeof game.playCount === 'number' ? (
<span className="game-detail-hero__play-count">
{game.playCount.toLocaleString()} 次游玩
</span>
) : null}
</div>
</div>
</section>
{children}
<section className="game-detail-body">
<div className="game-detail-main">
<h2>关于这款游戏</h2>
<p>{game.description}</p>
<div className="game-detail-tag-list">
{game.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</div>
<aside className="game-detail-side">
<div className="game-detail-info-card">
<strong>游玩方式</strong>
<span>{controls || '未标注操作方式'}</span>
</div>
<div className="game-detail-info-card">
<strong>设备支持</strong>
<span>
<Smartphone aria-hidden="true" />{' '}
{game.deviceSupport.mobile ? '移动端' : ''}
{game.deviceSupport.desktop ? ' · 桌面端' : ''}
{game.deviceSupport.touch ? ' · 支持触屏' : ''}
</span>
</div>
<div className="game-detail-info-card">
<strong>当前版本</strong>
<span>{version || '—'}</span>
</div>
{(infoCards ?? []).map((card) => (
<div className="game-detail-info-card" key={card.label}>
<strong>{card.label}</strong>
<span>{card.value}</span>
</div>
))}
</aside>
</section>
{footnoteText ? (
<p className="game-detail-footnote">
<Maximize2 aria-hidden="true" /> {footnoteText}
</p>
) : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More