补齐游戏分发封面与截图的冻结、上传与展示
- 服务端:游戏表追加可空 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:
@@ -1,4 +1,5 @@
|
||||
import './gameDistribution.css';
|
||||
import './gameDistributionShowcase.css';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import {
|
||||
type GameDistributionGame,
|
||||
type GameDistributionInputMode,
|
||||
@@ -52,6 +54,77 @@ function resolveControlsLabel(
|
||||
return controls || '未标注操作方式';
|
||||
}
|
||||
|
||||
/** 缩略图自己换签,失败时退回空占位,不阻塞 hero 与主行动作。 */
|
||||
function GameShowcaseThumb({
|
||||
objectKey,
|
||||
label,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
objectKey: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(null, { objectKey });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="game-detail-thumb"
|
||||
aria-pressed={isActive}
|
||||
aria-label={label}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{resolvedUrl ? (
|
||||
<img src={resolvedUrl} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<span className="game-detail-thumb__placeholder" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页封面区:默认展示封面,存在截图时在下方给出可点击的缩略图条。
|
||||
*
|
||||
* 缩略图第一项固定是封面本身,方便作者/玩家从截图切回封面;只有封面时整条不渲染。
|
||||
*/
|
||||
function GameShowcase({ game }: { game: GameDistributionGame }) {
|
||||
const coverKey = (game.coverObjectKey ?? '').trim();
|
||||
const screenshotKeys = (game.screenshots ?? [])
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
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">
|
||||
<GameCover game={game} priority objectKey={activeKey} />
|
||||
{items.length > 1 ? (
|
||||
<div className="game-detail-thumbs" role="group" aria-label="游戏截图">
|
||||
{items.map((item, index) => (
|
||||
<GameShowcaseThumb
|
||||
key={item.key}
|
||||
objectKey={item.key}
|
||||
label={`${item.label}(查看大图)`}
|
||||
isActive={index === activeIndex}
|
||||
onSelect={() => setActiveIndex(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameDetailPage({
|
||||
gameId,
|
||||
onBack,
|
||||
@@ -144,7 +217,7 @@ export function GameDetailPage({
|
||||
<ArrowLeft /> 返回游戏广场
|
||||
</button>
|
||||
<section className="game-detail-hero">
|
||||
<GameCover game={game} />
|
||||
<GameShowcase game={game} />
|
||||
<div className="game-detail-hero__copy">
|
||||
<span className="game-eyebrow">
|
||||
<Sparkles aria-hidden="true" /> {game.category}
|
||||
|
||||
@@ -18,12 +18,28 @@ import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
|
||||
|
||||
const listGamesMock = vi.hoisted(() => vi.fn());
|
||||
const getGameMock = vi.hoisted(() => vi.fn());
|
||||
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
listGames: listGamesMock,
|
||||
getGame: getGameMock,
|
||||
}));
|
||||
|
||||
// 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (
|
||||
source: string | null | undefined,
|
||||
options: { objectKey?: string | null } = {},
|
||||
) => {
|
||||
const objectKey = (options.objectKey ?? '').trim();
|
||||
return {
|
||||
resolvedUrl: resolvedAssetUrlMock(objectKey || (source ?? '').trim()),
|
||||
isResolving: false,
|
||||
shouldResolve: Boolean(objectKey || (source ?? '').trim()),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -89,6 +105,9 @@ beforeEach(() => {
|
||||
setViewportWidth(1024);
|
||||
listGamesMock.mockResolvedValue([]);
|
||||
getGameMock.mockResolvedValue(createGame());
|
||||
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
|
||||
objectKey ? `https://assets.example.test/${objectKey}` : '',
|
||||
);
|
||||
});
|
||||
|
||||
describe('GameGalleryPage', () => {
|
||||
@@ -418,3 +437,114 @@ describe('GamePlayPage 启动超时与旋转提示', () => {
|
||||
expect(screen.queryByText(/旋转设备/u)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('游戏封面与截图展示', () => {
|
||||
function resetGalleryContext() {
|
||||
window.sessionStorage.clear();
|
||||
window.history.replaceState({}, '', '/games');
|
||||
}
|
||||
|
||||
it('广场卡片用签名地址展示真实封面', async () => {
|
||||
resetGalleryContext();
|
||||
listGamesMock.mockResolvedValue([
|
||||
createGame({ coverObjectKey: 'game-distribution/cover/alpha.png' }),
|
||||
]);
|
||||
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
|
||||
|
||||
const card = (await screen.findByText('星轨防线')).closest('button');
|
||||
const cover = card?.querySelector('img.game-cover__image');
|
||||
expect(cover?.getAttribute('src')).toBe(
|
||||
'https://assets.example.test/game-distribution/cover/alpha.png',
|
||||
);
|
||||
expect(cover?.getAttribute('loading')).toBe('lazy');
|
||||
});
|
||||
|
||||
it('没有封面或换签失败时保留占位而不渲染图片', async () => {
|
||||
resetGalleryContext();
|
||||
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
|
||||
objectKey && objectKey !== 'game-distribution/cover/broken.png'
|
||||
? `https://assets.example.test/${objectKey}`
|
||||
: '',
|
||||
);
|
||||
listGamesMock.mockResolvedValue([
|
||||
createGame({ id: 'game-none', title: '没有封面', coverObjectKey: null }),
|
||||
createGame({
|
||||
id: 'game-broken',
|
||||
title: '换签失败',
|
||||
coverObjectKey: 'game-distribution/cover/broken.png',
|
||||
}),
|
||||
]);
|
||||
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('没有封面')).toBeTruthy();
|
||||
expect(screen.getByText('换签失败')).toBeTruthy();
|
||||
expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(0);
|
||||
expect(document.querySelectorAll('.game-cover__icon')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('详情页展示封面与截图缩略图,并可切换 hero', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
coverObjectKey: 'game-distribution/cover/alpha.png',
|
||||
screenshots: [
|
||||
'game-distribution/screenshot/one.png',
|
||||
'game-distribution/screenshot/two.png',
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('heading', { name: '星轨防线' });
|
||||
const heroImage = () =>
|
||||
document
|
||||
.querySelector('.game-detail-showcase img.game-cover__image')
|
||||
?.getAttribute('src');
|
||||
expect(heroImage()).toBe(
|
||||
'https://assets.example.test/game-distribution/cover/alpha.png',
|
||||
);
|
||||
|
||||
const thumbs = screen.getAllByRole('button', { name: /(查看大图)/u });
|
||||
expect(thumbs).toHaveLength(3);
|
||||
expect(thumbs[0]?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(thumbs[2]?.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
fireEvent.click(thumbs[2]!);
|
||||
await waitFor(() =>
|
||||
expect(heroImage()).toBe(
|
||||
'https://assets.example.test/game-distribution/screenshot/two.png',
|
||||
),
|
||||
);
|
||||
expect(thumbs[2]?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(thumbs[0]?.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('只有封面时不渲染缩略图条', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({ coverObjectKey: 'game-distribution/cover/only.png' }),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('heading', { name: '星轨防线' });
|
||||
expect(
|
||||
screen.queryAllByRole('button', { name: /(查看大图)/u }),
|
||||
).toHaveLength(0);
|
||||
expect(screen.queryByRole('group', { name: '游戏截图' })).toBeNull();
|
||||
});
|
||||
|
||||
it('既没有封面也没有截图时退回原占位视觉', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({ coverObjectKey: null, screenshots: [] }),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('heading', { name: '星轨防线' });
|
||||
expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(0);
|
||||
expect(document.querySelectorAll('.game-cover__icon')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import './gameDistribution.css';
|
||||
import './gameDistributionShowcase.css';
|
||||
|
||||
import { Gamepad2, Monitor, Search, Smartphone, Sparkles } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import {
|
||||
type GameCategory,
|
||||
type GameDistributionGame,
|
||||
@@ -34,13 +36,29 @@ type GameGalleryPageProps = {
|
||||
onOpenPublish?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 游戏封面。
|
||||
*
|
||||
* 有平台封面素材时换签名地址后显示真实封面,未上传、换签失败或还在解析时保留原有的
|
||||
* 渐变色 + 图标占位,保证首屏不出现空框或布局跳动;`objectKey` 允许详情页把封面位临时
|
||||
* 展示成选中的截图。
|
||||
*/
|
||||
export function GameCover({
|
||||
game,
|
||||
compact = false,
|
||||
priority = false,
|
||||
objectKey,
|
||||
}: {
|
||||
game: GameDistributionGame;
|
||||
compact?: boolean;
|
||||
/** 首屏 hero 用的封面立即加载,列表卡片保持延迟加载。 */
|
||||
priority?: boolean;
|
||||
objectKey?: string | null;
|
||||
}) {
|
||||
const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim();
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(null, {
|
||||
objectKey: targetKey,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={`game-cover ${compact ? 'game-cover--compact' : ''}`}
|
||||
@@ -49,9 +67,21 @@ export function GameCover({
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="game-cover__glow" />
|
||||
<span className="game-cover__icon">{game.icon}</span>
|
||||
<span className="game-cover__grid" />
|
||||
{resolvedUrl ? (
|
||||
<img
|
||||
className="game-cover__image"
|
||||
src={resolvedUrl}
|
||||
alt=""
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="game-cover__glow" />
|
||||
<span className="game-cover__icon">{game.icon}</span>
|
||||
<span className="game-cover__grid" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,23 @@ import {
|
||||
uploadGamePackage,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
resolveGamePublishImagePreview,
|
||||
uploadGamePublishImageAsset,
|
||||
} from './gamePublishAssets';
|
||||
import { resolvePublishMetadataError } from './gamePublishMetadata';
|
||||
import { GamePublishPage } from './GamePublishPage';
|
||||
|
||||
// 只替换真实上传与换签,保留常量与本地校验,避免测试真的打 OSS。
|
||||
vi.mock('./gamePublishAssets', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./gamePublishAssets')>();
|
||||
return {
|
||||
...actual,
|
||||
uploadGamePublishImageAsset: vi.fn(),
|
||||
resolveGamePublishImagePreview: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
createGame: vi.fn(),
|
||||
createGameVersion: vi.fn(),
|
||||
@@ -76,7 +90,22 @@ function renderPage(authValue: AuthValue | null = createAuthValue()) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildImageFile(name: string, type = 'image/png') {
|
||||
return new File(['image-bytes'], name, { type });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(uploadGamePublishImageAsset).mockReset();
|
||||
vi.mocked(resolveGamePublishImagePreview).mockReset();
|
||||
vi.mocked(resolveGamePublishImagePreview).mockResolvedValue('');
|
||||
vi.mocked(uploadGamePublishImageAsset).mockImplementation(
|
||||
async (file: File) => ({
|
||||
assetObjectId: `asset-${file.name}`,
|
||||
objectKey: `game-distribution/uploads/${file.name}`,
|
||||
src: `https://cdn.example.com/${file.name}`,
|
||||
name: file.name,
|
||||
}),
|
||||
);
|
||||
vi.mocked(createGame).mockReset();
|
||||
vi.mocked(createGameVersion).mockReset();
|
||||
vi.mocked(getGameVersion).mockReset();
|
||||
@@ -86,6 +115,22 @@ beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
async function selectCover() {
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [buildImageFile('cover.png')] },
|
||||
});
|
||||
await screen.findByText(/已选择「cover.png」/u);
|
||||
}
|
||||
|
||||
async function selectScreenshots(names: string[]) {
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: { files: names.map((name) => buildImageFile(name)) },
|
||||
});
|
||||
for (const name of names) {
|
||||
await screen.findByText(new RegExp(`截图 ${names.indexOf(name) + 1}`, 'u'));
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraft(ownerUserId: string) {
|
||||
window.localStorage.setItem(
|
||||
'genarrative.game-distribution.publish-draft.v1',
|
||||
@@ -101,11 +146,13 @@ function writeDraft(ownerUserId: string) {
|
||||
}
|
||||
|
||||
test('元数据校验与服务端口径一致', () => {
|
||||
const desktop = { desktop: true, mobile: false, touch: false };
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: ' ',
|
||||
summary: '简介',
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
deviceSupport: desktop,
|
||||
coverAssetId: 'asset-cover',
|
||||
}),
|
||||
).toBe('游戏标题必须为 1 到 40 个字符');
|
||||
expect(
|
||||
@@ -113,6 +160,7 @@ test('元数据校验与服务端口径一致', () => {
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: { desktop: false, mobile: true, touch: false },
|
||||
coverAssetId: 'asset-cover',
|
||||
}),
|
||||
).toBe('选择移动端时必须支持触控');
|
||||
expect(
|
||||
@@ -120,13 +168,33 @@ test('元数据校验与服务端口径一致', () => {
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: { desktop: false, mobile: false, touch: false },
|
||||
coverAssetId: 'asset-cover',
|
||||
}),
|
||||
).toBe('至少选择桌面端或移动端');
|
||||
// 服务端要求发布必须带封面,且截图上限 6 张。
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
deviceSupport: desktop,
|
||||
}),
|
||||
).toBe('发布游戏必须提供封面');
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: desktop,
|
||||
coverAssetId: 'asset-cover',
|
||||
screenshotCount: 7,
|
||||
}),
|
||||
).toBe('游戏截图最多 6 张');
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: desktop,
|
||||
coverAssetId: 'asset-cover',
|
||||
screenshotCount: 6,
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
@@ -146,11 +214,62 @@ test('缺少发行包时不上传', async () => {
|
||||
fireEvent.change(screen.getByLabelText('一句话简介'), {
|
||||
target: { value: '测试简介' },
|
||||
});
|
||||
await selectCover();
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
expect(await screen.findByText('请选择发行包 ZIP')).toBeTruthy();
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('缺少封面时本地先拦截且不创建游戏', async () => {
|
||||
renderPage();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '测试游戏' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('一句话简介'), {
|
||||
target: { value: '测试简介' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
|
||||
expect(await screen.findByText('发布游戏必须提供封面')).toBeTruthy();
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
expect(uploadGamePackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('封面上传失败时给出原因且不提交', async () => {
|
||||
vi.mocked(uploadGamePublishImageAsset).mockRejectedValue(
|
||||
new Error('游戏封面过大,请压缩后再上传(当前 9.0MB,最多 6MB)。'),
|
||||
);
|
||||
renderPage();
|
||||
fireEvent.change(screen.getByLabelText(/游戏封面/u), {
|
||||
target: { files: [buildImageFile('huge.png')] },
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'游戏封面过大,请压缩后再上传(当前 9.0MB,最多 6MB)。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('截图超过 6 张时本地拦截', async () => {
|
||||
renderPage();
|
||||
const files = Array.from({ length: 7 }, (_, index) =>
|
||||
buildImageFile(`shot-${index}.png`),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: { files },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/游戏截图最多 6 张/u)).toBeTruthy();
|
||||
expect(uploadGamePublishImageAsset).not.toHaveBeenCalled();
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('按创建游戏、创建版本、上传、送审顺序提交并展示审核中', async () => {
|
||||
vi.mocked(createGame).mockResolvedValue({
|
||||
id: 'game-1',
|
||||
@@ -181,6 +300,8 @@ test('按创建游戏、创建版本、上传、送审顺序提交并展示审
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await selectCover();
|
||||
await selectScreenshots(['shot-1.png', 'shot-2.png']);
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
|
||||
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
|
||||
@@ -190,6 +311,8 @@ test('按创建游戏、创建版本、上传、送审顺序提交并展示审
|
||||
summary: '测试简介',
|
||||
category: '休闲',
|
||||
inputModes: ['keyboard', 'mouse', 'touch'],
|
||||
coverAssetId: 'asset-cover.png',
|
||||
screenshots: ['asset-shot-1.png', 'asset-shot-2.png'],
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(createGameVersion).mock.calls[0]?.[0]).toBe('game-1');
|
||||
@@ -220,6 +343,7 @@ test('后端失败时不伪造成功', async () => {
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await selectCover();
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
|
||||
expect(await screen.findByText('游戏分发服务暂不可用(503)')).toBeTruthy();
|
||||
@@ -330,6 +454,16 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
|
||||
status: 'published',
|
||||
publicationRevision: 4,
|
||||
recoveryAction: 'none',
|
||||
frozenMetadata: {
|
||||
coverAssetId: 'asset-cover-old',
|
||||
coverObjectKey: 'game-distribution/cover/old.png',
|
||||
screenshots: [
|
||||
{
|
||||
assetId: 'asset-shot-old',
|
||||
objectKey: 'game-distribution/screenshot/old.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(createGameVersion).mockResolvedValue({
|
||||
@@ -362,6 +496,14 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
|
||||
expect(screen.getByLabelText('游戏名称')).toHaveProperty('value', '星轨防线');
|
||||
expect(screen.getByLabelText('分类')).toHaveProperty('value', '动作');
|
||||
|
||||
// 冻结资料直接回填封面与截图,作者不需要为了沿用封面重新上传一次。
|
||||
await waitFor(() =>
|
||||
expect(resolveGamePublishImagePreview).toHaveBeenCalledWith(
|
||||
'game-distribution/cover/old.png',
|
||||
),
|
||||
);
|
||||
expect(uploadGamePublishImageAsset).not.toHaveBeenCalled();
|
||||
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
@@ -379,6 +521,8 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
|
||||
title: '星轨防线',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
coverAssetId: 'asset-cover-old',
|
||||
screenshots: ['asset-shot-old'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -389,6 +533,80 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('线上封面没有冻结素材时要求重新选择封面', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
{
|
||||
id: 'game-1',
|
||||
title: '星轨防线',
|
||||
summary: '守住轨道城',
|
||||
description: '旧版资料',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
publicationRevision: 4,
|
||||
latestVersion: {
|
||||
versionId: 'gamever-2',
|
||||
gameId: 'game-1',
|
||||
versionNumber: 2,
|
||||
packageSha256: 'd'.repeat(64),
|
||||
packageBytes: 1024,
|
||||
status: 'published',
|
||||
publicationRevision: 4,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T09:00:00Z',
|
||||
},
|
||||
versions: [],
|
||||
} as never,
|
||||
]);
|
||||
// 历史版本没有冻结快照:只能看到线上封面,但没有可复用的素材 ID。
|
||||
vi.mocked(getGameVersion).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game-1',
|
||||
title: '星轨防线',
|
||||
summary: '守住轨道城',
|
||||
description: '旧版资料',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
publicationRevision: 4,
|
||||
coverObjectKey: 'game-distribution/cover/legacy.png',
|
||||
},
|
||||
version: {
|
||||
versionId: 'gamever-2',
|
||||
versionNumber: 2,
|
||||
status: 'published',
|
||||
publicationRevision: 4,
|
||||
recoveryAction: 'none',
|
||||
frozenMetadata: null,
|
||||
},
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<GamePublishPage
|
||||
onBack={vi.fn()}
|
||||
onOpenMyGames={vi.fn()}
|
||||
updateGameId="game-1"
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/本次发布需要重新选择一次封面图片/u),
|
||||
).toBeTruthy();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'本次发布需要重新选择一次封面图片(当前展示的是线上封面)',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(createGameVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('更新模式读不到归属游戏时失败关闭且不提交', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([]);
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ import {
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import {
|
||||
GAME_PUBLISH_IMAGE_ACCEPT,
|
||||
type GamePublishImageAsset,
|
||||
MAX_GAME_SCREENSHOTS,
|
||||
resolveGamePublishImagePreview,
|
||||
uploadGamePublishImageAsset,
|
||||
} from './gamePublishAssets';
|
||||
import {
|
||||
clearPublishDraft,
|
||||
type GamePublishDraft,
|
||||
@@ -74,6 +81,81 @@ function createPublishKey() {
|
||||
return `web-publish-${random}`;
|
||||
}
|
||||
|
||||
type VersionMediaApplyTarget = {
|
||||
setCover: (
|
||||
updater: (
|
||||
current: GamePublishImageAsset | null,
|
||||
) => GamePublishImageAsset | null,
|
||||
) => void;
|
||||
setScreenshots: (
|
||||
updater: (current: GamePublishImageAsset[]) => GamePublishImageAsset[],
|
||||
) => void;
|
||||
isCancelled: () => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用版本冻结资料回填封面与截图。
|
||||
*
|
||||
* 快照缺失(或旧版本没存素材 ID)时只回填线上封面预览:此时创作者必须重新选择一次封面,
|
||||
* 因为服务端只接受素材 ID 作为冻结输入,前端不能凭对象键猜素材身份。
|
||||
*/
|
||||
async function applyVersionMediaDefaults(
|
||||
detail: GameDistributionVersionDetail,
|
||||
apply: VersionMediaApplyTarget,
|
||||
) {
|
||||
const frozen = detail.version.frozenMetadata ?? null;
|
||||
const coverAssetId = frozen?.coverAssetId?.trim() ?? '';
|
||||
const coverObjectKey =
|
||||
frozen?.coverObjectKey?.trim() ?? detail.game.coverObjectKey?.trim() ?? '';
|
||||
const coverName = detail.game.title.trim() || '当前封面';
|
||||
const buildCover = (src: string): GamePublishImageAsset => ({
|
||||
assetObjectId: coverAssetId,
|
||||
objectKey: coverObjectKey,
|
||||
src,
|
||||
name: coverName,
|
||||
});
|
||||
|
||||
const screenshots = (frozen?.screenshots ?? []).filter((screenshot) =>
|
||||
Boolean(screenshot?.assetId?.trim()),
|
||||
);
|
||||
if (apply.isCancelled()) return;
|
||||
apply.setCover(() =>
|
||||
coverAssetId || coverObjectKey ? buildCover('') : null,
|
||||
);
|
||||
apply.setScreenshots(() =>
|
||||
screenshots.map((screenshot, index) => ({
|
||||
assetObjectId: screenshot.assetId.trim(),
|
||||
objectKey: screenshot.objectKey?.trim() ?? '',
|
||||
src: '',
|
||||
name: `截图 ${index + 1}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const coverPreview = await resolveGamePublishImagePreview(coverObjectKey);
|
||||
if (apply.isCancelled()) return;
|
||||
if (coverPreview) {
|
||||
// 只在作者还没换图、且预览仍为空时补上签名地址,避免覆盖作者的新选择。
|
||||
apply.setCover((current) =>
|
||||
current && !current.src && current.objectKey === coverObjectKey
|
||||
? { ...current, src: coverPreview }
|
||||
: current,
|
||||
);
|
||||
}
|
||||
for (const [index, screenshot] of screenshots.entries()) {
|
||||
const preview = await resolveGamePublishImagePreview(
|
||||
screenshot.objectKey ?? '',
|
||||
);
|
||||
if (apply.isCancelled() || !preview) continue;
|
||||
apply.setScreenshots((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index && item.assetObjectId === screenshot.assetId
|
||||
? { ...item, src: preview }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function GamePublishPage({
|
||||
onBack,
|
||||
onOpenMyGames,
|
||||
@@ -81,6 +163,8 @@ export function GamePublishPage({
|
||||
}: GamePublishPageProps) {
|
||||
const authUi = useAuthUi();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
const screenshotInputRef = useRef<HTMLInputElement>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -95,6 +179,14 @@ export function GamePublishPage({
|
||||
const [orientation, setOrientation] =
|
||||
useState<GameDistributionOrientation>('responsive');
|
||||
const [packageFile, setPackageFile] = useState<File | null>(null);
|
||||
// 封面与截图都先上传成平台素材,再把素材 ID 交给服务端冻结;页面只保存素材标识与预览地址。
|
||||
const [coverAsset, setCoverAsset] = useState<GamePublishImageAsset | null>(
|
||||
null,
|
||||
);
|
||||
const [screenshotAssets, setScreenshotAssets] = useState<
|
||||
GamePublishImageAsset[]
|
||||
>([]);
|
||||
const [isUploadingAsset, setIsUploadingAsset] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [publishedGameId, setPublishedGameId] = useState('');
|
||||
@@ -141,6 +233,11 @@ export function GamePublishPage({
|
||||
versionNumber: detail.version.versionNumber,
|
||||
title: detail.game.title,
|
||||
});
|
||||
void applyVersionMediaDefaults(detail, {
|
||||
setCover: setCoverAsset,
|
||||
setScreenshots: setScreenshotAssets,
|
||||
isCancelled: () => cancelled,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
@@ -179,6 +276,11 @@ export function GamePublishPage({
|
||||
setTitle(detail.game.title);
|
||||
setSummary(detail.game.summary);
|
||||
setDescription(detail.game.description);
|
||||
void applyVersionMediaDefaults(detail, {
|
||||
setCover: setCoverAsset,
|
||||
setScreenshots: setScreenshotAssets,
|
||||
isCancelled: () => cancelled,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
@@ -194,6 +296,80 @@ export function GamePublishPage({
|
||||
draftDetail !== null &&
|
||||
draftDetail.version.recoveryAction !== 'submit';
|
||||
|
||||
async function handleCoverSelected(file: File | null) {
|
||||
if (!file) return;
|
||||
setError('');
|
||||
setIsUploadingAsset(true);
|
||||
try {
|
||||
const uploaded = await uploadGamePublishImageAsset(file, {
|
||||
kind: 'cover',
|
||||
});
|
||||
setCoverAsset(uploaded);
|
||||
} catch (uploadError: unknown) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '封面上传失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingAsset(false);
|
||||
// 清空 input 才能让作者重新选择同一张图。
|
||||
if (coverInputRef.current) coverInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScreenshotsSelected(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
const remaining = MAX_GAME_SCREENSHOTS - screenshotAssets.length;
|
||||
if (files.length > remaining) {
|
||||
setError(
|
||||
remaining > 0
|
||||
? `游戏截图最多 6 张,还可以再选 ${remaining} 张`
|
||||
: '游戏截图最多 6 张',
|
||||
);
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setIsUploadingAsset(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
const uploaded = await uploadGamePublishImageAsset(file, {
|
||||
kind: 'screenshot',
|
||||
});
|
||||
// 逐张入库:中途失败时已经传好的截图仍然保留,作者不用重新选择。
|
||||
setScreenshotAssets((current) =>
|
||||
current.length >= MAX_GAME_SCREENSHOTS
|
||||
? current
|
||||
: [...current, uploaded],
|
||||
);
|
||||
}
|
||||
} catch (uploadError: unknown) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '截图上传失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingAsset(false);
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeScreenshotAsset(index: number) {
|
||||
setScreenshotAssets((current) =>
|
||||
current.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
}
|
||||
|
||||
/** 展示中的线上封面没有素材 ID 时无法冻结进新版本,必须先本地拦一次并说明原因。 */
|
||||
function resolveMediaSubmitError() {
|
||||
if (coverAsset && !coverAsset.assetObjectId.trim()) {
|
||||
return '本次发布需要重新选择一次封面图片(当前展示的是线上封面)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function dismissDraft() {
|
||||
clearPublishDraft();
|
||||
setDraft(null);
|
||||
@@ -252,10 +428,17 @@ export function GamePublishPage({
|
||||
setError(updateContextError || '待更新游戏尚未就绪,请稍后重试');
|
||||
return;
|
||||
}
|
||||
const mediaError = resolveMediaSubmitError();
|
||||
if (mediaError) {
|
||||
setError(mediaError);
|
||||
return;
|
||||
}
|
||||
const metadataError = resolvePublishMetadataError({
|
||||
title,
|
||||
summary,
|
||||
deviceSupport,
|
||||
coverAssetId: coverAsset?.assetObjectId,
|
||||
screenshotCount: screenshotAssets.length,
|
||||
});
|
||||
if (metadataError) {
|
||||
setError(metadataError);
|
||||
@@ -283,6 +466,8 @@ export function GamePublishPage({
|
||||
description: description.trim() || summary.trim(),
|
||||
category,
|
||||
tags,
|
||||
coverAssetId: coverAsset?.assetObjectId ?? '',
|
||||
screenshots: screenshotAssets.map((asset) => asset.assetObjectId),
|
||||
deviceSupport,
|
||||
inputModes,
|
||||
orientation,
|
||||
@@ -334,10 +519,17 @@ export function GamePublishPage({
|
||||
await handleResumeSubmit();
|
||||
return;
|
||||
}
|
||||
const mediaError = resolveMediaSubmitError();
|
||||
if (mediaError) {
|
||||
setError(mediaError);
|
||||
return;
|
||||
}
|
||||
const metadataError = resolvePublishMetadataError({
|
||||
title,
|
||||
summary,
|
||||
deviceSupport,
|
||||
coverAssetId: coverAsset?.assetObjectId,
|
||||
screenshotCount: screenshotAssets.length,
|
||||
});
|
||||
if (metadataError) {
|
||||
setError(metadataError);
|
||||
@@ -364,6 +556,8 @@ export function GamePublishPage({
|
||||
description: description.trim() || summary.trim(),
|
||||
category,
|
||||
tags,
|
||||
coverAssetId: coverAsset?.assetObjectId ?? '',
|
||||
screenshots: screenshotAssets.map((asset) => asset.assetObjectId),
|
||||
deviceSupport,
|
||||
inputModes,
|
||||
orientation,
|
||||
@@ -642,6 +836,107 @@ export function GamePublishPage({
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset className="game-publish-fieldset game-publish-media">
|
||||
<legend>封面与截图</legend>
|
||||
<div className="game-publish-media__cover">
|
||||
<div className="game-publish-media__preview">
|
||||
{coverAsset?.src ? (
|
||||
<img src={coverAsset.src} alt="游戏封面预览" />
|
||||
) : (
|
||||
<span className="game-publish-media__placeholder">
|
||||
{coverAsset ? '封面预览加载中' : '还没有封面'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="game-publish-media__copy">
|
||||
<label className="game-publish-field">
|
||||
游戏封面(必需)
|
||||
<input
|
||||
ref={coverInputRef}
|
||||
type="file"
|
||||
accept={GAME_PUBLISH_IMAGE_ACCEPT}
|
||||
disabled={isResumingDraft || isSubmitting || isUploadingAsset}
|
||||
onChange={(event) => {
|
||||
void handleCoverSelected(event.target.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
<span className="game-publish-hint">
|
||||
{coverAsset?.assetObjectId
|
||||
? `已选择「${coverAsset.name}」;重新选择即可替换本次发布的封面。`
|
||||
: coverAsset
|
||||
? '本次发布需要重新选择一次封面图片,才能沿用这张线上封面。'
|
||||
: '封面会作为广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
|
||||
</span>
|
||||
</label>
|
||||
{coverAsset ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-publish-media__remove"
|
||||
disabled={isResumingDraft || isSubmitting || isUploadingAsset}
|
||||
onClick={() => setCoverAsset(null)}
|
||||
>
|
||||
移除封面
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="game-publish-media__screenshots">
|
||||
<label className="game-publish-field">
|
||||
{`游戏截图(可选,最多 ${MAX_GAME_SCREENSHOTS} 张)`}
|
||||
<input
|
||||
ref={screenshotInputRef}
|
||||
type="file"
|
||||
accept={GAME_PUBLISH_IMAGE_ACCEPT}
|
||||
multiple
|
||||
disabled={
|
||||
isResumingDraft ||
|
||||
isSubmitting ||
|
||||
isUploadingAsset ||
|
||||
screenshotAssets.length >= MAX_GAME_SCREENSHOTS
|
||||
}
|
||||
onChange={(event) => {
|
||||
void handleScreenshotsSelected(
|
||||
Array.from(event.target.files ?? []),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="game-publish-hint">
|
||||
截图会按选择顺序展示在详情页;不需要可以留空。
|
||||
</span>
|
||||
</label>
|
||||
{screenshotAssets.length > 0 ? (
|
||||
<ul className="game-publish-media__strip">
|
||||
{screenshotAssets.map((asset, index) => (
|
||||
<li
|
||||
key={`${asset.assetObjectId || asset.objectKey}-${index}`}
|
||||
className="game-publish-media__shot"
|
||||
>
|
||||
{asset.src ? (
|
||||
<img src={asset.src} alt={`截图 ${index + 1} 预览`} />
|
||||
) : (
|
||||
<span className="game-publish-media__placeholder">
|
||||
{`截图 ${index + 1}`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="game-publish-media__remove"
|
||||
disabled={
|
||||
isResumingDraft || isSubmitting || isUploadingAsset
|
||||
}
|
||||
onClick={() => removeScreenshotAsset(index)}
|
||||
>
|
||||
{`移除截图 ${index + 1}`}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
{isUploadingAsset ? (
|
||||
<span className="game-publish-hint">正在上传图片,请稍候…</span>
|
||||
) : null}
|
||||
</fieldset>
|
||||
<label className="game-publish-field">
|
||||
{resumeNeedsPackage ? '重新选择发行包 ZIP' : '发行包 ZIP'}
|
||||
<input
|
||||
@@ -664,6 +959,7 @@ export function GamePublishPage({
|
||||
disabled={
|
||||
!canPublish ||
|
||||
isSubmitting ||
|
||||
isUploadingAsset ||
|
||||
(Boolean(updateGameId) && !updateContext)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -880,6 +880,98 @@
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.game-publish-media {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.game-publish-media__cover {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.game-publish-media__copy {
|
||||
display: grid;
|
||||
flex: 1 1 16rem;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.game-publish-media__preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: min(15rem, 100%);
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.7rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(241, 159, 95, 0.22),
|
||||
rgba(91, 49, 42, 0.14)
|
||||
);
|
||||
}
|
||||
.game-publish-media__preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.game-publish-media__placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.game-publish-media__screenshots {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.game-publish-media__strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.game-publish-media__shot {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
width: 8.5rem;
|
||||
}
|
||||
.game-publish-media__shot img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
.game-publish-media__shot .game-publish-media__placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 1px dashed var(--platform-surface-border);
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
.game-publish-media__remove {
|
||||
justify-self: start;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--platform-text-soft);
|
||||
font: inherit;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-publish-media__remove:hover:not(:disabled) {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
.game-publish-media__remove:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.game-publish-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -888,6 +980,12 @@
|
||||
.game-publish-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.game-publish-media__preview {
|
||||
width: 100%;
|
||||
}
|
||||
.game-publish-media__shot {
|
||||
width: calc(50% - 0.3rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端首屏:压缩 hero 高度,让第一排游戏卡片在 900px 视口内就能看到。 */
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 游戏分发封面与截图的展示层补充样式。
|
||||
*
|
||||
* 真实封面/截图换签成功后覆盖原有渐变占位;未上传、换签失败或仍在解析时保持
|
||||
* gameDistribution.css 里的占位视觉,因此这里的规则只负责“图片已就绪”这一态。
|
||||
*/
|
||||
.game-cover__image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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 {
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-detail-thumb__placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.game-detail-thumb {
|
||||
width: 4.6rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getSignedAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
|
||||
/** 游戏截图数量上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
|
||||
export const MAX_GAME_SCREENSHOTS = 6;
|
||||
/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */
|
||||
export const GAME_PUBLISH_IMAGE_MAX_BYTES = 6 * 1024 * 1024;
|
||||
export const GAME_PUBLISH_IMAGE_ACCEPT =
|
||||
'image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
const GAME_PUBLISH_IMAGE_PREVIEW_EXPIRE_SECONDS = 60 * 60;
|
||||
|
||||
export type GamePublishImageKind = 'cover' | 'screenshot';
|
||||
|
||||
/** 已上传到平台的封面/截图素材:素材 ID 用于冻结资料,对象键用于换签展示。 */
|
||||
export type GamePublishImageAsset = {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
/** 签名预览地址;换签失败时为空字符串,此时只显示占位,不影响继续发布。 */
|
||||
src: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
export function resolveGamePublishImageKindLabel(kind: GamePublishImageKind) {
|
||||
return kind === 'cover' ? '游戏封面' : '游戏截图';
|
||||
}
|
||||
|
||||
/** 本地预检;服务端仍会校验素材归属与图片类型,这里只拦掉明显不合规的文件。 */
|
||||
export function validateGamePublishImageFile(
|
||||
file: File,
|
||||
kind: GamePublishImageKind,
|
||||
) {
|
||||
const label = resolveGamePublishImageKindLabel(kind);
|
||||
if (file.size <= 0) {
|
||||
throw new Error(`${label}文件为空,请重新选择。`);
|
||||
}
|
||||
if (file.size > GAME_PUBLISH_IMAGE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`${label}过大,请压缩后再上传(当前 ${formatBytes(file.size)},最多 6MB)。`,
|
||||
);
|
||||
}
|
||||
const contentType = file.type.trim();
|
||||
if (contentType && !contentType.startsWith('image/')) {
|
||||
throw new Error(`${label}必须是图片文件。`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传一张封面/截图并返回可用于冻结资料的素材标识。
|
||||
*
|
||||
* 复用平台图片上传通道(直传凭证 + 直传 + confirm),对象键由服务端派生;这里不把
|
||||
* 本地文件路径或外链写进资料,避免资料冻结时引用到平台之外的内容。
|
||||
*/
|
||||
export async function uploadGamePublishImageAsset(
|
||||
file: File,
|
||||
options: { kind: GamePublishImageKind; signal?: AbortSignal },
|
||||
): Promise<GamePublishImageAsset> {
|
||||
validateGamePublishImageFile(file, options.kind);
|
||||
const uploaded = await uploadEditorMediaAssetFile(file, 'image', {
|
||||
assetKind: `game_distribution_${options.kind}`,
|
||||
pathSegments: ['game-distribution', options.kind, `${Date.now()}`],
|
||||
entityId: `game-distribution-${options.kind}`,
|
||||
metadata: { game_distribution_media: options.kind },
|
||||
signal: options.signal,
|
||||
});
|
||||
return {
|
||||
assetObjectId: uploaded.assetObjectId,
|
||||
objectKey: uploaded.objectKey,
|
||||
src: uploaded.src,
|
||||
name: file.name.trim() || resolveGamePublishImageKindLabel(options.kind),
|
||||
};
|
||||
}
|
||||
|
||||
/** 作者续发时按冻结资料里的对象键换签预览;失败返回空字符串,由调用方回退占位。 */
|
||||
export async function resolveGamePublishImagePreview(objectKey: string) {
|
||||
const normalizedKey = objectKey.trim();
|
||||
if (!normalizedKey) return '';
|
||||
try {
|
||||
return await getSignedAssetReadUrl({
|
||||
objectKey: normalizedKey,
|
||||
expireSeconds: GAME_PUBLISH_IMAGE_PREVIEW_EXPIRE_SECONDS,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { GameDistributionDeviceSupport } from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
import { MAX_GAME_SCREENSHOTS } from './gamePublishAssets';
|
||||
|
||||
/** 作者侧元数据校验;口径与服务端 `validate_game_metadata` 保持一致。 */
|
||||
export function resolvePublishMetadataError(input: {
|
||||
title: string;
|
||||
summary: string;
|
||||
deviceSupport: GameDistributionDeviceSupport;
|
||||
/** 封面素材 ID;服务端要求发布必须带封面,缺失时在本地先拦一次。 */
|
||||
coverAssetId?: string | null;
|
||||
/** 已选截图数量;上限与服务端 `MAX_GAME_SCREENSHOTS` 一致。 */
|
||||
screenshotCount?: number;
|
||||
}) {
|
||||
const title = input.title.trim();
|
||||
const summary = input.summary.trim();
|
||||
@@ -20,5 +25,11 @@ export function resolvePublishMetadataError(input: {
|
||||
if (input.deviceSupport.mobile && !input.deviceSupport.touch) {
|
||||
return '选择移动端时必须支持触控';
|
||||
}
|
||||
if (!input.coverAssetId?.trim()) {
|
||||
return '发布游戏必须提供封面';
|
||||
}
|
||||
if ((input.screenshotCount ?? 0) > MAX_GAME_SCREENSHOTS) {
|
||||
return '游戏截图最多 6 张';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user