补齐游戏分发封面与截图的冻结、上传与展示
- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。 - 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。 - 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。 - 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。 - 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。 - AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。 - AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。 - 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
{ "url": "https://www.genarrative.world/api/*" },
|
||||
{ "url": "https://*/api/*" },
|
||||
{ "url": "http://localhost:*/*" },
|
||||
{ "url": "http://127.0.0.1:*/*" }
|
||||
{ "url": "http://127.0.0.1:*/*" },
|
||||
{ "url": "https://*.aliyuncs.com/*" }
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
|
||||
+280
-2
@@ -3,14 +3,64 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectExportPackageResult } from '../../app/types';
|
||||
import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload';
|
||||
import {
|
||||
createGameDistributionPublishKey,
|
||||
type GameDistributionPublishMetadata,
|
||||
type GameDistributionPublishResult,
|
||||
MAX_AGC_GAME_SCREENSHOTS,
|
||||
publishLocalProjectGame,
|
||||
} from '../../services/gameDistributionPublish';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
|
||||
/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */
|
||||
const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024;
|
||||
const PANEL_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
type PanelImageKind = 'cover' | 'screenshot';
|
||||
|
||||
type PanelImageAsset = {
|
||||
/** 文件签名;同一文件重复选择时复用已上传素材,不重复直传。 */
|
||||
signature: string;
|
||||
name: string;
|
||||
assetObjectId: string;
|
||||
previewUrl: string;
|
||||
};
|
||||
|
||||
function resolvePanelImageLabel(kind: PanelImageKind) {
|
||||
return kind === 'cover' ? '游戏封面' : '游戏截图';
|
||||
}
|
||||
|
||||
/** 本地预检;服务端仍会独立校验素材归属与图片类型。 */
|
||||
function resolvePanelImageFileError(file: File, kind: PanelImageKind) {
|
||||
const label = resolvePanelImageLabel(kind);
|
||||
if (file.size <= 0) return `${label}文件为空,请重新选择`;
|
||||
if (file.size > PANEL_IMAGE_MAX_BYTES) {
|
||||
return `${label}过大,请压缩后再上传(最多 6MB)`;
|
||||
}
|
||||
const contentType = file.type.trim();
|
||||
if (contentType && !contentType.startsWith('image/')) {
|
||||
return `${label}必须是图片文件`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildPanelImageSignature(file: File, kind: PanelImageKind) {
|
||||
return `${kind}:${file.name}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
|
||||
/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */
|
||||
function buildPanelImagePreviewUrl(file: File) {
|
||||
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return URL.createObjectURL(file);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
'休闲',
|
||||
'益智',
|
||||
@@ -45,7 +95,15 @@ export function GameDistributionPublishPanel({
|
||||
const [result, setResult] = useState<GameDistributionPublishResult | null>(
|
||||
null,
|
||||
);
|
||||
const [cover, setCover] = useState<PanelImageAsset | null>(null);
|
||||
const [screenshots, setScreenshots] = useState<PanelImageAsset[]>([]);
|
||||
const [uploadingLabel, setUploadingLabel] = useState('');
|
||||
const publishIdempotencyKeyRef = useRef('');
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
const screenshotInputRef = useRef<HTMLInputElement>(null);
|
||||
// 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。
|
||||
const uploadedAssetCacheRef = useRef(new Map<string, string>());
|
||||
const objectUrlsRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -65,6 +123,121 @@ export function GameDistributionPublishPanel({
|
||||
}
|
||||
}, [open, packageResult?.packageRelativePath]);
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrls = objectUrlsRef.current;
|
||||
return () => {
|
||||
objectUrls.forEach((url) => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// 预览地址释放失败不影响关闭面板。
|
||||
}
|
||||
});
|
||||
objectUrls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function resolveAssetObjectId(file: File, kind: PanelImageKind) {
|
||||
const signature = buildPanelImageSignature(file, kind);
|
||||
const cached = uploadedAssetCacheRef.current.get(signature);
|
||||
if (cached) return cached;
|
||||
const uploaded = await uploadPlatformMediaAsset({
|
||||
file,
|
||||
assetKind:
|
||||
kind === 'cover'
|
||||
? 'game_distribution_cover'
|
||||
: 'game_distribution_screenshot',
|
||||
pathSegments: ['game-distribution', kind, `${Date.now()}`],
|
||||
entityId: `game-distribution-${kind}`,
|
||||
metadata: { game_distribution_media: kind },
|
||||
});
|
||||
uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId);
|
||||
return uploaded.assetObjectId;
|
||||
}
|
||||
|
||||
function buildPanelImageAsset(
|
||||
file: File,
|
||||
kind: PanelImageKind,
|
||||
assetObjectId: string,
|
||||
) {
|
||||
const previewUrl = buildPanelImagePreviewUrl(file);
|
||||
if (previewUrl) objectUrlsRef.current.add(previewUrl);
|
||||
return {
|
||||
signature: buildPanelImageSignature(file, kind),
|
||||
name: file.name.trim() || resolvePanelImageLabel(kind),
|
||||
assetObjectId,
|
||||
previewUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCoverSelected(file: File | null) {
|
||||
if (!file) return;
|
||||
setError('');
|
||||
const fileError = resolvePanelImageFileError(file, 'cover');
|
||||
if (fileError) {
|
||||
setError(fileError);
|
||||
return;
|
||||
}
|
||||
setUploadingLabel('正在上传封面…');
|
||||
try {
|
||||
const assetObjectId = await resolveAssetObjectId(file, 'cover');
|
||||
setCover(buildPanelImageAsset(file, 'cover', assetObjectId));
|
||||
} catch (uploadError) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '封面上传失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
setUploadingLabel('');
|
||||
if (coverInputRef.current) coverInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScreenshotsSelected(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
const remaining = MAX_AGC_GAME_SCREENSHOTS - screenshots.length;
|
||||
if (files.length > remaining) {
|
||||
setError(
|
||||
remaining > 0
|
||||
? `游戏截图最多 6 张,还可以再选 ${remaining} 张`
|
||||
: '游戏截图最多 6 张',
|
||||
);
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setUploadingLabel('正在上传截图…');
|
||||
try {
|
||||
for (const file of files) {
|
||||
const fileError = resolvePanelImageFileError(file, 'screenshot');
|
||||
if (fileError) {
|
||||
setError(fileError);
|
||||
return;
|
||||
}
|
||||
const assetObjectId = await resolveAssetObjectId(file, 'screenshot');
|
||||
// 逐张入库:中途失败时已传好的截图保留,作者不用重新选择。
|
||||
setScreenshots((current) =>
|
||||
current.length >= MAX_AGC_GAME_SCREENSHOTS
|
||||
? current
|
||||
: [
|
||||
...current,
|
||||
buildPanelImageAsset(file, 'screenshot', assetObjectId),
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (uploadError) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '截图上传失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
setUploadingLabel('');
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!packageResult || !projectPath.trim()) {
|
||||
setError('请先导出有效的试玩包');
|
||||
@@ -75,6 +248,14 @@ export function GameDistributionPublishPanel({
|
||||
setError('需要在 Tauri App 内发布');
|
||||
return;
|
||||
}
|
||||
if (!cover) {
|
||||
setError('请先选择游戏封面(JPG/PNG/WebP)');
|
||||
return;
|
||||
}
|
||||
if (uploadingLabel) {
|
||||
setError('素材还在上传中,请稍候再发布');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -83,7 +264,13 @@ export function GameDistributionPublishPanel({
|
||||
projectPath,
|
||||
packageRelativePath: packageResult.packageRelativePath,
|
||||
manifest,
|
||||
metadata: { title, summary, category },
|
||||
metadata: {
|
||||
title,
|
||||
summary,
|
||||
category,
|
||||
coverAssetId: cover.assetObjectId,
|
||||
screenshots: screenshots.map((item) => item.assetObjectId),
|
||||
},
|
||||
idempotencyKey: publishIdempotencyKeyRef.current,
|
||||
});
|
||||
setResult(next);
|
||||
@@ -190,6 +377,97 @@ export function GameDistributionPublishPanel({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="game-distribution-publish-panel__media"
|
||||
aria-label="封面与截图"
|
||||
>
|
||||
<div className="game-distribution-publish-panel__cover">
|
||||
<div className="game-distribution-publish-panel__cover-preview">
|
||||
{cover?.previewUrl ? (
|
||||
<img src={cover.previewUrl} alt="游戏封面预览" />
|
||||
) : (
|
||||
<span>{cover ? '封面已上传' : '还没有封面'}</span>
|
||||
)}
|
||||
</div>
|
||||
<label>
|
||||
游戏封面(必需)
|
||||
<input
|
||||
ref={coverInputRef}
|
||||
type="file"
|
||||
accept={PANEL_IMAGE_ACCEPT}
|
||||
onChange={(event) => {
|
||||
void handleCoverSelected(event.target.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
<span className="game-distribution-publish-panel__hint">
|
||||
{cover
|
||||
? `已选择「${cover.name}」;重新选择即可替换封面。`
|
||||
: '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
|
||||
</span>
|
||||
</label>
|
||||
{cover ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCover(null)}
|
||||
disabled={busy}
|
||||
>
|
||||
移除封面
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<label>
|
||||
{`游戏截图(可选,最多 ${MAX_AGC_GAME_SCREENSHOTS} 张)`}
|
||||
<input
|
||||
ref={screenshotInputRef}
|
||||
type="file"
|
||||
accept={PANEL_IMAGE_ACCEPT}
|
||||
multiple
|
||||
disabled={
|
||||
busy || screenshots.length >= MAX_AGC_GAME_SCREENSHOTS
|
||||
}
|
||||
onChange={(event) => {
|
||||
void handleScreenshotsSelected(
|
||||
Array.from(event.target.files ?? []),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="game-distribution-publish-panel__hint">
|
||||
截图会按选择顺序展示在游戏详情页;不需要可以留空。
|
||||
</span>
|
||||
</label>
|
||||
{screenshots.length > 0 ? (
|
||||
<ul className="game-distribution-publish-panel__shots">
|
||||
{screenshots.map((item, index) => (
|
||||
<li key={item.signature}>
|
||||
{item.previewUrl ? (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt={`截图 ${index + 1} 预览`}
|
||||
/>
|
||||
) : (
|
||||
<span>{`截图 ${index + 1}`}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setScreenshots((current) =>
|
||||
current.filter((_, itemIndex) => itemIndex !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
{`移除截图 ${index + 1}`}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{uploadingLabel ? (
|
||||
<span className="game-distribution-publish-panel__hint">
|
||||
{uploadingLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="game-distribution-publish-panel__error" role="alert">
|
||||
{error}
|
||||
@@ -206,7 +484,7 @@ export function GameDistributionPublishPanel({
|
||||
busy || !title.trim() || !summary.trim() || !packageResult
|
||||
}
|
||||
>
|
||||
{busy ? '上传中…' : '发布游戏'}
|
||||
{busy ? '上传中…' : uploadingLabel ? '素材上传中…' : '发布游戏'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
import { requestClientApi } from './clientApi';
|
||||
|
||||
/** 直传凭证里的对象存储目标;字段口径与平台 `/api/assets/direct-upload-tickets` 一致。 */
|
||||
type DirectUploadTicketResponse = {
|
||||
upload: {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfirmAssetObjectResponse = {
|
||||
assetObject: {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
assetKind: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UploadedPlatformAsset = {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
};
|
||||
|
||||
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* 平台直传凭证支持的 OSS 主机白名单前缀。
|
||||
*
|
||||
* Tauri 的 http 插件只允许访问 `capabilities/main.json` 里声明的地址,这里再做一次校验,
|
||||
* 保证即使能力配置放宽到 `*.aliyuncs.com`,客户端也只把文件发到平台素材存储,而不是任意主机。
|
||||
*/
|
||||
const PLATFORM_UPLOAD_HOST_SUFFIXES = ['.aliyuncs.com'];
|
||||
|
||||
function isLocalUploadHost(parsed: URL) {
|
||||
return (
|
||||
parsed.protocol === 'http:' &&
|
||||
(parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost')
|
||||
);
|
||||
}
|
||||
|
||||
/** 校验直传地址;不是平台素材存储时直接失败关闭,避免把本地文件发给第三方主机。 */
|
||||
export function resolvePlatformAssetUploadUrl(host: string) {
|
||||
const trimmedHost = host.trim();
|
||||
if (!trimmedHost) {
|
||||
throw new Error('素材上传地址为空,请稍后重试');
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmedHost);
|
||||
} catch {
|
||||
throw new Error('素材上传地址无效,请稍后重试');
|
||||
}
|
||||
const isOssHost =
|
||||
parsed.protocol === 'https:' &&
|
||||
PLATFORM_UPLOAD_HOST_SUFFIXES.some((suffix) =>
|
||||
parsed.hostname.endsWith(suffix),
|
||||
);
|
||||
if (!isOssHost && !isLocalUploadHost(parsed)) {
|
||||
throw new Error('素材上传地址不属于平台素材存储,已终止上传');
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function buildDirectUploadFormData(
|
||||
upload: DirectUploadTicketResponse['upload'],
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(upload.formFields ?? {}).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
// OSS 要求 file 字段位于表单末尾,否则签名校验会失败。
|
||||
formData.append('file', file, file.name);
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本地文件上传成平台素材对象并返回素材标识。
|
||||
*
|
||||
* 三步与网页端一致:申请直传凭证 → 直传对象存储 → confirm 登记素材。AGC 无法在浏览器里
|
||||
* 直接跨域直传 OSS,所以直传这一步固定走 Tauri HTTP 插件(Rust 侧发起请求)。
|
||||
*/
|
||||
export async function uploadPlatformMediaAsset(args: {
|
||||
file: File;
|
||||
assetKind: string;
|
||||
pathSegments: string[];
|
||||
entityId: string;
|
||||
metadata?: Record<string, string>;
|
||||
/** 单测注入的直传实现;正式运行时固定使用 Tauri HTTP。 */
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<UploadedPlatformAsset> {
|
||||
const fileName = args.file.name.trim() || 'cover.png';
|
||||
const contentType = args.file.type.trim() || 'application/octet-stream';
|
||||
const ticket = await requestClientApi<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: args.pathSegments,
|
||||
fileName,
|
||||
contentType,
|
||||
access: 'private',
|
||||
maxSizeBytes: args.file.size,
|
||||
metadata: {
|
||||
asset_kind: args.assetKind,
|
||||
...args.metadata,
|
||||
},
|
||||
}),
|
||||
},
|
||||
'创建素材上传凭证失败',
|
||||
);
|
||||
|
||||
const uploadHost = resolvePlatformAssetUploadUrl(ticket.upload.host);
|
||||
const uploadFetch = args.fetchImpl ?? tauriHttpFetch;
|
||||
const uploadResponse = await uploadFetch(uploadHost, {
|
||||
method: 'POST',
|
||||
body: buildDirectUploadFormData(ticket.upload, args.file),
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(
|
||||
`上传素材到对象存储失败(HTTP ${uploadResponse.status}),请重试`,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmed = await requestClientApi<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bucket: ticket.upload.bucket,
|
||||
objectKey: ticket.upload.objectKey,
|
||||
contentType,
|
||||
contentLength: args.file.size,
|
||||
assetKind: args.assetKind,
|
||||
accessPolicy: 'private',
|
||||
entityId: args.entityId,
|
||||
}),
|
||||
},
|
||||
'确认素材资产失败',
|
||||
);
|
||||
return {
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export type GameDistributionPublishMetadata = {
|
||||
description: string;
|
||||
category: GameDistributionCategory;
|
||||
tags: string[];
|
||||
/** 平台素材库里的封面素材 ID;服务端要求发布必须带封面。 */
|
||||
coverAssetId: string;
|
||||
/** 平台素材库里的截图素材 ID,最多 6 张。 */
|
||||
screenshots?: string[];
|
||||
deviceSupport: {
|
||||
desktop: boolean;
|
||||
mobile: boolean;
|
||||
@@ -27,6 +31,9 @@ export type GameDistributionPublishMetadata = {
|
||||
orientation: GameDistributionOrientation;
|
||||
};
|
||||
|
||||
/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
|
||||
export const MAX_AGC_GAME_SCREENSHOTS = 6;
|
||||
|
||||
export type GameDistributionPublishResult = {
|
||||
gameId: string;
|
||||
versionId: string;
|
||||
@@ -69,12 +76,25 @@ function normalizeMetadata(
|
||||
if (!summary || summary.length > 120) {
|
||||
throw new Error('游戏简介必须为 1 到 120 个字符');
|
||||
}
|
||||
const coverAssetId = (metadata?.coverAssetId ?? '').trim();
|
||||
if (!coverAssetId) {
|
||||
// 服务端会拒绝没有封面的发布;在创建游戏前失败关闭,避免留下无资料的半成品。
|
||||
throw new Error('请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场');
|
||||
}
|
||||
const screenshots = (metadata?.screenshots ?? [])
|
||||
.map((screenshot) => screenshot.trim())
|
||||
.filter(Boolean);
|
||||
if (screenshots.length > MAX_AGC_GAME_SCREENSHOTS) {
|
||||
throw new Error('游戏截图最多 6 张');
|
||||
}
|
||||
return {
|
||||
title,
|
||||
summary,
|
||||
description: (metadata?.description ?? summary).trim().slice(0, 2_000),
|
||||
category: metadata?.category ?? '其他',
|
||||
tags: metadata?.tags ?? [],
|
||||
coverAssetId,
|
||||
screenshots,
|
||||
deviceSupport: metadata?.deviceSupport ?? {
|
||||
desktop: true,
|
||||
mobile: true,
|
||||
@@ -96,6 +116,8 @@ function toCreateGameRequest(
|
||||
description: metadata.description,
|
||||
category: metadata.category,
|
||||
tags: metadata.tags,
|
||||
coverAssetId: metadata.coverAssetId,
|
||||
screenshots: metadata.screenshots ?? [],
|
||||
deviceSupport: metadata.deviceSupport,
|
||||
inputModes: metadata.inputModes,
|
||||
orientation: metadata.orientation,
|
||||
|
||||
@@ -3878,6 +3878,131 @@ textarea {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed rgb(104 77 57 / 18%);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media > label,
|
||||
.game-distribution-publish-panel__cover label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__media input[type='file'] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
color: var(--platform-text-strong);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 168px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-preview {
|
||||
display: grid;
|
||||
aspect-ratio: 16 / 9;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 12px;
|
||||
background: rgb(168 102 61 / 8%);
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__hint {
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover > button,
|
||||
.game-distribution-publish-panel__shots button {
|
||||
justify-self: start;
|
||||
width: fit-content;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 移除封面按钮固定落在文字列,避免占掉预览列的第二行。 */
|
||||
.game-distribution-publish-panel__cover > button {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots li {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots img,
|
||||
.game-distribution-publish-panel__shots li > span {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots li > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-style: dashed;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.game-distribution-publish-panel__cover {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover > button {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__error {
|
||||
margin: 14px 0 0;
|
||||
color: #b42318;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AGC 素材直传(封面 / 截图)的三步链路边界。
|
||||
*
|
||||
* 这里只替换平台 API 调用与对象存储直传实现,验证「凭证 → 直传 → confirm」的字段口径、
|
||||
* 地址白名单与错误文案,不触达真实 Tauri HTTP 插件或 OSS。
|
||||
*/
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestClientApiMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../src/services/clientApi', () => ({
|
||||
requestClientApi: (...args: unknown[]) => requestClientApiMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
resolvePlatformAssetUploadUrl,
|
||||
uploadPlatformMediaAsset,
|
||||
} from '../src/services/assetDirectUpload';
|
||||
|
||||
const TICKET = {
|
||||
upload: {
|
||||
bucket: 'genarrative-assets',
|
||||
host: 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/',
|
||||
objectKey:
|
||||
'generated-character-drafts/game-distribution/cover/42/cover.png',
|
||||
legacyPublicPath: '/generated-character-drafts/game-distribution/cover/42',
|
||||
formFields: {
|
||||
key: 'generated-character-drafts/game-distribution/cover/42/cover.png',
|
||||
policy: 'policy-value',
|
||||
OSSAccessKeyId: 'ak-value',
|
||||
signature: 'signature-value',
|
||||
success_action_status: '204',
|
||||
'x-oss-meta-asset_kind': 'game_distribution_cover',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CONFIRMED = {
|
||||
assetObject: {
|
||||
assetObjectId: 'asset_cover_1',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
assetKind: 'game_distribution_cover',
|
||||
},
|
||||
};
|
||||
|
||||
function buildCoverFile() {
|
||||
return new File(['cover-bytes'], 'cover.png', { type: 'image/png' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestClientApiMock.mockReset();
|
||||
requestClientApiMock
|
||||
.mockResolvedValueOnce(TICKET)
|
||||
.mockResolvedValueOnce(CONFIRMED);
|
||||
});
|
||||
|
||||
test('封面按凭证、直传、confirm 三步上传并返回素材标识', async () => {
|
||||
const uploadFetch = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const file = buildCoverFile();
|
||||
|
||||
const uploaded = await uploadPlatformMediaAsset({
|
||||
file,
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
entityId: 'game-distribution-cover',
|
||||
metadata: { game_distribution_media: 'cover' },
|
||||
fetchImpl: uploadFetch,
|
||||
});
|
||||
|
||||
expect(uploaded).toEqual({
|
||||
assetObjectId: 'asset_cover_1',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
});
|
||||
|
||||
const ticketCall = requestClientApiMock.mock.calls[0];
|
||||
expect(ticketCall?.[0]).toBe('/api/assets/direct-upload-tickets');
|
||||
const ticketBody = JSON.parse(String((ticketCall?.[1] as RequestInit).body));
|
||||
expect(ticketBody).toEqual(
|
||||
expect.objectContaining({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
fileName: 'cover.png',
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: file.size,
|
||||
metadata: {
|
||||
asset_kind: 'game_distribution_cover',
|
||||
game_distribution_media: 'cover',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadCall = uploadFetch.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(uploadCall[0]).toBe(TICKET.upload.host);
|
||||
expect(uploadCall[1].method).toBe('POST');
|
||||
const formData = uploadCall[1].body as FormData;
|
||||
expect(formData.get('policy')).toBe('policy-value');
|
||||
expect(formData.get('signature')).toBe('signature-value');
|
||||
expect(formData.get('success_action_status')).toBe('204');
|
||||
const uploadedFile = formData.get('file');
|
||||
expect(uploadedFile).toBeInstanceOf(File);
|
||||
expect((uploadedFile as File).name).toBe('cover.png');
|
||||
|
||||
const confirmCall = requestClientApiMock.mock.calls[1];
|
||||
expect(confirmCall?.[0]).toBe('/api/assets/objects/confirm');
|
||||
expect(JSON.parse(String((confirmCall?.[1] as RequestInit).body))).toEqual({
|
||||
bucket: 'genarrative-assets',
|
||||
objectKey: TICKET.upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: file.size,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-cover',
|
||||
});
|
||||
});
|
||||
|
||||
test('直传地址不属于平台素材存储时失败关闭,且不发送文件', async () => {
|
||||
requestClientApiMock.mockReset();
|
||||
requestClientApiMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: { ...TICKET.upload, host: 'https://evil.example.com/upload' },
|
||||
})
|
||||
.mockResolvedValueOnce(CONFIRMED);
|
||||
const uploadFetch = vi.fn();
|
||||
|
||||
await expect(
|
||||
uploadPlatformMediaAsset({
|
||||
file: buildCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', '42'],
|
||||
entityId: 'game-distribution-cover',
|
||||
fetchImpl: uploadFetch as never,
|
||||
}),
|
||||
).rejects.toThrow('素材上传地址不属于平台素材存储,已终止上传');
|
||||
|
||||
expect(uploadFetch).not.toHaveBeenCalled();
|
||||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('对象存储拒绝上传时给出可重试文案', async () => {
|
||||
const uploadFetch = vi.fn(
|
||||
async () => new Response('denied', { status: 403 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uploadPlatformMediaAsset({
|
||||
file: buildCoverFile(),
|
||||
assetKind: 'game_distribution_screenshot',
|
||||
pathSegments: ['game-distribution', 'screenshot', '42'],
|
||||
entityId: 'game-distribution-screenshot',
|
||||
fetchImpl: uploadFetch,
|
||||
}),
|
||||
).rejects.toThrow('上传素材到对象存储失败(HTTP 403),请重试');
|
||||
|
||||
// 直传失败时不得再登记素材,避免留下没有实体的素材记录。
|
||||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('本地回环与阿里云 OSS 之外的主机一律拒绝', () => {
|
||||
expect(resolvePlatformAssetUploadUrl('http://127.0.0.1:9000/bucket')).toBe(
|
||||
'http://127.0.0.1:9000/bucket',
|
||||
);
|
||||
expect(
|
||||
resolvePlatformAssetUploadUrl(
|
||||
'https://genarrative-assets.oss-cn-beijing.aliyuncs.com/',
|
||||
),
|
||||
).toBe('https://genarrative-assets.oss-cn-beijing.aliyuncs.com/');
|
||||
expect(() =>
|
||||
resolvePlatformAssetUploadUrl('http://oss.example.com/'),
|
||||
).toThrow('素材上传地址不属于平台素材存储,已终止上传');
|
||||
expect(() => resolvePlatformAssetUploadUrl(' ')).toThrow(
|
||||
'素材上传地址为空,请稍后重试',
|
||||
);
|
||||
});
|
||||
@@ -77,6 +77,10 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: {
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: ['asset_shot_1', 'asset_shot_2'],
|
||||
},
|
||||
});
|
||||
|
||||
const createGameCall = fetchClientHttp.mock.calls[0];
|
||||
@@ -85,6 +89,8 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
|
||||
String((createGameCall?.[1] as RequestInit).body),
|
||||
);
|
||||
expect(createGameBody.localProjectId).toBe('local-proj-1');
|
||||
expect(createGameBody.coverAssetId).toBe('asset_cover');
|
||||
expect(createGameBody.screenshots).toEqual(['asset_shot_1', 'asset_shot_2']);
|
||||
|
||||
const createVersionCall = fetchClientHttp.mock.calls[1];
|
||||
expect(createVersionCall?.[0]).toBe(
|
||||
@@ -112,7 +118,49 @@ test('缺少本地项目标识时在发起请求前失败关闭', async () => {
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
}),
|
||||
).rejects.toThrow('发布需要本地项目标识');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('缺少封面时在创建游戏前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: ' ' },
|
||||
}),
|
||||
).rejects.toThrow('请先选择游戏封面');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('截图超过 6 张时在创建游戏前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: {
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: Array.from({ length: 7 }, (_, index) => `asset_${index}`),
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('游戏截图最多 6 张');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -17,9 +17,21 @@ import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { LocalProjectExportPackagePayload } from '../src/app/types';
|
||||
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
|
||||
/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */
|
||||
const LIVE_COVER_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
function buildLiveCoverFile() {
|
||||
const bytes = Buffer.from(LIVE_COVER_PNG_BASE64, 'base64');
|
||||
return new File([new Uint8Array(bytes)], 'agc-live-cover.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, '');
|
||||
@@ -154,6 +166,16 @@ liveTest(
|
||||
goal: '验证 AGC 一键发布链路',
|
||||
} as unknown as GameCreationAppManifest;
|
||||
const invoke = vi.fn(async () => payload);
|
||||
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。
|
||||
const uploadedCover = await uploadPlatformMediaAsset({
|
||||
file: buildLiveCoverFile(),
|
||||
assetKind: 'game_distribution_cover',
|
||||
pathSegments: ['game-distribution', 'cover', stamp],
|
||||
entityId: 'game-distribution-cover',
|
||||
// jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。
|
||||
fetchImpl: (input, init) => realFetch(apiUrl(input), init),
|
||||
});
|
||||
expect(uploadedCover.assetObjectId).toMatch(/\S/u);
|
||||
const metadata = {
|
||||
summary: '由 AGC 发布函数真实提交',
|
||||
description:
|
||||
@@ -163,6 +185,8 @@ liveTest(
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'] as const,
|
||||
orientation: 'landscape' as const,
|
||||
coverAssetId: uploadedCover.assetObjectId,
|
||||
screenshots: [] as string[],
|
||||
};
|
||||
|
||||
const first = await publishLocalProjectGame({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { LocalProjectExportPackageResult } from '../src/app/types';
|
||||
import { GameDistributionPublishPanel } from '../src/components/game-distribution/GameDistributionPublishPanel';
|
||||
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
|
||||
vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
@@ -27,6 +28,11 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
return { ...actual, publishLocalProjectGame: vi.fn() };
|
||||
});
|
||||
|
||||
// 面板只负责选图与调用上传;这里替换掉真实直传,避免测试触达 Tauri/OSS。
|
||||
vi.mock('../src/services/assetDirectUpload', () => ({
|
||||
uploadPlatformMediaAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
type TauriInvoke = (
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
@@ -57,6 +63,25 @@ const PACKAGE_RESULT = {
|
||||
totalBytes: 3,
|
||||
} as unknown as LocalProjectExportPackageResult;
|
||||
|
||||
function buildImageFile(name: string) {
|
||||
return new File(['cover-bytes'], name, { type: 'image/png' });
|
||||
}
|
||||
|
||||
/** 选择封面并等待上传完成;面板只有在素材拿到 ID 之后才允许发布。 */
|
||||
async function selectCover(
|
||||
file: File = buildImageFile('cover.png'),
|
||||
assetObjectId = 'asset_cover',
|
||||
) {
|
||||
vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({
|
||||
assetObjectId,
|
||||
objectKey: `game-distribution/cover/${file.name}`,
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await screen.findByText(new RegExp(`已选择「${file.name}」`, 'u'));
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
overrides: Partial<Parameters<typeof GameDistributionPublishPanel>[0]> = {},
|
||||
) {
|
||||
@@ -79,6 +104,7 @@ function renderPanel(
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(publishLocalProjectGame).mockReset();
|
||||
vi.mocked(uploadPlatformMediaAsset).mockReset();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
@@ -123,6 +149,15 @@ describe('GameDistributionPublishPanel', () => {
|
||||
fireEvent.change(screen.getByLabelText('分类'), {
|
||||
target: { value: '动作' },
|
||||
});
|
||||
await selectCover();
|
||||
vi.mocked(uploadPlatformMediaAsset).mockResolvedValueOnce({
|
||||
assetObjectId: 'asset_shot_1',
|
||||
objectKey: 'game-distribution/screenshot/shot-1.png',
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: { files: [buildImageFile('shot-1.png')] },
|
||||
});
|
||||
await screen.findByText('移除截图 1');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -137,6 +172,8 @@ describe('GameDistributionPublishPanel', () => {
|
||||
title: '星轨防线二',
|
||||
summary: '守住轨道城',
|
||||
category: '动作',
|
||||
coverAssetId: 'asset_cover',
|
||||
screenshots: ['asset_shot_1'],
|
||||
});
|
||||
expect(String(args?.idempotencyKey)).toMatch(/^agc-publish-/u);
|
||||
expect(invoke).toBeDefined();
|
||||
@@ -156,6 +193,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
}) as never,
|
||||
);
|
||||
renderPanel();
|
||||
await selectCover();
|
||||
|
||||
const submit = screen.getByRole('button', { name: '发布游戏' });
|
||||
fireEvent.click(submit);
|
||||
@@ -182,6 +220,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
new Error('上传游戏发行包失败:游戏分发服务暂不可用(503)'),
|
||||
);
|
||||
renderPanel();
|
||||
await selectCover();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
@@ -193,6 +232,77 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(screen.queryByText('已提交审核')).toBeNull();
|
||||
});
|
||||
|
||||
test('没有选择封面时不发起发布并给出可操作提示', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
|
||||
).not.toBeNull();
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('封面与截图都先上传成平台素材,重复提交复用同一素材 ID', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(publishLocalProjectGame).mockRejectedValue(new Error('先失败'));
|
||||
const coverFile = buildImageFile('cover.png');
|
||||
renderPanel();
|
||||
await selectCover(coverFile, 'asset_cover_cached');
|
||||
|
||||
expect(vi.mocked(uploadPlatformMediaAsset).mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
assetKind: 'game_distribution_cover',
|
||||
entityId: 'game-distribution-cover',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
await waitFor(() =>
|
||||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
// 再次选择同一个文件对象不应重新上传(面板按文件签名复用素材 ID)。
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [coverFile] },
|
||||
});
|
||||
await screen.findByText(/已选择「cover.png」/u);
|
||||
expect(uploadPlatformMediaAsset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('截图超过 6 张时本地拦截且不上传', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: {
|
||||
files: Array.from({ length: 7 }, (_, index) =>
|
||||
buildImageFile(`shot-${index}.png`),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/游戏截图最多 6 张/u)).not.toBeNull();
|
||||
expect(uploadPlatformMediaAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('素材上传失败时保留面板并展示原因', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(uploadPlatformMediaAsset).mockRejectedValueOnce(
|
||||
new Error('创建素材上传凭证失败'),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [buildImageFile('cover.png')] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText('创建素材上传凭证失败')).not.toBeNull();
|
||||
expect(screen.getByText('还没有封面')).not.toBeNull();
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
Reference in New Issue
Block a user