陶泥儿导出收敛为单一发布入口并把线上值内联到字段

- 移除独立「打包」按钮与「发行包」卡片,发布入口不套卡片,内部先跑打包
- 线上值逐字段内联到对应输入框下方,退役 OnlineValuesCard
- 线上值进项目自动读取,失败按固定间隔静默自动重试,去掉「重新读取」手动入口
- 读取失败时提示正在自动重试,发布入口保持停住
- 同步陶泥儿面板测试与技术方案/实施计划文档
This commit is contained in:
2026-10-07 14:09:26 +08:00
parent 11c8c2789a
commit 9a653c8382
11 changed files with 384 additions and 391 deletions
@@ -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<TaonierOnlineState>('idle');
/** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useTaonierExport.throwToErrorPool]。 */
const reportedFailureRef = useRef('');
const formRef = useRef<TaonierExportForm | null>(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<void> => {
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<void> => {
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]);
/**
@@ -179,7 +179,7 @@ export function useTaonierTab({
setProgress({
phase: 'failed',
message: '发布失败',
detail: '还没有拿到平台发布版本号,请稍后重新读取线上值',
detail: '还没有拿到平台发布版本号,线上值正在自动重试',
});
return;
}
@@ -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({
</h2>
<p className="mt-2 text-xs leading-relaxed text-(--platform-text-base)">
要让陶泥儿先在项目里帮你调通一次导出流程(用哪套配置、入口在哪、怎么打包都由它试出来)。
调通之后,每次点「打包」就能直接生成发行包。
调通之后,每次点「打包并发布」就能直接生成发行包。
</p>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<PaneButton onClick={onDoItMyself}>我自己来</PaneButton>
@@ -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({
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3">
{formSlot}
<section
className="rounded-2xl border border-(--platform-surface-border) bg-(image:--platform-panel-fill) p-4"
aria-label="发行包"
>
<h3 className="text-sm font-bold text-(--platform-text-strong)">
发行包
</h3>
<div className="mt-4 flex flex-col gap-2">
{/*
「打包」不因为有脚本判据没到位就变灰:有没有脚本是**点下去那一刻**才成立的事实
(agent 可能刚写完、用户可能刚删掉)。灰着按钮等于把「能不能开始」的判断推给用户,
而他手上没有任何办法去核对。点下去先现算一次,没有就当场把适配出口递过去。
*/}
<PaneButton
variant="primary"
size="hero"
disabled={taonier.running}
title={
taonier.hasScript
? '重新生成一份陶泥儿发行包'
: '项目还没配置好导出,先让陶泥儿帮你调通'
}
onClick={() => void taonier.packageAll()}
>
{taonier.running ? '打包中…' : '打包'}
</PaneButton>
{/* 下载入口只在打包成功之后出现:宿主不报告磁盘上有没有产物,没打过就不给一个下不了的按钮。 */}
{taonier.artifactReady ? (
<PaneButton
ariaLabel="下载发行包"
className="justify-center"
onClick={downloadArtifact}
>
下载发行包
</PaneButton>
) : null}
</div>
</section>
{conflict && form ? (
<ConflictCard
key={conflict.contentHash}
@@ -230,53 +194,45 @@ export function ArtifactsPane({
/>
) : null}
<OnlineValuesCard
online={online}
onlineState={onlineState}
onlineMessage={taonier.onlineMessage}
form={form}
onRetry={() => void taonier.refreshOnline()}
onAdoptText={taonier.adoptOnlineText}
onAdoptCover={taonier.adoptOnlineCover}
onAdoptScreenshots={taonier.adoptOnlineScreenshots}
/>
<section
className="rounded-2xl border border-(--platform-surface-border) bg-(image:--platform-panel-fill) p-4"
aria-label="发布到游戏广场"
>
<h3 className="text-sm font-bold text-(--platform-text-strong)">
发布到游戏广场
</h3>
<p className="mt-2 text-xs leading-relaxed text-(--platform-text-base)">
{publishStatusText(onlineState, online, taonier.onlineMessage)}
</p>
{/*
发布入口不套卡片:它就是这一步唯一的动作,外面再包一层边框/标题只是噪音。
打包是它的第一步,所以只有这一颗按钮,没有单独的「打包」步骤。
*/}
<div className="flex flex-col gap-2">
<PaneButton
variant="primary"
size="hero"
disabled={publishDisabled}
title={
publishDisabled
? '先确认发布状态并填好表单,再发布'
: '打包并提交到游戏广场'
}
onClick={publish.publish}
>
{publishLabel}
</PaneButton>
{/* 下载入口只在打包成功之后出现:宿主不报告磁盘上有没有产物,没打过就不给一个下不了的按钮。 */}
{taonier.artifactReady ? (
<PaneButton
ariaLabel="下载发行包"
className="justify-center"
onClick={downloadArtifact}
>
下载发行包
</PaneButton>
) : null}
{publish.result ? (
<p
role="status"
className="mt-2 text-xs leading-relaxed text-(--platform-text-base)"
className="text-xs leading-relaxed text-(--platform-text-base)"
>
{publish.result.status === 'pending_review'
? `已提交送审,版本号 v${publish.result.versionNumber}。`
: `已提交,状态 ${publish.result.status},版本号 v${publish.result.versionNumber}。`}
</p>
) : null}
<div className="mt-4">
<PaneButton
variant="primary"
size="hero"
disabled={publishDisabled}
title={
publishDisabled
? '先确认发布状态、填好表单并打出发行包,再发布'
: '发布或更新到游戏广场'
}
onClick={publish.publish}
>
{publishLabel}
</PaneButton>
</div>
</section>
</div>
</div>
<AdaptPromptDialog
@@ -301,22 +257,3 @@ export function ArtifactsPane({
</div>
);
}
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 '这个项目还没有发布到游戏广场。点发布创建它。';
}
@@ -20,7 +20,7 @@ import { PaneButton } from '../common/PaneButton';
* 要改的东西不在项目代码里,把陶泥儿拉进来只会让它去改一份没问题可改的代码。
*
* 每条失败都带「关闭」:关掉之后弹窗收起、现场不再重复弹(下一次重读会再送同一个现场回来),
* 用户想自己重来就回面板点「打包」。没有「重试」——它跟关掉再点一次「打包」是同一件事。
* 用户想自己重来就回面板点「打包并发布」。没有「重试」——它跟关掉再点一次是同一件事。
*
* 只收 [`TaonierCardFailure`]:表单校验与保存冲突有各自的 UI,到不了这里。宿主给的现场也不在
* 这里展开:那是给 agent 的原始错误(`JSON.stringify` 进指令),不是给用户看的。
@@ -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({
<span>帮我填</span>
</PaneButton>
</div>
{onlineState === 'checking' || onlineState === 'idle' ? (
<p className="mt-2 text-xs text-(--platform-text-soft)">
正在读取线上值…
</p>
) : null}
{onlineState === 'unavailable' ? (
<p className="mt-2 text-xs leading-relaxed text-(--platform-text-base)">
{onlineMessage || '无法确认该项目的线上发布状态。'}
<span className="text-(--platform-text-soft)"> 正在自动重试…</span>
</p>
) : null}
{fieldErrors.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-(--platform-warm-border) bg-(--platform-warm-bg) px-3 py-2">
<p className="text-xs leading-relaxed text-(--platform-text-base)">
@@ -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}
</div>
{inputSlot}
{showOnline ? (
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 break-all text-[11px] leading-snug text-(--platform-text-soft)">
线上值 {onlineValue || '(线上为空)'}
</span>
<PaneButton
variant="ghost"
ariaLabel={`沿用线上${meta.label}`}
title={`把线上${meta.label}写进草稿`}
disabled={!onlineValue || same}
onClick={() => onAdoptText(meta.field)}
>
沿用
</PaneButton>
</div>
) : null}
{error ? (
<p
role="alert"
@@ -198,7 +252,7 @@ export function FormCard({
/>
) : (
<span className="grid size-24 shrink-0 place-items-center overflow-hidden rounded-xl border border-dashed border-(--platform-surface-border) bg-(--platform-input-fill) text-center text-xs text-(--platform-text-soft)">
{onlineCoverAvailable ? '沿用线上封面' : '还没有选封面'}
{showOnline && online?.cover ? '沿用线上封面' : '还没有选封面'}
</span>
)}
<div className="flex min-w-0 flex-col gap-2">
@@ -214,6 +268,35 @@ export function FormCard({
</PaneButton>
</div>
</div>
{showOnline && online?.cover ? (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="text-[11px] leading-snug text-(--platform-text-soft)">
线上值
</span>
{online.cover.previewUrl ? (
<img
src={online.cover.previewUrl}
alt="线上封面预览"
className="h-10 w-16 rounded-md object-cover"
/>
) : (
<span className="text-[11px] text-(--platform-text-soft)">
(没有封面)
</span>
)}
</div>
<PaneButton
variant="ghost"
ariaLabel="沿用线上封面"
title="清空草稿的本地封面,发布时沿用线上封面"
disabled={!online.cover.objectKey || !form.coverPath.trim()}
onClick={onAdoptCover}
>
沿用线上封面
</PaneButton>
</div>
) : null}
</div>
<div className="grid gap-1.5">
@@ -243,6 +326,36 @@ export function FormCard({
))}
</div>
) : null}
{showOnline && onlineScreenshots.length > 0 ? (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<span className="text-[11px] leading-snug text-(--platform-text-soft)">
线上值
</span>
<div className="mt-1 flex flex-wrap gap-1.5">
{onlineScreenshots.map((image, index) =>
image.previewUrl ? (
<img
key={`${image.objectKey}-${index}`}
src={image.previewUrl}
alt={`线上截图 ${index + 1} 预览`}
className="h-10 w-16 rounded-md object-cover"
/>
) : null,
)}
</div>
</div>
<PaneButton
variant="ghost"
ariaLabel="整组沿用线上截图"
title="清空草稿的本地截图,发布时整组沿用线上截图"
disabled={form.screenshotPaths.length === 0}
onClick={onAdoptScreenshots}
>
沿用线上截图
</PaneButton>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<PaneButton
variant="ghost"
@@ -1,194 +0,0 @@
import { RefreshCw } from 'lucide-react';
import type { TaonierExportForm } from '../../generated/TaonierExportForm';
import { TAONIER_FORM_FIELDS } from '../../state/taonierFields';
import type {
TaonierOnlineState,
TaonierOnlineValues,
} from '../../state/useTaonierExport';
import { PaneButton } from '../common/PaneButton';
/**
* 线上值卡:把 `read_game_distribution_publication` 读回来的 live 作品行只读并排展示,并给
* 「沿用线上值」动作。
*
* 只读是刻意的:线上值是平台事实,不是这份草稿的一部分。草稿不自动回填、不被覆盖;用户想用线上
* 哪一项就点哪一项的「沿用」,动作由调用方写进草稿并标 dirty(下一次自动保存落盘)。
*
* 图片字段的两条动作语义不同:
* - 封面「沿用线上」= 清空草稿的本地封面路径;
* - 截图「沿用线上」= 清空草稿的截图列表,表示整组沿用。
*
* 读取失败(`unavailable`)不给一份空值冒充「首次发布」——那会让用户在已有作品上再建一个。这里
* 只显示原因与「重新读取」。
*/
export function OnlineValuesCard({
online,
onlineState,
onlineMessage,
form,
onRetry,
onAdoptText,
onAdoptCover,
onAdoptScreenshots,
}: {
online: TaonierOnlineValues | null;
onlineState: TaonierOnlineState;
onlineMessage: string;
form: TaonierExportForm | null;
onRetry: () => 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 (
<section
className="rounded-2xl border border-(--platform-surface-border) bg-(image:--platform-panel-fill) p-4"
aria-label="线上值"
>
<div className="flex items-start justify-between gap-3">
<h3 className="text-sm font-bold text-(--platform-text-strong)">
线上值
</h3>
<PaneButton
variant="ghost"
ariaLabel="重新读取线上值"
title="重新读取线上作品的资料与媒体"
onClick={onRetry}
>
<RefreshCw size={13} aria-hidden="true" />
<span>重新读取</span>
</PaneButton>
</div>
{onlineState === 'checking' || onlineState === 'idle' ? (
<p className="mt-3 text-xs text-(--platform-text-soft)">
正在读取线上值…
</p>
) : null}
{onlineState === 'unavailable' ? (
<p className="mt-3 text-xs leading-relaxed text-(--platform-text-base)">
{onlineMessage || '无法确认该项目的线上发布状态,请稍后重试。'}
</p>
) : null}
{onlineState === 'ready' && online ? (
<>
<p className="mt-2 text-xs leading-relaxed text-(--platform-text-soft)">
{onlineSummary}
</p>
<div className="mt-3 flex flex-col gap-2">
{TAONIER_FORM_FIELDS.map((meta) => {
const value = online[meta.field] ?? '';
const same = (form?.[meta.field] ?? '').trim() === value.trim();
return (
<div
key={meta.field}
className="flex items-start justify-between gap-3 rounded-xl border border-(--platform-surface-border) bg-(--platform-input-fill) px-3 py-2"
>
<div className="grid min-w-0 gap-0.5">
<span className="text-xs font-bold text-(--platform-text-strong)">
{meta.label}
</span>
<span className="break-all text-xs text-(--platform-text-base)">
{value || '(线上为空)'}
</span>
</div>
<PaneButton
variant="ghost"
ariaLabel={`沿用线上${meta.label}`}
title={`把线上${meta.label}写进草稿`}
disabled={!value.trim() || same}
onClick={() => onAdoptText(meta.field)}
>
沿用
</PaneButton>
</div>
);
})}
</div>
<div className="mt-3 grid gap-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<span className="text-xs font-bold text-(--platform-text-strong)">
线上封面
</span>
{online.cover?.previewUrl ? (
<img
src={online.cover.previewUrl}
alt="线上封面预览"
className="mt-1 h-20 w-32 rounded-lg object-cover"
/>
) : (
<p className="mt-1 text-xs text-(--platform-text-soft)">
(线上没有封面)
</p>
)}
</div>
<PaneButton
variant="ghost"
ariaLabel="沿用线上封面"
title="清空草稿的本地封面,发布时沿用线上封面"
disabled={!online.cover?.objectKey || !form?.coverPath.trim()}
onClick={onAdoptCover}
>
沿用线上封面
</PaneButton>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<span className="text-xs font-bold text-(--platform-text-strong)">
线上截图
</span>
{online.screenshots.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-2">
{online.screenshots.map((image, index) =>
image.previewUrl ? (
<img
key={`${image.objectKey}-${index}`}
src={image.previewUrl}
alt={`线上截图 ${index + 1} 预览`}
className="h-16 w-24 rounded-lg object-cover"
/>
) : null,
)}
</div>
) : (
<p className="mt-1 text-xs text-(--platform-text-soft)">
(线上没有截图)
</p>
)}
</div>
<PaneButton
variant="ghost"
ariaLabel="整组沿用线上截图"
title="清空草稿的本地截图,发布时整组沿用线上截图"
disabled={
online.screenshots.length === 0 ||
(form?.screenshotPaths.length ?? 0) === 0
}
onClick={onAdoptScreenshots}
>
沿用线上截图
</PaneButton>
</div>
</div>
</>
) : null}
</section>
);
}
@@ -4,7 +4,7 @@ import { ArtifactsPane } from './ArtifactsPane';
import { ChatPane } from './chat/Pane';
/**
* 陶泥儿工作台:中缝一分为二,左半是文件草稿面板与打包 / 发布,右半是另一个 chatbox。
* 陶泥儿工作台:中缝一分为二,左半是发布表单与唯一的「打包并发布」,右半是另一个 chatbox。
*
* 左右两侧的状态都在面板根的 `useTaonierTab`:这一页跟着 tab 切走时可以卸载,草稿、线上值、
* 订阅、回合与输入草稿都留在面板上。发布进度弹窗由面板根的进度状态驱动,与左半并列挂在同一层。
@@ -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(
<ArtifactsPane
taonier={taonier}
projectPath={PROJECT_PATH}
publish={{ publishing: false, result: null, publish: vi.fn() }}
publish={publish}
/>,
);
}
@@ -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),
@@ -89,7 +89,7 @@
### 3.4 线上值与沿用
- 读命令除草稿外返回只读 `online`:live `GameDistributionGame` 行的 `title/summary/category` + `coverObjectKey` / 截图 objectKey 列表 + 预览地址。
- UI 并排显示「草稿值 vs 线上值」;不自动 seed、不覆盖草稿。
- UI 逐字段内联显示「草稿值 vs 线上值」(线上值一行更小,跟在该字段输入框下,首次发布线上为空时不铺空对照);读取自动进行、失败静默自动重试,不提供手动「重新读取」入口;不自动 seed、不覆盖草稿。
- 「沿用线上值」操作:文字字段一键把线上值写入草稿(标 dirty → 自动落盘);图片字段「替换为本地图」/「沿用线上」(清空本地路径,表示沿用);`screenshotPaths` 为空表示整组沿用线上。
- 发布:路径非空的图发原始二进制;路径为空的图带线上 objectKey;文案沿用现有状态语义,不混淆在审/驳回状态。
@@ -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。