完善游戏审核试玩与客户端版本更新
后台审核详情复用公开游戏详情样式,补齐发布者、冻结资料、素材换签和待审 ZIP 试玩。 AGC 保存已发布作品绑定,支持同一 gameId 更新版本并由用户填写目标版本号。 保留 SpacetimeDB 表结构不变,增加版本号请求校验、幂等边界和本地清单字段。
This commit is contained in:
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台作品管理列表。
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
+296
-9
@@ -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 } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user