diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierExport.ts index bf035e39a..92cc2bd95 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierExport.ts @@ -67,6 +67,13 @@ const AUTOSAVE_DELAY_MS = 600; * 每次只读内容(不含指纹),而且只在用户手上没有未保存输入时才采纳表单,所以轮询不会吃掉输入。 */ const CONTENT_REFRESH_INTERVAL_MS = 2000; +/** + * 线上值读取失败后的自动重试间隔。 + * + * 线上值进项目读一次,读成功就停(它不是项目文件,不会被 agent 每 2 秒改);读失败时按这个间隔 + * 自动重试,用户不需要手动点「重新读取」。重试读的是平台侧,节奏比本地内容轮询慢一档。 + */ +const ONLINE_RETRY_INTERVAL_MS = 5000; /** 线上作品的一张只读图片:objectKey 是沿用线上时要回给宿主的凭据,预览地址只用于展示。 */ export type TaonierOnlineImage = { @@ -258,6 +265,10 @@ export function useTaonierExport({ const initialReadSettledRef = useRef(false); /** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */ const adoptableRef = useRef(false); + /** 线上值读取的请求序号:只有最后一次请求的结果允许写状态,慢的旧请求不能覆盖新结果。 */ + const onlineRequestSeqRef = useRef(0); + /** 自动重试回调里要判断线上值是不是已经读成功,但不该把重试节拍打乱。 */ + const onlineStateRef = useRef('idle'); /** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useTaonierExport.throwToErrorPool]。 */ const reportedFailureRef = useRef(''); const formRef = useRef(null); @@ -286,6 +297,10 @@ export function useTaonierExport({ adoptableRef.current = !dirty && !conflict && fieldErrors.length === 0; }, [dirty, conflict, fieldErrors]); + useEffect(() => { + onlineStateRef.current = onlineState; + }, [onlineState]); + /** * 换项目时把整套状态与本地判据清回初值。 * @@ -440,76 +455,106 @@ export function useTaonierExport({ /** * 读线上值:live `GameDistributionGame` 行的只读投影。 * - * 与草稿读取分开:它不是项目文件、不会被 agent 每 2 秒改,所以只在进项目与用户显式重试 / - * 发布成功之后读。`unavailable` 时不给一份空值冒充「首次发布」,那会让用户在已有作品上再建一个。 + * 与草稿读取分开:它不是项目文件、不会被 agent 每 2 秒改,所以进项目读一次,读成功就停;读 + * 失败由外层按 [ONLINE_RETRY_INTERVAL_MS] 自动重试——这套加载是自动的,界面上没有、也不该有 + * 一颗「重新读取」。`silent` 用于自动重试:不把状态翻成「读取中」,界面留在失败态直到读回来, + * 避免每几秒闪一下。 + * + * `unavailable` 时不给一份空值冒充「首次发布」,那会让用户在已有作品上再建一个。 */ - const refreshOnline = useCallback(async (): Promise => { - if (!projectPath) return; - const invoke = resolveTauriInvoke(); - if (!invoke) return; - const requestedPath = projectPath; - setOnlineState('checking'); - try { - const recovery = await readGameDistributionPublication( - { projectPath: requestedPath }, - invoke, - ); - if (projectPathRef.current !== requestedPath) return; - if (recovery.state === 'unavailable') { + const refreshOnline = useCallback( + async (options?: { silent?: boolean }): Promise => { + if (!projectPath) return; + const invoke = resolveTauriInvoke(); + if (!invoke) return; + const requestedPath = projectPath; + const silent = options?.silent === true; + // 请求序号:慢的旧请求回来时不能覆盖新请求写下的状态。 + const seq = onlineRequestSeqRef.current + 1; + onlineRequestSeqRef.current = seq; + if (!silent) setOnlineState('checking'); + try { + const recovery = await readGameDistributionPublication( + { projectPath: requestedPath }, + invoke, + ); + if (projectPathRef.current !== requestedPath) return; + if (onlineRequestSeqRef.current !== seq) return; + if (recovery.state === 'unavailable') { + setOnline(null); + setOnlineState('unavailable'); + setOnlineMessage( + recovery.message?.trim() || + '无法确认该项目的线上发布状态,请稍后重试', + ); + return; + } + const draft = recovery.state === 'update' ? recovery.draft : null; + const draftRecord = (draft ?? null) as unknown as Record< + string, + unknown + > | null; + setOnline({ + state: recovery.state, + title: typeof draft?.title === 'string' ? draft.title : '', + summary: typeof draft?.summary === 'string' ? draft.summary : '', + category: typeof draft?.category === 'string' ? draft.category : '', + cover: draftRecord ? toOnlineImage(draftRecord.cover) : null, + screenshots: draftRecord + ? toOnlineImages(draftRecord.screenshots) + : [], + gameId: recovery.state === 'update' ? recovery.binding.gameId : null, + revision: + recovery.state === 'update' ? recovery.binding.revision : null, + nextVersionNumber: + typeof recovery.nextVersionNumber === 'number' && + recovery.nextVersionNumber > 0 + ? recovery.nextVersionNumber + : null, + priceMudPoints: + typeof draft?.priceMudPoints === 'number' + ? draft.priceMudPoints + : 0, + latestVersionNumber: + recovery.state === 'update' + ? (recovery.binding.latestVersionNumber ?? 0) + : 0, + refreshed: recovery.state === 'update' ? recovery.refreshed : true, + message: recovery.message?.trim() ?? '', + }); + setOnlineState('ready'); + setOnlineMessage(''); + } catch (error) { + if (projectPathRef.current !== requestedPath) return; + if (onlineRequestSeqRef.current !== seq) return; setOnline(null); setOnlineState('unavailable'); setOnlineMessage( - recovery.message?.trim() || - '无法确认该项目的线上发布状态,请稍后重试', + error instanceof Error + ? error.message + : '无法确认该项目的线上发布状态,请稍后重试', ); - return; } - const draft = recovery.state === 'update' ? recovery.draft : null; - const draftRecord = (draft ?? null) as unknown as Record< - string, - unknown - > | null; - setOnline({ - state: recovery.state, - title: typeof draft?.title === 'string' ? draft.title : '', - summary: typeof draft?.summary === 'string' ? draft.summary : '', - category: typeof draft?.category === 'string' ? draft.category : '', - cover: draftRecord ? toOnlineImage(draftRecord.cover) : null, - screenshots: draftRecord ? toOnlineImages(draftRecord.screenshots) : [], - gameId: recovery.state === 'update' ? recovery.binding.gameId : null, - revision: - recovery.state === 'update' ? recovery.binding.revision : null, - nextVersionNumber: - typeof recovery.nextVersionNumber === 'number' && - recovery.nextVersionNumber > 0 - ? recovery.nextVersionNumber - : null, - priceMudPoints: - typeof draft?.priceMudPoints === 'number' ? draft.priceMudPoints : 0, - latestVersionNumber: - recovery.state === 'update' - ? (recovery.binding.latestVersionNumber ?? 0) - : 0, - refreshed: recovery.state === 'update' ? recovery.refreshed : true, - message: recovery.message?.trim() ?? '', - }); - setOnlineState('ready'); - setOnlineMessage(''); - } catch (error) { - if (projectPathRef.current !== requestedPath) return; - setOnline(null); - setOnlineState('unavailable'); - setOnlineMessage( - error instanceof Error - ? error.message - : '无法确认该项目的线上发布状态,请稍后重试', - ); - } - }, [projectPath]); + }, + [projectPath], + ); + /** + * 线上值的自动加载:进项目读一次;没读成功就按 [ONLINE_RETRY_INTERVAL_MS] 静默重试,读成功即停。 + * + * 读成功后线上值不会再自己变(作品从别处被改是另一回事),发布成功那一次由发布链路显式再读, + * 所以不需要一直轮询平台。 + */ useEffect(() => { if (!enabled) return; void refreshOnline(); + const timer = window.setInterval(() => { + // 窗口在后台就别重试了:用户看不见,也没有人会因此少点一次。 + if (document.visibilityState === 'hidden') return; + if (onlineStateRef.current === 'ready') return; + void refreshOnline({ silent: true }); + }, ONLINE_RETRY_INTERVAL_MS); + return () => window.clearInterval(timer); }, [enabled, refreshOnline]); /** 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 783bc928a..99105c06a 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 @@ -179,7 +179,7 @@ export function useTaonierTab({ setProgress({ phase: 'failed', message: '发布失败', - detail: '还没有拿到平台发布版本号,请稍后重新读取线上值', + detail: '还没有拿到平台发布版本号,线上值正在自动重试', }); return; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/AdaptPromptDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/AdaptPromptDialog.tsx index 1b3adc0a9..c18e5ac63 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/AdaptPromptDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/AdaptPromptDialog.tsx @@ -7,10 +7,10 @@ import { PaneButton } from '../common/PaneButton'; * 判据只有一个:宿主现算的 `hasScript` 为假。这件事只能由陶泥儿在项目里实验完成(用哪版 vite * 配置、入口在哪、怎么调打包脚本都由它定),所以用户只有两条路:现在就让陶泥儿调通,或者自己处理。 * - * 是阻断层而不是卡片:不点一个选择就不放行,避免用户在不清楚状况时直接点「打包」。两个选择都必须 - * 显式给出,背景点击与 Esc 不生效。 + * 是阻断层而不是卡片:不点一个选择就不放行,避免用户在不清楚状况时直接点「打包并发布」。两个选择 + * 都必须显式给出,背景点击与 Esc 不生效。 * - * 文案不出现脚本名、注册表、zip 这些实现词——用户要的是「怎么才能打包」。 + * 文案不出现脚本名、注册表、zip 这些实现词——用户要的是「怎么才能发布」。 */ export function AdaptPromptDialog({ open, @@ -35,7 +35,7 @@ export function AdaptPromptDialog({

要让陶泥儿先在项目里帮你调通一次导出流程(用哪套配置、入口在哪、怎么打包都由它试出来)。 - 调通之后,每次点「打包」就能直接生成发行包。 + 调通之后,每次点「打包并发布」就能直接生成发行包。

我自己来 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/ArtifactsPane.tsx index da4fc91cd..9f91b7007 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/ArtifactsPane.tsx @@ -16,9 +16,8 @@ import { } from './DownloadFailureDialog'; import { FailureDialog } from './FailureDialog'; import { FormCard } from './FormCard'; -import { OnlineValuesCard } from './OnlineValuesCard'; -/** 发布编排的只读投影 + 动作:进度、结果与「发布 / 更新」入口都由面板根的 `useTaonierTab` 持有。 */ +/** 发布编排的只读投影 + 动作:进度、结果与「打包并发布」入口都由面板根的 `useTaonierTab` 持有。 */ export type TaonierPublishPanel = { publishing: boolean; result: GameDistributionPublishResult | null; @@ -26,16 +25,16 @@ export type TaonierPublishPanel = { }; /** - * 陶泥儿工作台左半:发布表单 + 线上值 + 发行包 + 「打包」与「发布 / 更新」。 + * 陶泥儿工作台左半:发布表单(线上值跟着每个字段走)+ 唯一的「打包并发布」。 * - * 「打包」(先校验落盘表单、再跑构建产出 zip)与「发布」(校验打包后提交游戏分发)是两颗按钮, - * 但发布自己会先跑一次打包:用户点发布时不需要先记住点打包。打包成功才给「下载发行包」——没打过 - * 就不给一个下不了的按钮。适配入口不在按钮行里——未配置好导出时由阻断弹窗(`AdaptPromptDialog`) - * 给出「让陶泥儿帮我调通 / 我自己来」两个选择,失败现场由失败弹窗兜底。 + * 没有单独的「打包」步骤:打包是发布的第一步,由 `publishGame` 内部跑 `packageAll()`。用户填完表 + * 单点一次,就是「打包 + 提交游戏分发」。打包成功后给「下载发行包」——没打过就不给一个下不了的 + * 按钮。适配入口不在按钮行里——未配置好导出时由阻断弹窗(`AdaptPromptDialog`)给出「让陶泥儿帮我 + * 调通 / 我自己来」两个选择,失败现场由失败弹窗兜底。 * - * 线上值卡(`OnlineValuesCard`)把 `read_game_distribution_publication` 的只读投影并排展示,草稿不 - * 自动回填、不被覆盖;「沿用」是用户显式动作。发布状态行按线上读取状态给「首次发布 / 更新 / 无法 - * 确认」,`unavailable` 时发布按钮停住——恢复失败不能退化成首次发布,否则会多建作品。 + * 线上值(`read_game_distribution_publication` 的只读投影)在 `FormCard` 里逐字段内联展示,草稿不 + * 自动回填、不被覆盖;「沿用」是用户显式动作。发布入口按线上状态给「打包并发布 / 打包并更新」, + * `unavailable` 时停住——恢复失败不能退化成首次发布,否则会多建作品。 * * 左半本来就矮,失败是用户必须看见的东西,所以失败走阻断弹窗而不是会被滚出视野的就地卡片。 */ @@ -127,7 +126,7 @@ export function ArtifactsPane({ if (publish.publishing) { publishLabel = '正在发布…'; } else if (online?.state === 'update') { - publishLabel = '更新游戏'; + publishLabel = '打包并更新'; } /** @@ -141,12 +140,16 @@ export function ArtifactsPane({ form={form} fieldErrors={taonier.fieldErrors} projectPath={projectPath} - onlineCoverAvailable={Boolean(online?.cover?.objectKey)} - onlineScreenshotCount={online?.screenshots.length ?? 0} + online={online} + onlineState={onlineState} + onlineMessage={taonier.onlineMessage} onChange={taonier.updateField} onSetCover={taonier.replaceCoverWithLocal} onAppendScreenshot={taonier.appendScreenshotLocal} onRemoveScreenshot={taonier.removeScreenshotAt} + onAdoptText={taonier.adoptOnlineText} + onAdoptCover={taonier.adoptOnlineCover} + onAdoptScreenshots={taonier.adoptOnlineScreenshots} onAskAgentToFill={() => void taonier.fillForm()} onAskAgentAboutErrors={taonier.handFieldErrorsToAgent} /> @@ -182,45 +185,6 @@ export function ArtifactsPane({
{formSlot} -
-

- 发行包 -

-
- {/* - 「打包」不因为有脚本判据没到位就变灰:有没有脚本是**点下去那一刻**才成立的事实 - (agent 可能刚写完、用户可能刚删掉)。灰着按钮等于把「能不能开始」的判断推给用户, - 而他手上没有任何办法去核对。点下去先现算一次,没有就当场把适配出口递过去。 - */} - void taonier.packageAll()} - > - {taonier.running ? '打包中…' : '打包'} - - {/* 下载入口只在打包成功之后出现:宿主不报告磁盘上有没有产物,没打过就不给一个下不了的按钮。 */} - {taonier.artifactReady ? ( - - 下载发行包 - - ) : null} -
-
- {conflict && form ? ( ) : null} - void taonier.refreshOnline()} - onAdoptText={taonier.adoptOnlineText} - onAdoptCover={taonier.adoptOnlineCover} - onAdoptScreenshots={taonier.adoptOnlineScreenshots} - /> - -
-

- 发布到游戏广场 -

-

- {publishStatusText(onlineState, online, taonier.onlineMessage)} -

+ {/* + 发布入口不套卡片:它就是这一步唯一的动作,外面再包一层边框/标题只是噪音。 + 打包是它的第一步,所以只有这一颗按钮,没有单独的「打包」步骤。 + */} +
+ + {publishLabel} + + {/* 下载入口只在打包成功之后出现:宿主不报告磁盘上有没有产物,没打过就不给一个下不了的按钮。 */} + {taonier.artifactReady ? ( + + 下载发行包 + + ) : null} {publish.result ? (

{publish.result.status === 'pending_review' ? `已提交送审,版本号 v${publish.result.versionNumber}。` : `已提交,状态 ${publish.result.status},版本号 v${publish.result.versionNumber}。`}

) : null} -
- - {publishLabel} - -
-
+
); } - -function publishStatusText( - onlineState: TaonierExportState['onlineState'], - online: TaonierExportState['online'], - onlineMessage: string, -): string { - if (onlineState === 'checking' || onlineState === 'idle') { - return '正在确认发布状态…'; - } - if (onlineState === 'unavailable') { - return onlineMessage || '无法确认发布状态,请稍后重试。'; - } - if (online?.state === 'update') { - return online.latestVersionNumber > 0 - ? `这个项目已发布到游戏广场,线上最近提交 v${online.latestVersionNumber}。点发布提交新版本。` - : '这个项目已发布到游戏广场。点发布提交新版本。'; - } - return '这个项目还没有发布到游戏广场。点发布创建它。'; -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FailureDialog.tsx index 38033d11d..82fa714aa 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FailureDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FailureDialog.tsx @@ -20,7 +20,7 @@ import { PaneButton } from '../common/PaneButton'; * 要改的东西不在项目代码里,把陶泥儿拉进来只会让它去改一份没问题可改的代码。 * * 每条失败都带「关闭」:关掉之后弹窗收起、现场不再重复弹(下一次重读会再送同一个现场回来), - * 用户想自己重来就回面板点「打包」。没有「重试」——它跟关掉再点一次「打包」是同一件事。 + * 用户想自己重来就回面板点「打包并发布」。没有「重试」——它跟关掉再点一次是同一件事。 * * 只收 [`TaonierCardFailure`]:表单校验与保存冲突有各自的 UI,到不了这里。宿主给的现场也不在 * 这里展开:那是给 agent 的原始错误(`JSON.stringify` 进指令),不是给用户看的。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FormCard.tsx index d83b548af..82c400725 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FormCard.tsx @@ -10,6 +10,10 @@ import { type TaonierField, type TaonierFieldError, } from '../../state/taonierFields'; +import type { + TaonierOnlineState, + TaonierOnlineValues, +} from '../../state/useTaonierExport'; import { PaneButton } from '../common/PaneButton'; import { CopyButton } from '../xiaohongshu/CopyButton'; import { IconPreview } from '../xiaohongshu/IconPreview'; @@ -20,6 +24,10 @@ import { IconPreview } from '../xiaohongshu/IconPreview'; * 表单只收集值:校验在界面这一层(保存前,见 `validateTaonierForm`),**每个**出错字段各自挂 * 红框与红字;宿主那侧只按原样读写草稿。 * + * 线上值不再是单独一张卡,而是**跟着对应字段走**:每个「现在是这个值」的输入框下面挂一行更小的 + * 「线上值 xxx」+「沿用」。线上值只读,草稿不自动回填、不被覆盖;「沿用」是用户显式动作。首次 + * 发布时线上本来就是空的,逐字段铺一行「(线上为空)」只是噪音,所以此时不给这一行。 + * * 图片字段的动作与草稿语义一一对应: * - 选本地封面 = 写 `coverPath`,发布时带二进制; * - 「沿用线上封面」= 清空 `coverPath`(空值语义就是沿用); @@ -32,26 +40,33 @@ export function FormCard({ form, fieldErrors, projectPath, - onlineCoverAvailable, - onlineScreenshotCount, + online, + onlineState, + onlineMessage, onChange, onSetCover, onAppendScreenshot, onRemoveScreenshot, + onAdoptText, + onAdoptCover, + onAdoptScreenshots, onAskAgentToFill, onAskAgentAboutErrors, }: { form: TaonierExportForm; fieldErrors: TaonierFieldError[]; projectPath: string | null; - /** 线上有没有可沿用的封面:没有时占位文案说「还没有选封面」。 */ - onlineCoverAvailable: boolean; - /** 线上有多少张可沿用的截图:为 0 时说明「还没有截图」。 */ - onlineScreenshotCount: number; + /** 线上作品的只读投影;没读到(或还没读)时为 `null`。 */ + online: TaonierOnlineValues | null; + onlineState: TaonierOnlineState; + onlineMessage: string; onChange: (field: TaonierField, value: string) => void; onSetCover: (path: string) => void; onAppendScreenshot: (path: string) => void; onRemoveScreenshot: (index: number) => void; + onAdoptText: (field: 'title' | 'summary' | 'category') => void; + onAdoptCover: () => void; + onAdoptScreenshots: () => void; onAskAgentToFill: () => void; onAskAgentAboutErrors: () => void; }) { @@ -59,11 +74,17 @@ export function FormCard({ const [screenshotImporterOpen, setScreenshotImporterOpen] = useState(false); const screenshotsFull = form.screenshotPaths.length >= MAX_AGC_GAME_SCREENSHOTS; + /** + * 只有「线上已有作品」才逐字段给对照:首次发布线上是空的,每行都写「(线上为空)」纯属噪音, + * 发布状态由发布入口那颗按钮与读取状态行交代。 + */ + const showOnline = onlineState === 'ready' && online?.state === 'update'; + const onlineScreenshots = showOnline ? (online?.screenshots ?? []) : []; // 三态先摊平:有本地图 → 报张数;否则线上有可沿用的整组截图 → 沿用;否则还没截图。 let screenshotSummary = '还没有截图'; if (form.screenshotPaths.length > 0) { screenshotSummary = `本地 ${form.screenshotPaths.length} 张`; - } else if (onlineScreenshotCount > 0) { + } else if (onlineScreenshots.length > 0) { screenshotSummary = '整组沿用线上截图'; } @@ -87,6 +108,19 @@ export function FormCard({ 帮我填 + + {onlineState === 'checking' || onlineState === 'idle' ? ( +

+ 正在读取线上值… +

+ ) : null} + {onlineState === 'unavailable' ? ( +

+ {onlineMessage || '无法确认该项目的线上发布状态。'} + 正在自动重试… +

+ ) : null} + {fieldErrors.length > 0 ? (

@@ -111,6 +145,10 @@ export function FormCard({ fieldErrors.find((item) => item.field === meta.field)?.reason ?? null; const value = form[meta.field]; + const onlineValue = showOnline + ? (online?.[meta.field] ?? '').trim() + : ''; + const same = value.trim() === onlineValue; // 字段控件按 options → multiline → input 三态摊开算好,JSX 里只留一个 {inputSlot}: // 不再用嵌套三元,也顺手复用同一份边框/错误态样式。 const fieldClassName = `w-full rounded-xl border bg-(--platform-input-fill) px-3 py-2 text-sm text-(--platform-text-strong) outline-none focus:border-(--platform-accent) ${ @@ -174,6 +212,22 @@ export function FormCard({ ) : null}

{inputSlot} + {showOnline ? ( +
+ + 线上值 {onlineValue || '(线上为空)'} + + onAdoptText(meta.field)} + > + 沿用 + +
+ ) : null} {error ? (

) : ( - {onlineCoverAvailable ? '沿用线上封面' : '还没有选封面'} + {showOnline && online?.cover ? '沿用线上封面' : '还没有选封面'} )}

@@ -214,6 +268,35 @@ export function FormCard({
+ {showOnline && online?.cover ? ( +
+
+ + 线上值 + + {online.cover.previewUrl ? ( + 线上封面预览 + ) : ( + + (没有封面) + + )} +
+ + 沿用线上封面 + +
+ ) : null}
@@ -243,6 +326,36 @@ export function FormCard({ ))}
) : null} + {showOnline && onlineScreenshots.length > 0 ? ( +
+
+ + 线上值 + +
+ {onlineScreenshots.map((image, index) => + image.previewUrl ? ( + {`线上截图 + ) : null, + )} +
+
+ + 沿用线上截图 + +
+ ) : null}
void; - onAdoptText: (field: 'title' | 'summary' | 'category') => void; - onAdoptCover: () => void; - onAdoptScreenshots: () => void; -}) { - // 先把「已发布」这段文案算成变量,避免在模板字符串里再叠一层三元。 - let onlineSummary = - '这个项目还没有发布到游戏广场。发布后会在这里显示线上值。'; - if (onlineState === 'ready' && online?.state === 'update') { - const versionSuffix = - online.latestVersionNumber > 0 - ? `,线上最近提交 v${online.latestVersionNumber}` - : ''; - onlineSummary = `这个项目已发布到游戏广场${versionSuffix}。线上值只读展示,点「沿用」才会写进草稿。`; - } - - return ( -
-
-

- 线上值 -

- - -
- - {onlineState === 'checking' || onlineState === 'idle' ? ( -

- 正在读取线上值… -

- ) : null} - - {onlineState === 'unavailable' ? ( -

- {onlineMessage || '无法确认该项目的线上发布状态,请稍后重试。'} -

- ) : null} - - {onlineState === 'ready' && online ? ( - <> -

- {onlineSummary} -

-
- {TAONIER_FORM_FIELDS.map((meta) => { - const value = online[meta.field] ?? ''; - const same = (form?.[meta.field] ?? '').trim() === value.trim(); - return ( -
-
- - {meta.label} - - - {value || '(线上为空)'} - -
- onAdoptText(meta.field)} - > - 沿用 - -
- ); - })} -
- -
-
-
- - 线上封面 - - {online.cover?.previewUrl ? ( - 线上封面预览 - ) : ( -

- (线上没有封面) -

- )} -
- - 沿用线上封面 - -
- -
-
- - 线上截图 - - {online.screenshots.length > 0 ? ( -
- {online.screenshots.map((image, index) => - image.previewUrl ? ( - {`线上截图 - ) : null, - )} -
- ) : ( -

- (线上没有截图) -

- )} -
- - 沿用线上截图 - -
-
- - ) : null} -
- ); -} 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 eae1a3f7e..68cb80663 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 @@ -4,7 +4,7 @@ import { ArtifactsPane } from './ArtifactsPane'; import { ChatPane } from './chat/Pane'; /** - * 陶泥儿工作台:中缝一分为二,左半是文件草稿面板与打包 / 发布,右半是另一个 chatbox。 + * 陶泥儿工作台:中缝一分为二,左半是发布表单与唯一的「打包并发布」,右半是另一个 chatbox。 * * 左右两侧的状态都在面板根的 `useTaonierTab`:这一页跟着 tab 切走时可以卸载,草稿、线上值、 * 订阅、回合与输入草稿都留在面板上。发布进度弹窗由面板根的进度状态驱动,与左半并列挂在同一层。 diff --git a/apps/ai-game-creator-shell/tests/taonierExport.test.tsx b/apps/ai-game-creator-shell/tests/taonierExport.test.tsx index 6a0d83fae..04b61c970 100644 --- a/apps/ai-game-creator-shell/tests/taonierExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/taonierExport.test.tsx @@ -4,7 +4,8 @@ * * 三个切面各钉一块: * - 交给 code agent 的指令正文(纯字符串组装,含三条硬契约与要拷的示例脚本); - * - 左半面板的可见动作(打包、下载、适配 / 失败的下一步、冲突逐字段二选一、沿用线上值); + * - 左半面板的可见动作(唯一的「打包并发布」入口、下载、适配 / 失败的下一步、冲突逐字段二选一、 + * 逐字段「沿用线上值」); * - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、「校验不过不触宿主」,以及 * 「适配才叫 agent」)。 */ @@ -54,7 +55,10 @@ import { type TaonierOnlineValues, useTaonierExport, } from '../src/view/project-development/export/state/useTaonierExport'; -import { ArtifactsPane } from '../src/view/project-development/export/tabs/taonier/ArtifactsPane'; +import { + ArtifactsPane, + type TaonierPublishPanel, +} from '../src/view/project-development/export/tabs/taonier/ArtifactsPane'; import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() })); @@ -156,6 +160,29 @@ const ONLINE_VALUES: TaonierOnlineValues = { message: '', }; +/** 首次发布的线上投影:线上还没有作品,发布入口是「打包并发布」而不是「打包并更新」。 */ +const ONLINE_FIRST_PUBLISH_VALUES: TaonierOnlineValues = { + state: 'first-publish', + title: '', + summary: '', + category: '', + cover: null, + screenshots: [], + gameId: null, + revision: null, + nextVersionNumber: 1, + priceMudPoints: 0, + latestVersionNumber: 0, + refreshed: true, + message: '', +}; + +/** 平台暂时读不到线上值:界面应显示自动重试,而不是给用户一颗手动按钮。 */ +const ONLINE_UNAVAILABLE = { + state: 'unavailable', + message: '平台暂时不可用。', +}; + /** 一个会认读 / 保存 / 构建 / 线上回读四类命令的宿主替身。 */ function createInvoke(options?: { form?: TaonierExportForm; @@ -818,6 +845,36 @@ describe('hook 状态', () => { ); }); + it('线上值读取失败后自动重试,不需要用户手动重新读取', async () => { + let onlineCalls = 0; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_taonier_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_taonier_export_hash') return CONTENT_HASH; + if (command === 'read_game_distribution_publication') { + onlineCalls += 1; + // 第一次失败,之后自动重试读成功;全程没有任何用户动作。 + return onlineCalls === 1 ? ONLINE_UNAVAILABLE : ONLINE_UPDATE; + } + return undefined; + }); + installTauriRuntime({ core: { invoke } }); + const { result } = renderHook(() => + useTaonierExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn: vi.fn(async () => true), + }), + ); + + await waitFor(() => expect(result.current.onlineState).toBe('unavailable')); + await waitFor(() => expect(result.current.online).toEqual(ONLINE_VALUES), { + timeout: 8000, + }); + expect(onlineCalls).toBeGreaterThanOrEqual(2); + }, 15000); + it('保存冲突交给冲突面板,不静默覆盖用户输入', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_taonier_export') { @@ -854,11 +911,17 @@ describe('hook 状态', () => { }); describe('面板左半', () => { - it('打包按钮触发 packageAll,发行包只在打包成功后给下载入口', () => { - const packageAll = vi.fn(async () => true); - renderPane(fakeTaonier({ packageAll })); - fireEvent.click(screen.getByRole('button', { name: '打包' })); - expect(packageAll).toHaveBeenCalledTimes(1); + it('只有一颗「打包并发布」入口,发行包只在打包成功后给下载入口', () => { + const publish = vi.fn(); + renderPane(fakeTaonier({ online: ONLINE_FIRST_PUBLISH_VALUES }), { + publishing: false, + result: null, + publish, + }); + // 没有单独的「打包」步骤:打包是发布的第一步,由发布入口内部跑。 + expect(screen.queryByRole('button', { name: '打包' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '打包并发布' })); + expect(publish).toHaveBeenCalledTimes(1); expect(screen.queryByRole('button', { name: '下载发行包' })).toBeNull(); cleanup(); @@ -866,6 +929,27 @@ describe('面板左半', () => { screen.getByRole('button', { name: '下载发行包' }); }); + it('线上值读取失败时不留手动入口,提示正在自动重试', () => { + renderPane( + fakeTaonier({ + online: null, + onlineState: 'unavailable', + onlineMessage: '平台暂时不可用。', + }), + ); + expect(screen.queryByRole('button', { name: '重新读取线上值' })).toBeNull(); + screen.getByText('平台暂时不可用。'); + screen.getByText(/正在自动重试/); + // 读不到线上值时发布入口停住,不能退化成首次发布。 + expect( + ( + screen.getByRole('button', { + name: '打包并发布', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + it('一次导入多张截图时按剩余名额逐张写入,不丢图', () => { const appendScreenshotLocal = vi.fn(); mockImportedAssets.current = [ @@ -948,7 +1032,7 @@ describe('面板左半', () => { expect(handFieldErrorsToAgent).toHaveBeenCalledTimes(1); }); - it('线上值卡逐字段给「沿用」,文字字段沿用走 adoptOnlineText', () => { + it('线上值跟着字段走,逐字段给「沿用」,文字字段沿用走 adoptOnlineText', () => { const adoptOnlineText = vi.fn(); const adoptOnlineCover = vi.fn(); renderPane( @@ -987,12 +1071,19 @@ describe('面板左半', () => { }); }); -function renderPane(taonier: TaonierExportState = fakeTaonier()) { +function renderPane( + taonier: TaonierExportState = fakeTaonier(), + publish: TaonierPublishPanel = { + publishing: false, + result: null, + publish: vi.fn(), + }, +) { return render( , ); } @@ -1024,6 +1115,7 @@ function fakeTaonier( adoptOnlineText: vi.fn(), resolveConflict: vi.fn(), packageAll: vi.fn(async () => true), + getEffectiveForm: () => null, adapt: vi.fn(), repair: vi.fn(), fillForm: vi.fn(async () => undefined), diff --git a/docs/project-memory/plans/【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md b/docs/project-memory/plans/【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md index 63e6b5c71..f38c5c05e 100644 --- a/docs/project-memory/plans/【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md +++ b/docs/project-memory/plans/【实施计划】陶泥儿导出文件草稿化与发布媒体直传-2026-10-06.md @@ -89,7 +89,7 @@ ### 3.4 线上值与沿用 - 读命令除草稿外返回只读 `online`:live `GameDistributionGame` 行的 `title/summary/category` + `coverObjectKey` / 截图 objectKey 列表 + 预览地址。 -- UI 并排显示「草稿值 vs 线上值」;不自动 seed、不覆盖草稿。 +- UI 逐字段内联显示「草稿值 vs 线上值」(线上值一行更小,跟在该字段输入框下,首次发布线上为空时不铺空对照);读取自动进行、失败静默自动重试,不提供手动「重新读取」入口;不自动 seed、不覆盖草稿。 - 「沿用线上值」操作:文字字段一键把线上值写入草稿(标 dirty → 自动落盘);图片字段「替换为本地图」/「沿用线上」(清空本地路径,表示沿用);`screenshotPaths` 为空表示整组沿用线上。 - 发布:路径非空的图发原始二进制;路径为空的图带线上 objectKey;文案沿用现有状态语义,不混淆在审/驳回状态。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3a84fff3a..f5548ec51 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -5,9 +5,9 @@ 陶泥儿(Taonier)导出采用与小红书同构的**文件草稿 + agent 适配打包**模式:草稿 `.export/taonier.json` 是唯一真相,打包由 `vite-export-taonier` skill 与项目 `build:taonier` 脚本完成,发布媒体向游戏分发 API **formdata 原始二进制直传**。 - 草稿:`.export/taonier.json`,形状 `{ form: { title, summary, category, coverPath, screenshotPaths[] } }`;内容 hash 冲突逐字段选择,宿主零业务校验,校验只在前端 `taonierFields`。 -- 打包:项目 `package.json` 提供 `build:taonier`(既有 `command.exec` 的 `build:*` 白名单),由 skill `vite-export-taonier` 的 `vite.config.taonier.mjs` 构建到 `dist-taonier`、`pack.mjs` 打成 `.export/taonier.zip`;**标准 `build`/`dist` 不被占用**。宿主只运行脚本并校验产物(普通文件、非空、非符号链接、能作为 ZIP 打开、根有 `index.html`、读取安全线 512 MiB),并按 `shared-contracts` 的 `GAME_DISTRIBUTION_MAX_PACKAGE_BYTES`(平台发行包上限 200 MiB)在构建收尾与发布读包两处预检,超限当场给「精简资源后重新导出再发布」,不等上传吃服务端 413;读取安全线与平台上限不是一个概念。 +- 打包:项目 `package.json` 提供 `build:taonier`(既有 `command.exec` 的 `build:*` 白名单),由 skill `vite-export-taonier` 的 `vite.config.taonier.mjs` 构建到 `dist-taonier`、`pack.mjs` 打成 `.export/taonier.zip`;**标准 `build`/`dist` 不被占用**。宿主只运行脚本并校验产物(普通文件、非空、非符号链接、能作为 ZIP 打开、根有 `index.html`、读取安全线 512 MiB),并按 `shared-contracts` 的 `GAME_DISTRIBUTION_MAX_PACKAGE_BYTES`(平台发行包上限 200 MiB)在构建收尾与发布读包两处预检,超限当场给「精简资源后重新导出再发布」,不等上传吃服务端 413;读取安全线与平台上限不是一个概念。面板只给一颗「打包并发布」入口(打包是发布的第一步),不再有独立的打包步骤或按钮。 - 适配与错误:未适配弹「让陶泥儿帮我调通」;adapt/fill/repair 三类指令与小红书一致,失败现场原样转发 code agent;「帮我填」只写元数据,不改打包适配。 -- 线上值:草稿是唯一真相;读命令额外返回只读线上值(live game 行),不自动 seed/回填;文案字段与图片均可「沿用线上值」,图片路径为空表示沿用(发布时带线上 objectKey)。 +- 线上值:草稿是唯一真相;读命令额外返回只读线上值(live game 行),不自动 seed/回填;面板把线上值**逐字段内联**挂在对应输入框下方(比当前值小一行),首次发布线上为空时不铺空对照;读取自动进行、失败按固定间隔静默自动重试,界面不提供手动「重新读取」入口;文案字段与图片均可「沿用线上值」,图片路径为空表示沿用(发布时带线上 objectKey)。 - 退役收口:`exports/` 目录、历史试玩包链、`prepare/upload/list` 命令、生成任务产物声明、prompts、run-trace 白名单与排除列表都不再存在;AI 资料建议与封面生成、`useGameDistributionPublishForm`/`GameDistributionPublishFormView`/`GamePublishBlockedDialog` 一并删除,保留发布进度对话框。 - 发布媒体:`POST /api/game-distribution/games`、`POST .../versions`、`PATCH .../my-games/{id}` 收 `multipart/form-data`(元数据文本 + 封面/截图原始二进制 + 沿用 objectKey);整包仍走 raw/chunked。AGC 发布命令所需的本地图路径由发布面板(UI 表单)随命令传入,**不再回读 `.export/taonier.json` 草稿注册表**:文字字段与本地媒体路径都取 `packageAll()` 落定的同一份权威表单,面板看到的值就是提交的值。图片落项目快照桶 `agc/project-snapshots/v1/game-distribution/media/...`,不建 `asset_object`;公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定 = 已发布且 active 的 game 且 objectKey 命中封面/截图,不经素材库 ACL。作者预览自己作品的线上封面/截图走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(需 bearer,判定 = 作品归属 + objectKey 命中该作品当前媒体),未发布 / 待审 / 被驳回也能换签;AGC `resolve_preview_url` 已切到该路由,不再走素材库 `/api/assets/read-url`。硬切,无旧 JSON/assetId 兼容。 - 验收:草稿/打包/失败转发/沿用线上值与发布直传的定向测试;真实 vite 工程首轮适配与一次真实发布 smoke。