diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx
new file mode 100644
index 000000000..680203c86
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishFormView.tsx
@@ -0,0 +1,513 @@
+import { Eye, Trash2 } from 'lucide-react';
+
+import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution';
+import {
+ type GameDistributionPublishMetadata,
+ MAX_AGC_GAME_SCREENSHOTS,
+} from '../../services/gameDistributionPublish';
+import { ThemedModal } from '../modal/ThemedModal';
+import {
+ type GameDistributionPublishFormState,
+ PANEL_IMAGE_ACCEPT,
+} from './useGameDistributionPublishForm';
+
+/**
+ * 发布资料表单的表现层:弹窗与工作台内联共用同一份 DOM 结构。
+ *
+ * 状态全部来自 `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,
+ setSummary,
+ category,
+ setCategory,
+ busy,
+ error,
+ result,
+ cover,
+ setCover,
+ screenshots,
+ previewScreenshot,
+ setPreviewScreenshot,
+ uploadingLabel,
+ metadataSuggestionPending,
+ metadataSuggestionReady,
+ coverConfirmOpen,
+ setCoverConfirmOpen,
+ generatingCover,
+ coverGenerationPrice,
+ coverGenerationDetail,
+ coverInputRef,
+ screenshotInputRef,
+ summaryTouchedRef,
+ categoryTouchedRef,
+ activeScreenshotCount,
+ screenshotsUploading,
+ coverPriceLabel,
+ handleCoverSelected,
+ handleGenerateCover,
+ handleScreenshotsSelected,
+ removeScreenshot,
+ handleSubmit,
+ handlePackageAndPublish,
+ } = form;
+
+ const fieldsAndMedia = (
+ <>
+
+
+
+
+
+ {metadataSuggestionPending
+ ? '正在根据创作内容生成简介和分类…'
+ : metadataSuggestionReady
+ ? 'AI 已根据创作内容生成简介和分类,免费;你可以直接修改。'
+ : '已根据创作内容预填简介和分类,免费;你可以直接修改。'}
+
+
+
+
+
+ {cover?.previewUrl ? (
+

+ ) : (
+
{cover ? '封面已上传' : '还没有封面'}
+ )}
+
+
+
+ 游戏封面(必需)
+
+
+
+
+ {cover ? (
+
+ ) : null}
+
+
+ {cover
+ ? `已选择「${cover.name}」;重新选择即可替换封面。`
+ : '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
+
+ {coverGenerationDetail ? (
+
+ {coverGenerationDetail}
+
+ ) : null}
+
+
+
+ {screenshots.length > 0 ? (
+
+ {screenshots.map((item, index) => (
+ -
+
+ {item.previewUrl ? (
+

+ ) : (
+
{`截图 ${index + 1}`}
+ )}
+ {item.status === 'uploading' ? (
+
+ 上传中…
+
+ ) : null}
+ {item.status === 'failed' ? (
+ <>
+
+ {item.error || '截图上传失败'}
+
+
+
+
+ >
+ ) : null}
+ {item.status === 'ready' ? (
+
+
+
+
+
+ ) : null}
+
+
+ ))}
+
+ ) : null}
+ {uploadingLabel ? (
+
+ {uploadingLabel}
+
+ ) : null}
+
+ >
+ );
+
+ const coverConfirmDialog = (
+ setCoverConfirmOpen(false)}
+ panelClassName="game-publish-progress-dialog game-publish-cover-confirm-dialog"
+ panelStyle={{ background: '#fffaf7', color: '#4f362d' }}
+ >
+
+ ✦
+
+ AI 生成游戏封面
+
+ 将根据当前项目上下文生成 16:9 游戏封面,并自动设为发布封面。
+ {coverGenerationPrice === null
+ ? '本次生成会消耗泥点。'
+ : `本次生成预计消耗 ${coverGenerationPrice} 泥点。`}
+
+
+
+
+
+
+ );
+
+ const previewDialog = (
+ setPreviewScreenshot(null)}
+ panelClassName="game-distribution-publish-panel__preview-dialog"
+ panelStyle={{ background: '#1d1714', color: '#fffaf7' }}
+ >
+ {previewScreenshot?.previewUrl ? (
+
+ ) : (
+ 当前截图没有可用的预览图。
+ )}
+
+ );
+
+ if (inline) {
+ return (
+
+
+
+ 发布到游戏广场
+
+
+ 让玩家现在就能试玩
+
+
+ {result ? (
+
+
已提交审核
+
版本已进入审核队列,审核通过后才会在游戏广场公开展示。
+
+ 版本 {result.versionNumber} ·{' '}
+ {result.packageSizeBytes.toLocaleString()} B ·{' '}
+ {result.packageSha256.slice(0, 16)}…
+
+
+ ) : (
+ <>
+ {fieldsAndMedia}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ >
+ )}
+ {coverConfirmDialog}
+ {previewDialog}
+
+ );
+ }
+
+ return (
+ <>
+ undefined : onClose}
+ panelClassName="game-distribution-publish-panel"
+ closeOnBackdrop={!busy && !generatingCover}
+ closeOnEscape={!busy && !generatingCover}
+ >
+
+ {result ? (
+
+
已提交审核
+
版本已进入审核队列,审核通过后才会在游戏广场公开展示。
+
+ 版本 {result.versionNumber} ·{' '}
+ {result.packageSizeBytes.toLocaleString()} B ·{' '}
+ {result.packageSha256.slice(0, 16)}…
+
+
+
+
+
+ ) : (
+ <>
+ {fieldsAndMedia}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+ >
+ )}
+
+ {coverConfirmDialog}
+ {previewDialog}
+ >
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
index 23524532f..56b954f8b 100644
--- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
+++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
@@ -1,149 +1,15 @@
-import { Eye, Trash2 } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
-
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
-import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution';
-import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
-import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectExportPackageResult } from '../../app/types';
-import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload';
-import {
- createGameDistributionPublishKey,
- type GameDistributionPublishMetadata,
- type GameDistributionPublishResult,
- generateGameDistributionCover,
- MAX_AGC_GAME_SCREENSHOTS,
- publishLocalProjectGame,
- readGameCoverGenerationPrice,
- suggestGameDistributionPublishMetadata,
-} 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;
-};
-
-type PanelScreenshotEntry = {
- id: string;
- name: string;
- previewUrl: string;
- assetObjectId: string | null;
- status: 'uploading' | 'ready' | 'failed';
- error: string | null;
- file: File;
-};
-
-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 '';
- }
-}
-
-function createPanelScreenshotId() {
- const randomUuid =
- typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
- ? crypto.randomUUID()
- : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
- return `screenshot-${randomUuid}`;
-}
-
-const COVER_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
-const COVER_GENERATION_ASPECT_RATIO = '16:9';
-const COVER_GENERATION_IMAGE_SIZE = '2K';
-
-function buildPublishProjectContext(manifest: GameCreationAppManifest) {
- const taskSummary = (manifest.tasks ?? [])
- .slice(0, 16)
- .map((task) => `${task.title}(${task.status})`)
- .join('、');
- const assetSummary = (manifest.assets ?? [])
- .slice(0, 24)
- .map((asset) => `${asset.kind}:${contextSafeAssetLabel(asset.localPath)}`)
- .join('、');
- const versionSummary = (manifest.versions ?? [])
- .slice(-3)
- .map((version) => version.editPrompt?.trim())
- .filter(Boolean)
- .join(';');
- return [
- `当前任务与状态:${taskSummary || '暂无'}`,
- `现有素材:${assetSummary || '暂无'}`,
- manifest.preview?.status
- ? `运行视图:${manifest.preview.status}`
- : '运行视图:未运行',
- versionSummary ? `最近编辑:${versionSummary}` : '',
- ]
- .filter(Boolean)
- .join('\n')
- .slice(0, 6_000);
-}
+import type { GameDistributionPublishResult } from '../../services/gameDistributionPublish';
+import { GameDistributionPublishFormView } from './GameDistributionPublishFormView';
+import { useGameDistributionPublishForm } from './useGameDistributionPublishForm';
/**
- * 送进模型的项目上下文必须脱敏:素材只带文件名,不带目录片段。
+ * 发布资料表单的弹窗外壳:点按钮弹出的独立面板。
*
- * 素材清单里的 `localPath` 正常是项目内相对路径,但登记来源不受本地约束时也可能是
- * 绝对路径(含盘符或用户目录)——`C:\Users\<用户名>\...\assets\x.png` 这种一旦进提示词
- * 就等于把本地目录结构发给了平台模型。这里统一取最后一段并限长。
+ * 表单状态在 `useGameDistributionPublishForm`;工作台内联形态把它提升到导出面板根
+ * (`useTaonierTab`),切 tab 不丢。这里保留「弹窗 + 关闭」的入口形态。
*/
-function contextSafeAssetLabel(value: string) {
- const normalized = value.trim().replaceAll('\\', '/');
- const base = normalized.split('/').filter(Boolean).pop() ?? '';
- return base.slice(0, 80);
-}
-
-function buildCoverGenerationPrompt(
- manifest: GameCreationAppManifest,
- summary: string,
-) {
- const goal = manifest.goal?.trim() || summary.trim();
- return [
- `为游戏《${manifest.name.trim() || '未命名游戏'}》生成一张游戏广场发行封面。`,
- goal ? `创作目标与核心体验:${goal}` : '',
- '主体画面需要体现游戏的核心玩法、题材氛围与代表性元素,构图清晰,适合作为游戏卡片和详情页首图。',
- '16:9 横版构图,主体居中偏上,边缘保留安全区;不要生成文字、水印、平台标识、边框或界面截图。',
- ]
- .filter(Boolean)
- .join('\n');
-}
-
export function GameDistributionPublishPanel({
open,
projectPath,
@@ -159,735 +25,19 @@ export function GameDistributionPublishPanel({
onClose: () => void;
onPublished?: (result: GameDistributionPublishResult) => void;
}) {
- const [title, setTitle] = useState('');
- const [summary, setSummary] = useState('');
- const [category, setCategory] =
- useState('其他');
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState('');
- const [result, setResult] = useState(
- null,
- );
- const [cover, setCover] = useState(null);
- const [screenshots, setScreenshots] = useState([]);
- const [previewScreenshot, setPreviewScreenshot] =
- useState(null);
- const [uploadingLabel, setUploadingLabel] = useState('');
- const [metadataSuggestionPending, setMetadataSuggestionPending] =
- useState(false);
- const [metadataSuggestionReady, setMetadataSuggestionReady] = useState(false);
- const [coverConfirmOpen, setCoverConfirmOpen] = useState(false);
- const [generatingCover, setGeneratingCover] = useState(false);
- const [coverGenerationPrice, setCoverGenerationPrice] = useState<
- number | null
- >(null);
- const [coverGenerationDetail, setCoverGenerationDetail] = useState('');
- const publishIdempotencyKeyRef = useRef('');
- const metadataSuggestionRequestRef = useRef(0);
- const summaryTouchedRef = useRef(false);
- const categoryTouchedRef = useRef(false);
- const coverInputRef = useRef(null);
- const screenshotInputRef = useRef(null);
- // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。
- const uploadedAssetCacheRef = useRef(new Map());
- const objectUrlsRef = useRef(new Set());
-
- useEffect(() => {
- if (!open) return;
- const fallbackSummary = (manifest.goal ?? '由陶泥儿创作的可在线游玩游戏')
- .trim()
- .slice(0, 120);
- setTitle(manifest.name.trim());
- setSummary(fallbackSummary);
- setCategory('其他');
- setBusy(false);
- setError('');
- setResult(null);
- setMetadataSuggestionPending(false);
- setMetadataSuggestionReady(false);
- setCoverConfirmOpen(false);
- setGeneratingCover(false);
- setCoverGenerationPrice(null);
- setCoverGenerationDetail('');
- setPreviewScreenshot(null);
- summaryTouchedRef.current = false;
- categoryTouchedRef.current = false;
-
- const requestId = ++metadataSuggestionRequestRef.current;
- setMetadataSuggestionPending(true);
- void suggestGameDistributionPublishMetadata({
- name: manifest.name,
- goal: manifest.goal,
- context: buildPublishProjectContext(manifest),
- })
- .then((suggestion) => {
- if (requestId !== metadataSuggestionRequestRef.current) return;
- if (!summaryTouchedRef.current && suggestion.summary.trim()) {
- setSummary(suggestion.summary.trim().slice(0, 120));
- }
- if (!categoryTouchedRef.current && suggestion.category) {
- setCategory(suggestion.category);
- }
- setMetadataSuggestionReady(true);
- })
- .catch(() => {
- // 免费增强能力失败时保留本地兜底,不阻断发布。
- })
- .finally(() => {
- if (requestId === metadataSuggestionRequestRef.current) {
- setMetadataSuggestionPending(false);
- }
- });
- }, [manifest, open, packageResult?.packageRelativePath]);
-
- useEffect(() => {
- if (open) {
- publishIdempotencyKeyRef.current = createGameDistributionPublishKey();
- }
- }, [open, packageResult?.packageRelativePath]);
-
- useEffect(() => {
- if (!open) return;
- let cancelled = false;
- void readGameCoverGenerationPrice({
- model: COVER_GENERATION_MODEL,
- imageSize: COVER_GENERATION_IMAGE_SIZE,
- })
- .then((price) => {
- if (!cancelled) setCoverGenerationPrice(price);
- })
- .catch(() => {
- if (!cancelled) setCoverGenerationPrice(null);
- });
- return () => {
- cancelled = true;
- };
- }, [open]);
-
- 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 handleGenerateCover() {
- setCoverConfirmOpen(false);
- setError('');
- setCoverGenerationDetail('正在根据项目内容生成封面…');
- setGeneratingCover(true);
- try {
- const generated = await generateGameDistributionCover({
- prompt: buildCoverGenerationPrompt(manifest, summary),
- model: COVER_GENERATION_MODEL,
- aspectRatio: COVER_GENERATION_ASPECT_RATIO,
- imageSize: COVER_GENERATION_IMAGE_SIZE,
- assetLabel: `${manifest.name.trim() || '游戏'}封面`,
- });
- setCover({
- signature: generated.taskId
- ? `generated:${generated.taskId}`
- : `generated:${generated.assetObjectId}`,
- name: 'AI 生成封面',
- assetObjectId: generated.assetObjectId,
- previewUrl: generated.previewUrl,
- });
- setCoverGenerationDetail(
- '封面已生成并自动设为发布封面;重新生成会再次消耗泥点。',
- );
- } catch (generationError) {
- setCoverGenerationDetail('');
- setError(
- generationError instanceof Error
- ? generationError.message
- : '生成游戏封面失败,请重试',
- );
- } finally {
- setGeneratingCover(false);
- }
- }
-
- function createPanelScreenshotEntry(file: File): PanelScreenshotEntry {
- const fileError = resolvePanelImageFileError(file, 'screenshot');
- return {
- id: createPanelScreenshotId(),
- name: file.name.trim() || '游戏截图',
- previewUrl: buildPanelImagePreviewUrl(file),
- assetObjectId: null,
- status: fileError ? 'failed' : 'uploading',
- error: fileError || null,
- file,
- };
- }
-
- function removeScreenshot(id: string) {
- setScreenshots((current) => {
- const target = current.find((item) => item.id === id);
- if (target?.previewUrl && objectUrlsRef.current.has(target.previewUrl)) {
- try {
- URL.revokeObjectURL(target.previewUrl);
- } catch {
- // 预览地址释放失败不影响移除截图。
- }
- objectUrlsRef.current.delete(target.previewUrl);
- }
- return current.filter((item) => item.id !== id);
- });
- setPreviewScreenshot((current) => (current?.id === id ? null : current));
- }
-
- async function handleScreenshotsSelected(files: File[]) {
- if (files.length === 0) return;
- const counted = screenshots.filter(
- (item) => item.status !== 'failed',
- ).length;
- const remaining = MAX_AGC_GAME_SCREENSHOTS - counted;
- if (files.length > remaining) {
- setError(
- remaining > 0
- ? `游戏截图最多 6 张,还可以再选 ${remaining} 张`
- : '游戏截图最多 6 张',
- );
- if (screenshotInputRef.current) screenshotInputRef.current.value = '';
- return;
- }
- setError('');
- const entries = files.map(createPanelScreenshotEntry);
- setScreenshots((current) => [...current, ...entries]);
-
- // 每张截图独立上传、独立失败;某一张失败只标记该张,不打断同批其它截图。
- await Promise.all(
- entries.map(async (entry) => {
- if (entry.status === 'failed') return;
- try {
- const assetObjectId = await resolveAssetObjectId(
- entry.file,
- 'screenshot',
- );
- setScreenshots((current) =>
- current.map((item) =>
- item.id === entry.id
- ? {
- ...item,
- status: 'ready',
- assetObjectId,
- error: null,
- }
- : item,
- ),
- );
- } catch (uploadError) {
- setScreenshots((current) =>
- current.map((item) =>
- item.id === entry.id
- ? {
- ...item,
- status: 'failed',
- assetObjectId: null,
- error:
- uploadError instanceof Error
- ? uploadError.message
- : '截图上传失败,请删除后重试',
- }
- : item,
- ),
- );
- }
- }),
- );
- if (screenshotInputRef.current) screenshotInputRef.current.value = '';
- }
-
- async function handleSubmit() {
- if (!packageResult || !projectPath.trim()) {
- setError('请先导出有效的试玩包');
- return;
- }
- const invoke = resolveTauriInvoke();
- if (!invoke) {
- setError('需要在 Tauri App 内发布');
- return;
- }
- if (!cover) {
- setError('请先选择游戏封面(JPG/PNG/WebP)');
- return;
- }
- if (
- uploadingLabel ||
- screenshots.some((item) => item.status === 'uploading')
- ) {
- setError('素材还在上传中,请稍候再发布');
- return;
- }
- setBusy(true);
- setError('');
- try {
- const next = await publishLocalProjectGame({
- invoke,
- projectPath,
- packageRelativePath: packageResult.packageRelativePath,
- manifest,
- metadata: {
- title,
- summary,
- category,
- coverAssetId: cover.assetObjectId,
- screenshots: screenshots
- .filter(
- (
- item,
- ): item is PanelScreenshotEntry & {
- assetObjectId: string;
- } => item.status === 'ready' && Boolean(item.assetObjectId),
- )
- .map((item) => item.assetObjectId),
- },
- idempotencyKey: publishIdempotencyKeyRef.current,
- });
- setResult(next);
- onPublished?.(next);
- } catch (nextError) {
- setError(
- nextError instanceof Error ? nextError.message : String(nextError),
- );
- } finally {
- setBusy(false);
- }
- }
-
- const activeScreenshotCount = screenshots.filter(
- (item) => item.status !== 'failed',
- ).length;
- const screenshotsUploading = screenshots.some(
- (item) => item.status === 'uploading',
- );
- const coverPriceLabel =
- coverGenerationPrice === null ? '消耗泥点' : `${coverGenerationPrice} 泥点`;
-
+ const form = useGameDistributionPublishForm({
+ open,
+ inline: false,
+ projectPath,
+ manifest,
+ packageResult,
+ onPublished,
+ });
return (
- <>
- undefined : onClose}
- panelClassName="game-distribution-publish-panel"
- closeOnBackdrop={!busy && !generatingCover}
- closeOnEscape={!busy && !generatingCover}
- >
-
- {result ? (
-
-
已提交审核
-
版本已进入审核队列,审核通过后才会在游戏广场公开展示。
-
- 版本 {result.versionNumber} ·{' '}
- {result.packageSizeBytes.toLocaleString()} B ·{' '}
- {result.packageSha256.slice(0, 16)}…
-
-
-
-
-
- ) : (
- <>
-
-
-
-
-
- {metadataSuggestionPending
- ? '正在根据创作内容生成简介和分类…'
- : metadataSuggestionReady
- ? 'AI 已根据创作内容生成简介和分类,免费;你可以直接修改。'
- : '已根据创作内容预填简介和分类,免费;你可以直接修改。'}
-
-
-
-
-
- {cover?.previewUrl ? (
-

- ) : (
-
{cover ? '封面已上传' : '还没有封面'}
- )}
-
-
-
- 游戏封面(必需)
-
-
-
-
- {cover ? (
-
- ) : null}
-
-
- {cover
- ? `已选择「${cover.name}」;重新选择即可替换封面。`
- : '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
-
- {coverGenerationDetail ? (
-
- {coverGenerationDetail}
-
- ) : null}
-
-
-
- {screenshots.length > 0 ? (
-
- {screenshots.map((item, index) => (
- -
-
- {item.previewUrl ? (
-

- ) : (
-
{`截图 ${index + 1}`}
- )}
- {item.status === 'uploading' ? (
-
- 上传中…
-
- ) : null}
- {item.status === 'failed' ? (
- <>
-
- {item.error || '截图上传失败'}
-
-
-
-
- >
- ) : null}
- {item.status === 'ready' ? (
-
-
-
-
-
- ) : null}
-
-
- ))}
-
- ) : null}
- {uploadingLabel ? (
-
- {uploadingLabel}
-
- ) : null}
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
-
-
- >
- )}
-
- setCoverConfirmOpen(false)}
- panelClassName="game-publish-progress-dialog game-publish-cover-confirm-dialog"
- panelStyle={{ background: '#fffaf7', color: '#4f362d' }}
- >
-
- ✦
-
- AI 生成游戏封面
-
- 将根据当前项目上下文生成 16:9 游戏封面,并自动设为发布封面。
- {coverGenerationPrice === null
- ? '本次生成会消耗泥点。'
- : `本次生成预计消耗 ${coverGenerationPrice} 泥点。`}
-
-
-
-
-
-
- setPreviewScreenshot(null)}
- panelClassName="game-distribution-publish-panel__preview-dialog"
- panelStyle={{ background: '#1d1714', color: '#fffaf7' }}
- >
- {previewScreenshot?.previewUrl ? (
-
- ) : (
- 当前截图没有可用的预览图。
- )}
-
- >
+
);
}
diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts
new file mode 100644
index 000000000..2c9090b31
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/components/game-distribution/useGameDistributionPublishForm.ts
@@ -0,0 +1,630 @@
+import { useEffect, useRef, useState } from 'react';
+
+import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
+import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
+import { resolveTauriInvoke } from '../../app/tauri';
+import type { LocalProjectExportPackageResult } from '../../app/types';
+import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload';
+import {
+ createGameDistributionPublishKey,
+ type GameDistributionPublishMetadata,
+ type GameDistributionPublishResult,
+ generateGameDistributionCover,
+ MAX_AGC_GAME_SCREENSHOTS,
+ publishLocalProjectGame,
+ readGameCoverGenerationPrice,
+ suggestGameDistributionPublishMetadata,
+} from '../../services/gameDistributionPublish';
+
+/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */
+const PANEL_IMAGE_MAX_BYTES = 6 * 1024 * 1024;
+export 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;
+};
+
+type PanelScreenshotEntry = {
+ id: string;
+ name: string;
+ previewUrl: string;
+ assetObjectId: string | null;
+ status: 'uploading' | 'ready' | 'failed';
+ error: string | null;
+ file: File;
+};
+
+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 '';
+ }
+}
+
+function createPanelScreenshotId() {
+ const randomUuid =
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ return `screenshot-${randomUuid}`;
+}
+
+const COVER_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
+const COVER_GENERATION_ASPECT_RATIO = '16:9';
+const COVER_GENERATION_IMAGE_SIZE = '2K';
+
+function buildPublishProjectContext(manifest: GameCreationAppManifest) {
+ const taskSummary = (manifest.tasks ?? [])
+ .slice(0, 16)
+ .map((task) => `${task.title}(${task.status})`)
+ .join('、');
+ const assetSummary = (manifest.assets ?? [])
+ .slice(0, 24)
+ .map((asset) => `${asset.kind}:${contextSafeAssetLabel(asset.localPath)}`)
+ .join('、');
+ const versionSummary = (manifest.versions ?? [])
+ .slice(-3)
+ .map((version) => version.editPrompt?.trim())
+ .filter(Boolean)
+ .join(';');
+ return [
+ `当前任务与状态:${taskSummary || '暂无'}`,
+ `现有素材:${assetSummary || '暂无'}`,
+ manifest.preview?.status
+ ? `运行视图:${manifest.preview.status}`
+ : '运行视图:未运行',
+ versionSummary ? `最近编辑:${versionSummary}` : '',
+ ]
+ .filter(Boolean)
+ .join('\n')
+ .slice(0, 6_000);
+}
+
+/**
+ * 送进模型的项目上下文必须脱敏:素材只带文件名,不带目录片段。
+ *
+ * 素材清单里的 `localPath` 正常是项目内相对路径,但登记来源不受本地约束时也可能是
+ * 绝对路径(含盘符或用户目录)——`C:\Users\<用户名>\...\assets\x.png` 这种一旦进提示词
+ * 就等于把本地目录结构发给了平台模型。这里统一取最后一段并限长。
+ */
+function contextSafeAssetLabel(value: string) {
+ const normalized = value.trim().replaceAll('\\', '/');
+ const base = normalized.split('/').filter(Boolean).pop() ?? '';
+ return base.slice(0, 80);
+}
+
+function buildCoverGenerationPrompt(
+ manifest: GameCreationAppManifest,
+ summary: string,
+) {
+ const goal = manifest.goal?.trim() || summary.trim();
+ return [
+ `为游戏《${manifest.name.trim() || '未命名游戏'}》生成一张游戏广场发行封面。`,
+ goal ? `创作目标与核心体验:${goal}` : '',
+ '主体画面需要体现游戏的核心玩法、题材氛围与代表性元素,构图清晰,适合作为游戏卡片和详情页首图。',
+ '16:9 横版构图,主体居中偏上,边缘保留安全区;不要生成文字、水印、平台标识、边框或界面截图。',
+ ]
+ .filter(Boolean)
+ .join('\n');
+}
+
+export type GameDistributionPublishFormState = ReturnType<
+ typeof useGameDistributionPublishForm
+>;
+
+/**
+ * 发布资料表单的全部状态与动作。
+ *
+ * 弹窗形态由 `GameDistributionPublishPanel` 自己调用;工作台内联形态把它提升到导出面板根
+ * (`useTaonierTab`),这样切走 tab、页面组件卸载也不会丢掉已经填好的发布资料。
+ *
+ * `inline` 决定初始化开关:弹窗每次带着新试玩包打开都重来;内联常驻左栏,只在换项目时重来。
+ */
+export function useGameDistributionPublishForm({
+ open,
+ inline,
+ projectPath,
+ manifest,
+ packageResult,
+ onPublished,
+ onEnsurePackage,
+}: {
+ open: boolean;
+ inline: boolean;
+ projectPath: string;
+ manifest: GameCreationAppManifest | null;
+ packageResult: LocalProjectExportPackageResult | null;
+ onPublished?: (result: GameDistributionPublishResult) => void;
+ onEnsurePackage?: () => Promise;
+}) {
+ const manifestRef = useRef(manifest);
+ manifestRef.current = manifest;
+ const [title, setTitle] = useState('');
+ const [summary, setSummary] = useState('');
+ const [category, setCategory] =
+ useState('其他');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [result, setResult] = useState(
+ null,
+ );
+ const [cover, setCover] = useState(null);
+ const [screenshots, setScreenshots] = useState([]);
+ const [previewScreenshot, setPreviewScreenshot] =
+ useState(null);
+ const [uploadingLabel, setUploadingLabel] = useState('');
+ const [metadataSuggestionPending, setMetadataSuggestionPending] =
+ useState(false);
+ const [metadataSuggestionReady, setMetadataSuggestionReady] = useState(false);
+ const [coverConfirmOpen, setCoverConfirmOpen] = useState(false);
+ const [generatingCover, setGeneratingCover] = useState(false);
+ const [coverGenerationPrice, setCoverGenerationPrice] = useState<
+ number | null
+ >(null);
+ const [coverGenerationDetail, setCoverGenerationDetail] = useState('');
+ const publishIdempotencyKeyRef = useRef('');
+ const metadataSuggestionRequestRef = useRef(0);
+ const summaryTouchedRef = useRef(false);
+ const categoryTouchedRef = useRef(false);
+ const coverInputRef = useRef(null);
+ const screenshotInputRef = useRef(null);
+ // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。
+ const uploadedAssetCacheRef = useRef(new Map());
+ const objectUrlsRef = useRef(new Set());
+
+ /**
+ * 表单初始化开关:弹窗每次带着新试玩包打开都要重来;内联常驻左栏,只在换项目时重来,
+ * 不能因为「打包完包结果变化」就把用户已经填好的资料清掉。
+ */
+ const resetKey = inline
+ ? 'inline'
+ : (packageResult?.packageRelativePath ?? '');
+
+ useEffect(() => {
+ if (!open) return;
+ const currentManifest = manifestRef.current;
+ if (!currentManifest) return;
+ const fallbackSummary = (
+ currentManifest.goal ?? '由陶泥儿创作的可在线游玩游戏'
+ )
+ .trim()
+ .slice(0, 120);
+ setTitle(currentManifest.name.trim());
+ setSummary(fallbackSummary);
+ setCategory('其他');
+ setBusy(false);
+ setError('');
+ setResult(null);
+ setMetadataSuggestionPending(false);
+ setMetadataSuggestionReady(false);
+ setCoverConfirmOpen(false);
+ setGeneratingCover(false);
+ setCoverGenerationPrice(null);
+ setCoverGenerationDetail('');
+ setPreviewScreenshot(null);
+ summaryTouchedRef.current = false;
+ categoryTouchedRef.current = false;
+
+ const requestId = ++metadataSuggestionRequestRef.current;
+ setMetadataSuggestionPending(true);
+ void suggestGameDistributionPublishMetadata({
+ name: currentManifest.name,
+ goal: currentManifest.goal,
+ context: buildPublishProjectContext(currentManifest),
+ })
+ .then((suggestion) => {
+ if (requestId !== metadataSuggestionRequestRef.current) return;
+ if (!summaryTouchedRef.current && suggestion.summary.trim()) {
+ setSummary(suggestion.summary.trim().slice(0, 120));
+ }
+ if (!categoryTouchedRef.current && suggestion.category) {
+ setCategory(suggestion.category);
+ }
+ setMetadataSuggestionReady(true);
+ })
+ .catch(() => {
+ // 免费增强能力失败时保留本地兜底,不阻断发布。
+ })
+ .finally(() => {
+ if (requestId === metadataSuggestionRequestRef.current) {
+ setMetadataSuggestionPending(false);
+ }
+ });
+ }, [manifest?.projectId, open, resetKey]);
+
+ useEffect(() => {
+ if (open) {
+ publishIdempotencyKeyRef.current = createGameDistributionPublishKey();
+ }
+ }, [open, packageResult?.packageRelativePath]);
+
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ void readGameCoverGenerationPrice({
+ model: COVER_GENERATION_MODEL,
+ imageSize: COVER_GENERATION_IMAGE_SIZE,
+ })
+ .then((price) => {
+ if (!cancelled) setCoverGenerationPrice(price);
+ })
+ .catch(() => {
+ if (!cancelled) setCoverGenerationPrice(null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open]);
+
+ 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 handleGenerateCover() {
+ if (!manifest) {
+ setError('项目清单尚未就绪,请稍后重试');
+ return;
+ }
+ setCoverConfirmOpen(false);
+ setError('');
+ setCoverGenerationDetail('正在根据项目内容生成封面…');
+ setGeneratingCover(true);
+ try {
+ const generated = await generateGameDistributionCover({
+ prompt: buildCoverGenerationPrompt(manifest, summary),
+ model: COVER_GENERATION_MODEL,
+ aspectRatio: COVER_GENERATION_ASPECT_RATIO,
+ imageSize: COVER_GENERATION_IMAGE_SIZE,
+ assetLabel: `${manifest.name.trim() || '游戏'}封面`,
+ });
+ setCover({
+ signature: generated.taskId
+ ? `generated:${generated.taskId}`
+ : `generated:${generated.assetObjectId}`,
+ name: 'AI 生成封面',
+ assetObjectId: generated.assetObjectId,
+ previewUrl: generated.previewUrl,
+ });
+ setCoverGenerationDetail(
+ '封面已生成并自动设为发布封面;重新生成会再次消耗泥点。',
+ );
+ } catch (generationError) {
+ setCoverGenerationDetail('');
+ setError(
+ generationError instanceof Error
+ ? generationError.message
+ : '生成游戏封面失败,请重试',
+ );
+ } finally {
+ setGeneratingCover(false);
+ }
+ }
+
+ function createPanelScreenshotEntry(file: File): PanelScreenshotEntry {
+ const fileError = resolvePanelImageFileError(file, 'screenshot');
+ return {
+ id: createPanelScreenshotId(),
+ name: file.name.trim() || '游戏截图',
+ previewUrl: buildPanelImagePreviewUrl(file),
+ assetObjectId: null,
+ status: fileError ? 'failed' : 'uploading',
+ error: fileError || null,
+ file,
+ };
+ }
+
+ function removeScreenshot(id: string) {
+ setScreenshots((current) => {
+ const target = current.find((item) => item.id === id);
+ if (target?.previewUrl && objectUrlsRef.current.has(target.previewUrl)) {
+ try {
+ URL.revokeObjectURL(target.previewUrl);
+ } catch {
+ // 预览地址释放失败不影响移除截图。
+ }
+ objectUrlsRef.current.delete(target.previewUrl);
+ }
+ return current.filter((item) => item.id !== id);
+ });
+ setPreviewScreenshot((current) => (current?.id === id ? null : current));
+ }
+
+ async function handleScreenshotsSelected(files: File[]) {
+ if (files.length === 0) return;
+ const counted = screenshots.filter(
+ (item) => item.status !== 'failed',
+ ).length;
+ const remaining = MAX_AGC_GAME_SCREENSHOTS - counted;
+ if (files.length > remaining) {
+ setError(
+ remaining > 0
+ ? `游戏截图最多 6 张,还可以再选 ${remaining} 张`
+ : '游戏截图最多 6 张',
+ );
+ if (screenshotInputRef.current) screenshotInputRef.current.value = '';
+ return;
+ }
+ setError('');
+ const entries = files.map(createPanelScreenshotEntry);
+ setScreenshots((current) => [...current, ...entries]);
+
+ // 每张截图独立上传、独立失败;某一张失败只标记该张,不打断同批其它截图。
+ await Promise.all(
+ entries.map(async (entry) => {
+ if (entry.status === 'failed') return;
+ try {
+ const assetObjectId = await resolveAssetObjectId(
+ entry.file,
+ 'screenshot',
+ );
+ setScreenshots((current) =>
+ current.map((item) =>
+ item.id === entry.id
+ ? {
+ ...item,
+ status: 'ready',
+ assetObjectId,
+ error: null,
+ }
+ : item,
+ ),
+ );
+ } catch (uploadError) {
+ setScreenshots((current) =>
+ current.map((item) =>
+ item.id === entry.id
+ ? {
+ ...item,
+ status: 'failed',
+ assetObjectId: null,
+ error:
+ uploadError instanceof Error
+ ? uploadError.message
+ : '截图上传失败,请删除后重试',
+ }
+ : item,
+ ),
+ );
+ }
+ }),
+ );
+ if (screenshotInputRef.current) screenshotInputRef.current.value = '';
+ }
+
+ /** 真正提交发布:包由调用方传入,不依赖 prop 更新时机。 */
+ async function submitPackage(pkg: LocalProjectExportPackageResult) {
+ if (!projectPath.trim()) {
+ setError('请先导出有效的试玩包');
+ return;
+ }
+ if (!manifest) {
+ setError('项目清单尚未就绪,请稍后重试');
+ return;
+ }
+ const invoke = resolveTauriInvoke();
+ if (!invoke) {
+ setError('需要在 Tauri App 内发布');
+ return;
+ }
+ if (!cover) {
+ setError('请先选择游戏封面(JPG/PNG/WebP)');
+ return;
+ }
+ if (
+ uploadingLabel ||
+ screenshots.some((item) => item.status === 'uploading')
+ ) {
+ setError('素材还在上传中,请稍候再发布');
+ return;
+ }
+ setBusy(true);
+ setError('');
+ try {
+ const next = await publishLocalProjectGame({
+ invoke,
+ projectPath,
+ packageRelativePath: pkg.packageRelativePath,
+ manifest,
+ metadata: {
+ title,
+ summary,
+ category,
+ coverAssetId: cover.assetObjectId,
+ screenshots: screenshots
+ .filter(
+ (
+ item,
+ ): item is PanelScreenshotEntry & {
+ assetObjectId: string;
+ } => item.status === 'ready' && Boolean(item.assetObjectId),
+ )
+ .map((item) => item.assetObjectId),
+ },
+ idempotencyKey: publishIdempotencyKeyRef.current,
+ });
+ setResult(next);
+ onPublished?.(next);
+ } catch (nextError) {
+ setError(
+ nextError instanceof Error ? nextError.message : String(nextError),
+ );
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handleSubmit() {
+ if (!packageResult) {
+ setError('请先导出有效的试玩包');
+ return;
+ }
+ await submitPackage(packageResult);
+ }
+
+ /**
+ * 内联主行动:一次点击完成「打包 → 发布」。
+ *
+ * 顺序沿用旧入口的「点击即构建打包」:先按需构建试玩包(进度 / 阻断由父层弹窗承载),
+ * 打包成功后再走提交前的表单门禁(封面、必填项、在途上传),失败只把错误留在表单里。
+ */
+ async function handlePackageAndPublish() {
+ let pkg = packageResult;
+ if (!pkg) {
+ if (!onEnsurePackage) {
+ setError('请先导出有效的试玩包');
+ return;
+ }
+ pkg = await onEnsurePackage();
+ if (!pkg) return;
+ }
+ await submitPackage(pkg);
+ }
+
+ const activeScreenshotCount = screenshots.filter(
+ (item) => item.status !== 'failed',
+ ).length;
+ const screenshotsUploading = screenshots.some(
+ (item) => item.status === 'uploading',
+ );
+ const coverPriceLabel =
+ coverGenerationPrice === null ? '消耗泥点' : `${coverGenerationPrice} 泥点`;
+
+ return {
+ packageResult,
+ title,
+ setTitle,
+ summary,
+ setSummary,
+ category,
+ setCategory,
+ busy,
+ error,
+ result,
+ cover,
+ setCover,
+ screenshots,
+ previewScreenshot,
+ setPreviewScreenshot,
+ uploadingLabel,
+ metadataSuggestionPending,
+ metadataSuggestionReady,
+ coverConfirmOpen,
+ setCoverConfirmOpen,
+ generatingCover,
+ coverGenerationPrice,
+ coverGenerationDetail,
+ coverInputRef,
+ screenshotInputRef,
+ summaryTouchedRef,
+ categoryTouchedRef,
+ activeScreenshotCount,
+ screenshotsUploading,
+ coverPriceLabel,
+ handleCoverSelected,
+ handleGenerateCover,
+ handleScreenshotsSelected,
+ removeScreenshot,
+ handleSubmit,
+ handlePackageAndPublish,
+ };
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/usePanel.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/usePanel.ts
index d9642eb8a..866d5336b 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/export/state/usePanel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/usePanel.ts
@@ -52,11 +52,18 @@ export function usePanel({
const [xiaohongshuVisited, setXiaohongshuVisited] = useState(false);
const xiaohongshuEntered =
xiaohongshuVisited || activeTarget === Target.Xiaohongshu;
+ /**
+ * 陶泥儿发布表单的初始化开关同样比 `enabled` 窄:只有真的到过陶泥儿 tab 才初始化
+ * (进入即触发表单预填与 AI 简介建议)。状态留在面板根,离开后再回来不重来。
+ */
+ const [taonierVisited, setTaonierVisited] = useState(false);
+ const taonierEntered = taonierVisited || activeTarget === Target.Taonier;
/** 切到某个目标时的共同动作:两条入口(点卡片、点 tab 条)都必须经过它。 */
const enterTarget = useCallback((target: Target) => {
setActiveTarget(target);
if (target === Target.Xiaohongshu) setXiaohongshuVisited(true);
+ if (target === Target.Taonier) setTaonierVisited(true);
}, []);
const openTarget = useCallback(
@@ -72,7 +79,10 @@ export function usePanel({
const taonier: TaonierTabState = useTaonierTab({
enabled: workspaceEntered && Boolean(projectPath),
+ formEnabled: workspaceEntered && taonierEntered && Boolean(projectPath),
projectPath,
+ ensureConversationReadAllowed,
+ ensureConversationWriteAllowed,
});
const xiaohongshu: XiaohongshuTabState = useXiaohongshuTab({
diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts
index ec37b87a2..c4f9e777a 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts
@@ -1,11 +1,20 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, 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 type { GamePublishProgressState } from '../../../../components/game-distribution/GamePublishProgressDialog';
+import { useGameDistributionPublishForm } from '../../../../components/game-distribution/useGameDistributionPublishForm';
import { readGamePublishAvailability } from '../../../../services/gameDistributionPublish';
+import {
+ type DirectProjectConversationReadGate,
+ type DirectProjectConversationWriteGate,
+ useDirectProjectChatController,
+} from '../../chat/controller/useDirectProjectChatController';
import { useDirectProjectManifest } from '../../chat/controller/useDirectProjectManifest';
+import { useDirectProjectTurnStatus } from '../../chat/controller/useDirectProjectTurnStatus';
+import { buildDirectChatTurns } from '../../chat/conversation/directTurnPresentation';
+import type { UserContentPart } from '../../chat/generated/UserContentPart';
export type TaonierTabState = ReturnType;
@@ -42,29 +51,46 @@ function appendPublishPermissionLog(projectPath: string) {
}
/**
- * 陶泥儿导出 tab 的全部状态。
+ * 陶泥儿导出 tab 的全部状态:左半的发布编排 + 右半的第二个会话。
*
- * 发布编排(构建 → 打包 → 发布资料面板)从工作台壳搬到这里:tab 页只渲染,状态挂在
- * **面板根**(`usePanel`)上,所以切到别的 tab 再切回来时试玩包结果、封面与幂等键都还在。
- * 关闭导出面板会卸载这个 hook,在途的 `export_local_project_package` 回来后由
- * `mountedRef` 丢弃,不再向已卸载的组件写状态。
+ * 这个 hook 挂在**面板根**(`usePanel`)上,不挂在 tab 页里:切到别的 tab 时页面组件会卸载,
+ * 但发布灰度、试玩包结果、进度与右半的订阅、回合、草稿必须留在面板上。
+ *
+ * 右半会话走 `useDirectProjectChatController` 内部的 `useDirectThreadChatSubscription`:
+ * 那是同一 thread 的**第二个 subscriber**,有自己的 `subscriptionId` 与游标,不与主聊天互相推进;
+ * 输入草稿同样提升到面板层,Composer 随 tab 页卸载再挂回来也不丢字。
+ *
+ * 关闭导出面板会卸载这个 hook,在途的 `export_local_project_package` 回来后由 `mountedRef` 丢弃,
+ * 不再向已卸载的组件写状态。
*/
export function useTaonierTab({
enabled,
+ formEnabled,
projectPath,
+ ensureConversationReadAllowed,
+ ensureConversationWriteAllowed,
}: {
- /** 进过一次工作台且项目路径就绪后才读灰度:进入前不产生任何后端访问。 */
+ /** 进过一次工作台且项目路径就绪后才读灰度与订阅:进入前不产生任何后端访问。 */
enabled: boolean;
+ /**
+ * 发布资料表单的初始化开关,比 `enabled` 更窄:只有真的到过陶泥儿 tab 才初始化。
+ * 表单状态仍留在面板根,切走再切回来不重来、也不丢已填内容。
+ */
+ formEnabled: boolean;
projectPath: string | null;
+ /** 项目对话读 / 写权限门:第二订阅读历史、以及从这里发送回合时都必经这两道门。 */
+ ensureConversationReadAllowed: DirectProjectConversationReadGate;
+ ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
}) {
- const { manifest } = useDirectProjectManifest(projectPath);
+ const { assets, manifest, projectId, refresh, versions } =
+ useDirectProjectManifest(projectPath);
const manifestRef = useRef(manifest);
manifestRef.current = manifest;
const [allowed, setAllowed] = useState(false);
+ const [availabilityPending, setAvailabilityPending] = useState(true);
const [packageResult, setPackageResult] =
useState(null);
- const [panelOpen, setPanelOpen] = useState(false);
const [progress, setProgress] = useState(
null,
);
@@ -82,79 +108,156 @@ export function useTaonierTab({
if (!enabled) return;
let cancelled = false;
setAllowed(false);
+ setAvailabilityPending(true);
void readGamePublishAvailability()
.then((value) => {
if (!cancelled) setAllowed(value);
})
.catch(() => {
if (!cancelled) setAllowed(false);
+ })
+ .finally(() => {
+ if (!cancelled) setAvailabilityPending(false);
});
return () => {
cancelled = true;
};
}, [enabled, projectPath]);
- const requestPublish = useCallback(async () => {
- const currentProjectPath = projectPath?.trim() ?? '';
- if (!currentProjectPath) {
- setBlockedMessage('先打开一个项目再发布');
- return;
- }
- const invoke = resolveTauriInvoke();
- if (!invoke) {
- setBlockedMessage('需要在 Tauri App 内发布');
- return;
- }
- const currentManifest = manifestRef.current;
- if (!currentManifest) {
- setBlockedMessage('项目清单尚未就绪,请稍后重试');
- return;
- }
- if (!hasRunnablePrototype(currentManifest)) {
- setBlockedMessage(
- '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
- );
- return;
- }
- setProgress({
- phase: 'running',
- message: '正在构建并打包试玩包,请稍候…',
- });
- try {
- const result = await invoke(
- 'export_local_project_package',
- { projectPath: currentProjectPath },
- );
- if (!mountedRef.current) return;
- setProgress(null);
- setPackageResult(result);
- setPanelOpen(true);
- appendPublishPermissionLog(currentProjectPath);
- } catch (error) {
- if (!mountedRef.current) return;
- setProgress({
- phase: 'failed',
- message: '发布失败',
- detail: error instanceof Error ? error.message : String(error),
- });
- }
- }, [projectPath]);
+ const [runtimeNotice, setRuntimeNotice] = useState('');
+ /**
+ * 输入草稿提升到面板层:Composer 随 tab 页卸载后,正文不再随组件一起丢。
+ * 提交成功时 Composer 内部的 `clear()` 会经 `onDraftChange` 把空草稿回执上来。
+ */
+ const [draft, setDraft] = useState([]);
+ const chat = useDirectProjectChatController({
+ enabled,
+ ensureConversationReadAllowed,
+ ensureConversationWriteAllowed,
+ onRuntimeError: setRuntimeNotice,
+ projectId,
+ projectPath,
+ refreshManifest: refresh,
+ });
+ const turns = useMemo(
+ () =>
+ buildDirectChatTurns({
+ entries: chat.directEntries,
+ localMessages: chat.localMessages,
+ turnRunning: chat.directTurnRunning,
+ // 运行中回合的起点只在这里给:`turn.started.at` 是宿主的时间,条目上要等收口才盖。
+ turnStartedAt: chat.directTurnStartedAt,
+ }),
+ [
+ chat.directEntries,
+ chat.localMessages,
+ chat.directTurnRunning,
+ chat.directTurnStartedAt,
+ ],
+ );
+ const turnStatus = useDirectProjectTurnStatus({
+ turnRunning: chat.directTurnRunning,
+ commandInFlight: chat.commandInFlight,
+ pendingTurns: chat.pendingTurns,
+ turns,
+ });
+ // 运行中回合的读秒起点:只有最新一轮且还没结束才拿实时值,历史回合是 0(不读秒)。
+ const latestTurn = turns.at(-1) ?? null;
+ const activeTurnStartedAt =
+ latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0;
+
+ /**
+ * 构建并打包试玩包,返回结果供同一次点击继续发布。
+ *
+ * 灰度未命中、缺项目、宿主缺失、原型未完成或构建失败都返回 `null`:阻断 / 失败反馈由本 hook
+ * 的 `blockedMessage` / `progress` 承载,调用方只负责不再往下走。
+ */
+ const ensurePackage =
+ useCallback(async (): Promise => {
+ const currentProjectPath = projectPath?.trim() ?? '';
+ if (!currentProjectPath) {
+ setBlockedMessage('先打开一个项目再发布');
+ return null;
+ }
+ const invoke = resolveTauriInvoke();
+ if (!invoke) {
+ setBlockedMessage('需要在 Tauri App 内发布');
+ return null;
+ }
+ const currentManifest = manifestRef.current;
+ if (!currentManifest) {
+ setBlockedMessage('项目清单尚未就绪,请稍后重试');
+ return null;
+ }
+ if (!hasRunnablePrototype(currentManifest)) {
+ setBlockedMessage(
+ '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
+ );
+ return null;
+ }
+ setProgress({
+ phase: 'running',
+ message: '正在构建并打包试玩包,请稍候…',
+ });
+ try {
+ const result = await invoke(
+ 'export_local_project_package',
+ { projectPath: currentProjectPath },
+ );
+ if (!mountedRef.current) return null;
+ setProgress(null);
+ setPackageResult(result);
+ appendPublishPermissionLog(currentProjectPath);
+ return result;
+ } catch (error) {
+ if (!mountedRef.current) return null;
+ setProgress({
+ phase: 'failed',
+ message: '发布失败',
+ detail: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+ }, [projectPath]);
- const closePanel = useCallback(() => setPanelOpen(false), []);
const closeProgress = useCallback(() => setProgress(null), []);
const closeBlocked = useCallback(() => setBlockedMessage(null), []);
+ /**
+ * 发布资料表单的状态提升到面板根:切走 tab、页面组件卸载后已填的资料仍在。
+ *
+ * 内联形态(`inline: true`)与「打包并发布」共用同一个 `ensurePackage`:一次点击先按需
+ * 构建打包,再用返回的包继续提交发布。
+ */
+ const publishForm = useGameDistributionPublishForm({
+ open: formEnabled,
+ inline: true,
+ projectPath: projectPath ?? '',
+ manifest,
+ packageResult,
+ onEnsurePackage: ensurePackage,
+ });
+
return {
enabled,
allowed,
+ availabilityPending,
manifest,
+ assets,
+ versions,
projectPath,
packageResult,
- panelOpen,
+ publishForm,
progress,
blockedMessage,
- requestPublish,
- closePanel,
+ chat,
+ draft,
+ setDraft,
+ runtimeNotice,
+ turns,
+ turnStatus,
+ activeTurnStartedAt,
+ ensurePackage,
closeProgress,
closeBlocked,
};
diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/Page.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/Page.tsx
index 9fb97a5c7..3a78260e8 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/Page.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/Page.tsx
@@ -1,61 +1,20 @@
-import { Rocket } from 'lucide-react';
-
-import { GameDistributionPublishPanel } from '../../../../../components/game-distribution/GameDistributionPublishPanel';
import { GamePublishBlockedDialog } from '../../../../../components/game-distribution/GamePublishBlockedDialog';
import { GamePublishProgressDialog } from '../../../../../components/game-distribution/GamePublishProgressDialog';
import type { TaonierTabState } from '../../state/useTaonierTab';
+import { ChatPane } from './chat/Pane';
+import { PublishPane } from './PublishPane';
/**
- * 陶泥儿导出 tab:这里是「发布到游戏广场」的唯一入口。
+ * 陶泥儿发布工作台:中缝一分为二,左半是内联发布表单与「打包并发布」,右半是另一个 chatbox。
*
- * 状态全部在面板根的 `useTaonierTab`(见 `usePanel`),这一页只负责渲染入口、挂发布资料
- * 面板与两条反馈弹窗;构建、打包、灰度与幂等都由状态 hook 持有。
+ * 左右两侧的状态都在面板根的 `useTaonierTab`:这一页跟着 tab 切走时可以卸载,发布灰度、
+ * 试玩包结果、订阅、回合与草稿都留在面板上。两条发布反馈弹窗仍由面板根的状态驱动。
*/
export function TaonierPage({ tab }: { tab: TaonierTabState }) {
- const manifest = tab.manifest;
- const busy = tab.progress?.phase === 'running';
- const canPublish = tab.allowed && Boolean(manifest) && !busy;
-
return (
-
- {manifest ? (
-
-
- 发布到游戏广场
-
-
- 先构建并打包当前项目的试玩包,再填写发行资料提交审核。
-
- {tab.allowed ? null : (
-
- 当前账号暂未开放发布入口。
-
- )}
-
-
- ) : (
-
正在读取项目清单…
- )}
- {manifest ? (
-
- ) : null}
+
+
+
+
+ {tab.availabilityPending ? '正在读取发布权限…' : '正在读取项目清单…'}
+
+
+ );
+ }
+ if (!tab.allowed) {
+ return (
+
+ );
+ }
+ return (
+
+ undefined}
+ packaging={tab.progress?.phase === 'running'}
+ />
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/chat/Pane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/chat/Pane.tsx
new file mode 100644
index 000000000..6b97f4a24
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/chat/Pane.tsx
@@ -0,0 +1,19 @@
+import type { TaonierTabState } from '../../../state/useTaonierTab';
+import { ExportChatPane } from '../../common/ChatPane';
+
+/**
+ * 陶泥儿发布工作台右半 chatbox:与左侧发布表单并列的第二个订阅会话。
+ *
+ * 会话真相在面板根的 `useTaonierTab`:订阅、回合与草稿都留在面板上,切走再切回来还是同一条
+ * 会话、同一份草稿。
+ */
+export function ChatPane({ tab }: { tab: TaonierTabState }) {
+ return (
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx
index 58ad289d2..f7192fba6 100644
--- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx
+++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx
@@ -2,8 +2,8 @@
/**
* 客户端「发布」入口的可见反馈。
*
- * 发布动作使用独立的全屏进度弹窗,不回到旧的聊天确认卡片;
- * 失败必须留在弹窗内可见,成功后自动切换到发布资料面板。
+ * 陶泥儿 tab 左半是内联的发布资料表单,内容末尾的橙色「打包并发布」一次点击先构建打包、
+ * 再提交发布。打包用独立的全屏进度弹窗(失败留在弹窗内),成功后不再弹出发布面板。
*/
import { readFileSync } from 'node:fs';
@@ -129,16 +129,14 @@ function renderPublishProject(
async function openTaonierPublishEntry() {
fireEvent.click(await screen.findByRole('button', { name: '导出产物' }));
fireEvent.click(await screen.findByRole('button', { name: '陶泥儿' }));
- const publish = await screen.findByRole('button', {
- name: '发布到游戏广场',
- });
+ const publish = await screen.findByRole('button', { name: '打包并发布' });
await waitFor(() => expect(publish).toHaveProperty('disabled', false));
return publish;
}
async function clickPublish() {
fireEvent.click(await openTaonierPublishEntry());
- return screen.findByLabelText('陶泥儿项目对话');
+ return screen.findByLabelText('游戏发布对话栏');
}
beforeEach(() => {
@@ -204,7 +202,7 @@ describe('客户端发布入口的可见反馈', () => {
});
});
- it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => {
+ it('打包时显示全屏进度遮罩,成功后直接进入发布提交(不再弹发布面板)', async () => {
const deferred =
createDeferred
>();
const exportPackage = vi.fn(() => deferred.promise);
@@ -232,12 +230,14 @@ describe('客户端发布入口的可见反馈', () => {
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull();
});
+ // 打包成功后直接提交发布;还没有封面时把错误留在表单里,而不是再弹一个发布面板。
expect(
- await screen.findByRole('dialog', { name: '发布到游戏广场' }),
+ await screen.findByText('请先选择游戏封面(JPG/PNG/WebP)'),
).not.toBeNull();
+ expect(screen.queryByRole('dialog', { name: '发布到游戏广场' })).toBeNull();
});
- it('发布失败时在进度弹窗里显示错误并允许关闭', async () => {
+ it('打包失败时在进度弹窗里显示错误并允许关闭', async () => {
installTauri({
exportPackage: () => {
throw new Error('构建可玩版本失败:缺少入口');
diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
index 640cb8c0c..dcafa87e6 100644
--- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
+++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
@@ -86,7 +86,7 @@
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
-5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 导出产物面板陶泥儿 tab 的「发布到游戏广场」入口据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
+5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 导出产物面板陶泥儿 tab 的左半发布工作台(内联资料表单 + 内容末尾的橙色「打包并发布」按钮,与右半第二个会话 chatbox 左右并列)据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布表单不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`,游戏文档又运行在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie 与 storage。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。