AGC 退役发布表单弹窗外壳,错误统一走发布提示弹窗

- 删除已无现役调用方的 GameDistributionPublishPanel,发布资料表单只保留内联形态

- useGameDistributionPublishForm 去掉 inline 开关与 handleSubmit,onError 改为必填

- GameDistributionPublishFormView 去掉弹窗分支与 open / onClose 入参,只渲染内联布局

- 原专属测试迁移到内联表单并改名为 gameDistributionPublishForm.test.tsx
This commit is contained in:
2026-10-06 18:15:35 +08:00
parent 8a2c09f24a
commit c66d1586f4
6 changed files with 119 additions and 256 deletions
@@ -12,26 +12,19 @@ import {
} from './useGameDistributionPublishForm';
/**
* 发布资料表单的表现层:弹窗与工作台内联共用同一份 DOM 结构。
* 发布资料表单的表现层(工作台内联形态)。
*
* 状态全部来自 `form`(`useGameDistributionPublishForm` 的返回值);内联形态把主行动换成
* 内容末尾整行橙色的「打包并发布」。
* 状态全部来自 `form`(`useGameDistributionPublishForm` 的返回值);主行动是内容末尾整行
* 橙色的「打包并发布」,校验 / 提交错误由面板根的「发布提示」弹窗承载。
*/
export function GameDistributionPublishFormView({
form,
inline = false,
open,
onClose,
packaging = false,
}: {
form: GameDistributionPublishFormState;
inline?: boolean;
open: boolean;
onClose: () => void;
packaging?: boolean;
}) {
const {
packageResult,
title,
setTitle,
summary,
@@ -39,7 +32,6 @@ export function GameDistributionPublishFormView({
category,
setCategory,
busy,
error,
clearError,
result,
cover,
@@ -66,7 +58,6 @@ export function GameDistributionPublishFormView({
handleGenerateCover,
handleScreenshotsSelected,
removeScreenshot,
handleSubmit,
handlePackageAndPublish,
} = form;
@@ -349,160 +340,60 @@ export function GameDistributionPublishFormView({
</ThemedModal>
);
if (inline) {
return (
<div
className="flex h-full min-h-0 flex-col gap-3 overflow-auto p-3"
aria-label="游戏发布表单"
>
<section className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4">
<span className="game-distribution-publish-panel__eyebrow">
发布到游戏广场
</span>
<h2 className="mt-1 text-base font-semibold text-[#3d1f10]">
让玩家现在就能试玩
</h2>
</section>
{result ? (
<div
className="game-distribution-publish-panel__success"
role="status"
>
<strong>已提交审核</strong>
<p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p>
<p className="game-distribution-publish-panel__mono">
版本 {result.versionNumber} ·{' '}
{result.packageSizeBytes.toLocaleString()} B ·{' '}
{result.packageSha256.slice(0, 16)}…
</p>
</div>
) : (
<>
{fieldsAndMedia}
<button
type="button"
disabled={
busy ||
generatingCover ||
screenshotsUploading ||
Boolean(uploadingLabel) ||
Boolean(packaging)
}
onClick={() => void handlePackageAndPublish()}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-6 py-4 text-lg font-bold tracking-wide text-white shadow-md transition enabled:hover:bg-[#b4562f] disabled:cursor-not-allowed disabled:opacity-45"
>
{packaging
? '正在打包…'
: busy
? '上传中…'
: generatingCover
? '封面生成中…'
: screenshotsUploading
? '截图上传中…'
: uploadingLabel
? '素材上传中…'
: '打包并发布'}
</button>
</>
)}
{coverConfirmDialog}
{previewDialog}
</div>
);
}
return (
<>
<ThemedModal
open={open}
ariaLabel="发布到游戏广场"
onClose={busy || generatingCover ? () => undefined : onClose}
panelClassName="game-distribution-publish-panel"
closeOnBackdrop={!busy && !generatingCover}
closeOnEscape={!busy && !generatingCover}
>
<header className="game-distribution-publish-panel__header">
<div>
<span className="game-distribution-publish-panel__eyebrow">
发布到游戏广场
</span>
<h2>让玩家现在就能试玩</h2>
</div>
<div
className="flex h-full min-h-0 flex-col gap-3 overflow-auto p-3"
aria-label="游戏发布表单"
>
<section className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4">
<span className="game-distribution-publish-panel__eyebrow">
发布到游戏广场
</span>
<h2 className="mt-1 text-base font-semibold text-[#3d1f10]">
让玩家现在就能试玩
</h2>
</section>
{result ? (
<div className="game-distribution-publish-panel__success" role="status">
<strong>已提交审核</strong>
<p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p>
<p className="game-distribution-publish-panel__mono">
版本 {result.versionNumber} ·{' '}
{result.packageSizeBytes.toLocaleString()} B ·{' '}
{result.packageSha256.slice(0, 16)}…
</p>
</div>
) : (
<>
{fieldsAndMedia}
<button
type="button"
onClick={onClose}
disabled={busy || generatingCover}
aria-label="关闭发布面板"
disabled={
busy ||
generatingCover ||
screenshotsUploading ||
Boolean(uploadingLabel) ||
Boolean(packaging)
}
onClick={() => void handlePackageAndPublish()}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-[#c7653d] bg-[#c7653d] px-6 py-4 text-lg font-bold tracking-wide text-white shadow-md transition enabled:hover:bg-[#b4562f] disabled:cursor-not-allowed disabled:opacity-45"
>
×
{packaging
? '正在打包…'
: busy
? '上传中…'
: generatingCover
? '封面生成中…'
: screenshotsUploading
? '截图上传中…'
: uploadingLabel
? '素材上传中…'
: '打包并发布'}
</button>
</header>
{result ? (
<div
className="game-distribution-publish-panel__success"
role="status"
>
<strong>已提交审核</strong>
<p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p>
<p className="game-distribution-publish-panel__mono">
版本 {result.versionNumber} ·{' '}
{result.packageSizeBytes.toLocaleString()} B ·{' '}
{result.packageSha256.slice(0, 16)}…
</p>
<div className="game-distribution-publish-panel__actions">
<button type="button" onClick={onClose}>
完成
</button>
</div>
</div>
) : (
<>
{fieldsAndMedia}
{error ? (
<p
className="game-distribution-publish-panel__error"
role="alert"
>
{error}
</p>
) : null}
<div className="game-distribution-publish-panel__actions">
<button
type="button"
onClick={onClose}
disabled={busy || generatingCover || screenshotsUploading}
>
稍后再说
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={
busy ||
generatingCover ||
screenshotsUploading ||
Boolean(uploadingLabel) ||
!title.trim() ||
!summary.trim() ||
!packageResult
}
>
{busy
? '上传中…'
: generatingCover
? '封面生成中…'
: screenshotsUploading
? '截图上传中…'
: uploadingLabel
? '素材上传中…'
: '发布游戏'}
</button>
</div>
</>
)}
</ThemedModal>
</>
)}
{coverConfirmDialog}
{previewDialog}
</>
</div>
);
}
@@ -1,43 +0,0 @@
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { LocalProjectExportPackageResult } from '../../app/types';
import type { GameDistributionPublishResult } from '../../services/gameDistributionPublish';
import { GameDistributionPublishFormView } from './GameDistributionPublishFormView';
import { useGameDistributionPublishForm } from './useGameDistributionPublishForm';
/**
* 发布资料表单的弹窗外壳:点按钮弹出的独立面板。
*
* 表单状态在 `useGameDistributionPublishForm`;工作台内联形态把它提升到导出面板根
* (`useTaonierTab`),切 tab 不丢。这里保留「弹窗 + 关闭」的入口形态。
*/
export function GameDistributionPublishPanel({
open,
projectPath,
manifest,
packageResult,
onClose,
onPublished,
}: {
open: boolean;
projectPath: string;
manifest: GameCreationAppManifest;
packageResult: LocalProjectExportPackageResult | null;
onClose: () => void;
onPublished?: (result: GameDistributionPublishResult) => void;
}) {
const form = useGameDistributionPublishForm({
open,
inline: false,
projectPath,
manifest,
packageResult,
onPublished,
});
return (
<GameDistributionPublishFormView
form={form}
open={open}
onClose={onClose}
/>
);
}
@@ -148,14 +148,11 @@ export type GameDistributionPublishFormState = ReturnType<
/**
* 发布资料表单的全部状态与动作。
*
* 弹窗形态由 `GameDistributionPublishPanel` 自己调用;工作台内联形态把它提升到导出面板根
* (`useTaonierTab`),这样切走 tab、页面组件卸载也不会丢掉已经填好的发布资料。
*
* `inline` 决定初始化开关:弹窗每次带着新试玩包打开都重来;内联常驻左栏,只在换项目时重来。
* 由导出面板根(`useTaonierTab`)持有,这样切走 tab、页面组件卸载也不会丢掉已经填好的发布资料;
* 初始化只在换项目 / 打开时重来,不因为「打包结果变化」清掉用户填好的资料。
*/
export function useGameDistributionPublishForm({
open,
inline,
projectPath,
manifest,
packageResult,
@@ -164,17 +161,13 @@ export function useGameDistributionPublishForm({
onError,
}: {
open: boolean;
inline: boolean;
projectPath: string;
manifest: GameCreationAppManifest | null;
packageResult: LocalProjectExportPackageResult | null;
onPublished?: (result: GameDistributionPublishResult) => void;
onEnsurePackage?: () => Promise<LocalProjectExportPackageResult | null>;
/**
* 错误出口:内联工作台把表单校验 / 提交错误交给面板根的 `GamePublishBlockedDialog`
* (发布提示弹窗)展示;弹窗形态不传,退回表单里的内联文案。`null` 表示清掉弹窗。
*/
onError?: (message: string | null) => void;
/** 表单校验 / 提交错误的出口:面板根用它驱动「发布提示」弹窗(`GamePublishBlockedDialog`)。 */
onError: (message: string | null) => void;
}) {
const manifestRef = useRef(manifest);
manifestRef.current = manifest;
@@ -211,14 +204,6 @@ export function useGameDistributionPublishForm({
const uploadedAssetCacheRef = useRef(new Map<string, string>());
const objectUrlsRef = useRef(new Set<string>());
/**
* 表单初始化开关:弹窗每次带着新试玩包打开都要重来;内联常驻左栏,只在换项目时重来,
* 不能因为「打包完包结果变化」就把用户已经填好的资料清掉。
*/
const resetKey = inline
? 'inline'
: (packageResult?.packageRelativePath ?? '');
useEffect(() => {
if (!open) return;
const currentManifest = manifestRef.current;
@@ -231,7 +216,7 @@ export function useGameDistributionPublishForm({
setCategory('其他');
setBusy(false);
setError('');
onError?.(null);
onError(null);
setResult(null);
setMetadataSuggestionPending(false);
setMetadataSuggestionReady(false);
@@ -268,7 +253,7 @@ export function useGameDistributionPublishForm({
setMetadataSuggestionPending(false);
}
});
}, [manifest?.projectId, open, resetKey, onError]);
}, [manifest?.projectId, open, onError]);
useEffect(() => {
if (open) {
@@ -519,7 +504,7 @@ export function useGameDistributionPublishForm({
*/
function reportError(message: string) {
setError(message);
onError?.(message.trim() ? message : null);
onError(message.trim() ? message : null);
}
/** 用户一改字段就把上一次的校验错误收起来,别让已经修好的字段继续挂着红字。 */
@@ -587,14 +572,6 @@ export function useGameDistributionPublishForm({
}
}
async function handleSubmit() {
if (!packageResult) {
reportError('请先导出有效的试玩包');
return;
}
await submitPackage(packageResult);
}
/**
* 内联主行动:一次点击完成「打包 → 发布」。
*
@@ -665,7 +642,6 @@ export function useGameDistributionPublishForm({
handleGenerateCover,
handleScreenshotsSelected,
removeScreenshot,
handleSubmit,
handlePackageAndPublish,
};
}
@@ -231,7 +231,6 @@ export function useTaonierTab({
*/
const publishForm = useGameDistributionPublishForm({
open: formEnabled,
inline: true,
projectPath: projectPath ?? '',
manifest,
packageResult,
@@ -32,9 +32,6 @@ export function PublishPane({ tab }: { tab: TaonierTabState }) {
>
<GameDistributionPublishFormView
form={tab.publishForm}
inline
open
onClose={() => undefined}
packaging={tab.progress?.phase === 'running'}
/>
</div>
@@ -13,13 +13,16 @@ import {
waitFor,
within,
} from '@testing-library/react';
import { useState } from 'react';
import { afterEach, beforeEach, 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 { GameDistributionPublishFormView } from '../src/components/game-distribution/GameDistributionPublishFormView';
import { useGameDistributionPublishForm } from '../src/components/game-distribution/useGameDistributionPublishForm';
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
import {
type GameDistributionPublishResult,
generateGameDistributionCover,
publishLocalProjectGame,
readGameCoverGenerationPrice,
@@ -94,13 +97,49 @@ async function selectCover(
await screen.findByText(new RegExp(`已选择「${file.name}」`, 'u'));
}
/**
* 内联化之后,原弹窗外壳已退役;测试直接挂在现役的内联表单上(去掉面板根那层状态提升),
* 继续覆盖预填、门禁、上传与成功 / 失败边界。
*/
function PublishFormHarness({
open,
projectPath,
manifest,
packageResult,
onPublished,
}: {
open: boolean;
projectPath: string;
manifest: GameCreationAppManifest;
packageResult: LocalProjectExportPackageResult | null;
onClose: () => void;
onPublished?: (result: GameDistributionPublishResult) => void;
}) {
const [notice, setNotice] = useState<string | null>(null);
const form = useGameDistributionPublishForm({
open,
projectPath,
manifest,
packageResult,
onPublished,
// 内联形态在面板根用「发布提示」弹窗承载错误;测试里用一个等价的 alert 节点接住。
onError: setNotice,
});
return (
<>
{notice ? <p role="alert">{notice}</p> : null}
<GameDistributionPublishFormView form={form} />
</>
);
}
function renderPanel(
overrides: Partial<Parameters<typeof GameDistributionPublishPanel>[0]> = {},
overrides: Partial<Parameters<typeof PublishFormHarness>[0]> = {},
) {
const onClose = vi.fn();
const onPublished = vi.fn();
render(
<GameDistributionPublishPanel
<PublishFormHarness
open
projectPath="/tmp/authorized-game"
manifest={MANIFEST}
@@ -136,7 +175,7 @@ afterEach(() => {
(window.localStorage as Storage | undefined)?.clear();
});
describe('GameDistributionPublishPanel', () => {
describe('AGC 发布资料表单(内联形态)', () => {
test('打开时预填游戏资料且不展示发行包技术摘要', async () => {
installTauriInvoke(async () => undefined);
renderPanel();
@@ -260,7 +299,7 @@ describe('GameDistributionPublishPanel', () => {
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
fireEvent.click(screen.getByRole('button', { name: '打包并发布' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
@@ -298,7 +337,7 @@ describe('GameDistributionPublishPanel', () => {
target: { files: [buildImageFile('shot-1.png')] },
});
await screen.findByRole('button', { name: '删除截图 1' });
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
fireEvent.click(screen.getByRole('button', { name: '打包并发布' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
@@ -335,7 +374,7 @@ describe('GameDistributionPublishPanel', () => {
renderPanel();
await selectCover();
const submit = screen.getByRole('button', { name: '发布游戏' });
const submit = screen.getByRole('button', { name: '打包并发布' });
fireEvent.click(submit);
fireEvent.click(submit);
@@ -362,7 +401,7 @@ describe('GameDistributionPublishPanel', () => {
renderPanel();
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
fireEvent.click(screen.getByRole('button', { name: '打包并发布' }));
expect(
await screen.findByText(
@@ -376,7 +415,7 @@ describe('GameDistributionPublishPanel', () => {
installTauriInvoke(async () => undefined);
renderPanel();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
fireEvent.click(screen.getByRole('button', { name: '打包并发布' }));
expect(
await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
@@ -398,7 +437,7 @@ describe('GameDistributionPublishPanel', () => {
}),
);
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
fireEvent.click(screen.getByRole('button', { name: '打包并发布' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
@@ -499,7 +538,7 @@ describe('GameDistributionPublishPanel', () => {
expect(screen.queryByRole('dialog', { name: '截图预览' })).toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
fireEvent.click(screen.getByRole('button', { name: '打包并发布' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
@@ -541,19 +580,23 @@ describe('GameDistributionPublishPanel', () => {
expect(publishLocalProjectGame).not.toHaveBeenCalled();
});
test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => {
test('缺少封面或试玩包时失败关闭且不调用发布接口', async () => {
renderPanel();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
expect(await screen.findByText('需要在 Tauri App 内发布')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打包并发布' }));
// 校验前置:没有封面时在点击当下就给出提示,不进入打包也不提交发布。
expect(
await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
).not.toBeNull();
expect(publishLocalProjectGame).not.toHaveBeenCalled();
cleanup();
installTauriInvoke(async () => undefined);
renderPanel({ packageResult: null });
expect(screen.getByRole('button', { name: '发布游戏' })).toHaveProperty(
'disabled',
true,
);
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '打包并发布' }));
// 没有试玩包、也没有打包回调时,同样在点击当下就地给出原因,而不是灰掉按钮。
expect(await screen.findByText('请先导出有效的试玩包')).not.toBeNull();
expect(publishLocalProjectGame).not.toHaveBeenCalled();
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
});
});