导出面板:打包永远可点,没有脚本时当场给适配出口

- 「打包」不再因为没有脚本判据就变灰:有没有脚本是点下去那一刻才成立的事实,灰着等于把判断推给用户,而他手上没有办法核对;
- 点下去先现算一次,没有脚本就把适配弹窗递过去(`AdaptPromptDialog`),两条出路摆在一起:让陶泥儿调通 / 先放着;
- 面板读不到导出信息时不拿空表单糊弄,读失败与读取中分开显示,并照旧每 2 秒重试;
- `PaneButton` 增加 `brand` 语气与 `hero` 尺寸,打包那颗按钮整行宽、小红书红,是这一页唯一的主行动。
This commit is contained in:
2026-10-06 14:31:46 +08:00
parent 000e3172fb
commit 0f9c3beb8e
4 changed files with 411 additions and 181 deletions
@@ -5,16 +5,16 @@
* 会卸载,但「读到了什么、有什么冲突、正在跑什么」必须留在面板上。
*
* 三条边界写死在这里:
* - agent 调用只有用户显式点「适配 / 让 agent 修」才会发生,保存与导出**不触发任何** agent 调用;
* - agent 调用只有用户显式点「适配 / 让 agent 修 / 帮我填」才会发生,保存与导出**不触发任何**
* agent 调用;
* - `hasScript` 永远来自宿主的现算结果,前端不自己读 `package.json`、也不缓存这个事实;
* - 注册表指纹是写回时的同步细节,不进对外状态:它只在 `baselineRef` 里,且只有进编辑会话或
* 内容真的被采纳时才单独取一次。自动刷新([CONTENT_REFRESH_INTERVAL_MS])读的是不含指纹的
* 内容命令,所以「刷新」在类型上就动不了写回基线。
*
* 失败按 ADR 的两道口子分流(`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`、
* `【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`):typed 变体里除了宿主侧事实故障
* (`exportUnavailable`)都留在面板里就地消化;**宿主故障与认不出形状的拒绝原样抛出**,由全局
* `unhandledrejection` 送进错误池,面板不把原始 message 当成业务提示糊给用户。
* 失败分两道口子:typed 变体里除了宿主侧事实故障(`exportUnavailable`)都留在面板里就地消化;
* **宿主故障与认不出形状的拒绝原样抛出**,由全局 `unhandledrejection` 送进错误池,面板不把原始
* message 当成业务提示糊给用户。
*/
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -22,37 +22,32 @@ import { resolveTauriInvoke } from '../../../../app/tauri';
import type { UserContentPart } from '../../chat/generated/UserContentPart';
import type { FormInvalid } from '../generated/FormInvalid';
import type { SaveConflict } from '../generated/SaveConflict';
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm';
import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState';
import {
asXhsMinitoolFailure,
asXhsMinitoolBuildFailure,
asXhsMinitoolReadFailure,
asXhsMinitoolSaveFailure,
readXhsMinitoolExport,
readXhsMinitoolExportHash,
runXhsMinitoolExportBuild,
saveXhsMinitoolExportForm,
} from './xhsMinitoolApi';
import type { XhsMinitoolCardFailure } from './xhsMinitoolFailure';
import {
classifyXhsMinitoolFailure,
describeXhsMinitoolFailure,
type XhsMinitoolFailureNotice,
XhsMinitoolFailureRepairability,
} from './xhsMinitoolFailure';
import {
buildXhsMinitoolAdaptationInstruction,
buildXhsMinitoolRepairInstruction,
XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
buildXhsMinitoolErrorFixInstruction,
buildXhsMinitoolFormFillInstruction,
} from './xhsMinitoolInstruction';
import { describeOutputTail } from './xhsMinitoolOutputTail';
export type XhsMinitoolExportState = ReturnType<typeof useXhsMinitoolExport>;
/**
* 一次失败的完整现场:给用户看的说法,加上原始载荷(「让 agent 修」要用它拼指令)。
*/
export type XhsMinitoolFailureState = {
notice: XhsMinitoolFailureNotice;
error: XHSMiniToolExportError;
};
/** 输入停下多久之后自动保存:够长到不打断打字,够短到用户切走的时候不丢字。 */
const AUTOSAVE_DELAY_MS = 600;
/**
@@ -62,13 +57,8 @@ const AUTOSAVE_DELAY_MS = 600;
* 每次只读内容(不含指纹),而且只在用户手上没有未保存输入时才采纳表单,所以轮询不会吃掉输入。
*/
const CONTENT_REFRESH_INTERVAL_MS = 2000;
const NO_HOST_NOTICE =
'需要在客户端内打开导出面板,宿主才能读写注册表并运行构建脚本';
/** 把结构化失败收成现场:给用户看的说法 + 原始载荷。 */
function failureState(error: XHSMiniToolExportError): XhsMinitoolFailureState {
return { notice: describeXhsMinitoolFailure(error), error };
}
const NO_HOST_NOTICE = '要在客户端里打开导出面板,才能保存信息并生成代码包';
const CONTENT_UNAVAILABLE_NOTICE = '没能读到导出信息,正在自动重试…';
/** 三个字段就是全部内容,逐字段比即可,不必引入深比较。 */
function sameForm(
@@ -98,16 +88,39 @@ export function useXhsMinitoolExport({
const [hasScript, setHasScript] = useState(false);
const [fieldError, setFieldError] = useState<FormInvalid | null>(null);
const [conflict, setConflict] = useState<SaveConflict | null>(null);
const [failure, setFailure] = useState<XhsMinitoolFailureState | null>(null);
const [failure, setFailure] = useState<XhsMinitoolCardFailure | null>(null);
const [notice, setNotice] = useState('');
const [loading, setLoading] = useState(false);
/**
* 首次读取是否已经落定(无论成功、结构化失败还是认不出形状的拒绝)。
*
* 界面不能拿「`form` 还是 null」当「正在读」的判据:读失败时 `form` 也一直是 null,那样面板会
* 永远停在「正在读取导出信息…」上,用户既看不到失败原因、也不知道它在不在动。
*/
const [readSettled, setReadSettled] = useState(false);
const [running, setRunning] = useState(false);
/**
* 这次会话里是否已经成功打出过分区:只有它为真,面板才给「下载代码包」。
*
* 宿主不报告磁盘上有没有产物,所以初值只能是 `false`(哪怕上一轮会话刚打过);成功构建置真、
* 构建失败置假——上次成功的那份此刻可能已经被失败的那次覆盖或删掉,不给一个下不了的下载按钮。
* 用户改表单不影响它:磁盘上那份仍是可下载的,只是内容旧了。
*/
const [artifactReady, setArtifactReady] = useState(false);
const [dirty, setDirty] = useState(false);
/**
* 「还没适配」的阻断提示被用户处理过一次就收起:无论选了「发给 code agent」还是「我自己来」,
* 都不许每 2 秒重读回来时再弹一次。脚本真的出现过后重置——它要是再消失,提示要能回来。
* 这是纯界面状态,但它必须跨 tab 页卸载存活,所以和写回基线一样挂在面板根的这个 hook 上。
*/
const [adaptPromptDismissed, setAdaptPromptDismissed] = useState(false);
/**
* 写回基线:注册表指纹,保存时当 `baseHash`。**不进对外状态**——界面没有它,也只有保存路径
* 读它。
*/
const baselineRef = useRef('');
/** 首次读取是否已经落定。`loading` 只覆盖这一段,自动重读不许再翻它。 */
const initialReadSettledRef = useRef(false);
/** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */
const adoptableRef = useRef(false);
/** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */
@@ -122,6 +135,11 @@ export function useXhsMinitoolExport({
adoptableRef.current = !dirty && !conflict && !fieldError;
}, [dirty, conflict, fieldError]);
// 脚本出现之后就把提示重新武装:它要是又被删掉,用户该再看到一次阻断提示。
useEffect(() => {
if (hasScript) setAdaptPromptDismissed(false);
}, [hasScript]);
/**
* 真故障(认不出形状的拒绝、宿主侧事实故障)原样抛出,交给全局 `unhandledrejection` 结算。
*
@@ -147,45 +165,71 @@ export function useXhsMinitoolExport({
* `hasScript`,绝不覆盖正在打的字。
*
* 内容真的变了才去取指纹:指纹与内容必须配套,取早了会把外部改动认成自己的基线。
*
* 返回值是**这次现算出来的**「有没有脚本」:`true` / `false` 是宿主的当场结论,`null` 是这次
* 没读到(没有项目路径、没有宿主、注册表坏、认不出形状的拒绝)。「打包」要用这个当场结论决定
* 是接着构建,还是掉头去叫陶泥儿适配——所以读不到时不能谎报成 `false`。
*/
const refresh = useCallback(async (): Promise<void> => {
if (!projectPath) return;
const refresh = useCallback(async (): Promise<boolean | null> => {
if (!projectPath) return null;
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice(NO_HOST_NOTICE);
return;
return null;
}
setLoading(true);
// 只有首次读取才翻 `loading`:自动重读每 2 秒一次,每次都翻一下这个状态,界面上的
// 按钮与卡片就会跟着一闪一闪(未适配提示、按钮行的「适配」都以它为判据之一)。
const isInitialRead = !initialReadSettledRef.current;
if (isInitialRead) setLoading(true);
try {
const content: XHSMiniToolExportState = await readXhsMinitoolExport(
invoke,
projectPath,
);
setHasScript(content.hasScript);
if (!adoptableRef.current || sameForm(formRef.current, content.form)) {
return;
// 首次读取无条件采纳:那时候用户不可能有未保存输入,采纳规则拦在这里只会让面板永远
// 停在「正在读取」(`adoptableRef` 的初值是 false,它是给轮询用的)。
if (
!isInitialRead &&
(!adoptableRef.current || sameForm(formRef.current, content.form))
) {
return content.hasScript;
}
setForm(content.form);
setFieldError(null);
setConflict(null);
setFailure(null);
// 只撤掉「读不到」那一句:适配 / 打包的回执也是 `notice`,读到内容不等于用户读过了它,
// 每 2 秒重读一次就把回执擦掉会让回执根本来不及被看见。
setNotice((previous) =>
previous === CONTENT_UNAVAILABLE_NOTICE ? '' : previous,
);
baselineRef.current = await readXhsMinitoolExportHash(
invoke,
projectPath,
);
return content.hasScript;
} catch (error) {
const structured = asXhsMinitoolFailure(error);
const structured = asXhsMinitoolReadFailure(error);
if (!structured) {
// 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):面板留一句实话,
// 现场原样抛给错误池结算。没有这一句,面板会停在「正在读取」上装死。
setNotice(CONTENT_UNAVAILABLE_NOTICE);
throwToErrorPool(String(error), error);
return;
return null;
}
setFailure(failureState(structured));
setFailure(structured);
// 宿主侧事实故障:先让面板显示现场,再抛进错误池(策略拒绝等预期拒绝不走这一步)。
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
return null;
} finally {
setLoading(false);
if (isInitialRead) {
initialReadSettledRef.current = true;
setLoading(false);
setReadSettled(true);
}
}
}, [projectPath, throwToErrorPool]);
@@ -200,13 +244,20 @@ export function useXhsMinitoolExport({
return () => window.clearInterval(timer);
}, [enabled, refresh]);
/**
* 落盘一份表单,返回是否真的写成功。
*
* 这是**唯一**做表单硬校验的地方(`validate_form` 在保存命令里):自动保存到点就调它,「打包」
* 要落盘手上没保存的输入也调它。返回 `false` 就是这次输入没能落盘,调用方(打包)停在原地等
* 用户处理,不拿一份没通过校验的表单去构建。
*/
const persist = useCallback(
async (next: XHSMiniToolExportForm, baseHash: string): Promise<void> => {
if (!projectPath) return;
async (next: XHSMiniToolExportForm, baseHash: string): Promise<boolean> => {
if (!projectPath) return false;
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice(NO_HOST_NOTICE);
return;
return false;
}
try {
baselineRef.current = await saveXhsMinitoolExportForm(invoke, {
@@ -218,23 +269,25 @@ export function useXhsMinitoolExport({
setConflict(null);
setFailure(null);
setDirty(false);
return true;
} catch (error) {
// 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、
// 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。
setDirty(false);
const structured = asXhsMinitoolFailure(error);
const structured = asXhsMinitoolSaveFailure(error);
if (!structured) {
throwToErrorPool(String(error), error);
return;
return false;
}
if (structured.type === 'formInvalid') setFieldError(structured);
else if (structured.type === 'saveConflict') setConflict(structured);
else {
setFailure(failureState(structured));
setFailure(structured);
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
}
return false;
}
},
[projectPath, throwToErrorPool],
@@ -272,69 +325,180 @@ export function useXhsMinitoolExport({
[conflict, persist],
);
/** 导出:每次都重新跑脚本、重新产出 zip,没有"上次的产物还能用"这条捷径。 */
const runExport = useCallback(async (): Promise<void> => {
/**
* 跑一次构建:每次都重新跑脚本、重新产出 zip,没有"上次的产物还能用"这条捷径。
*
* 不碰 `running`:调用方决定这次构建是一次独立导出,还是「打包」里保存之后的收尾。
*/
const buildOnce = useCallback(async (): Promise<void> => {
if (!projectPath) return;
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice(NO_HOST_NOTICE);
return;
}
setRunning(true);
setNotice('');
setFailure(null);
try {
const result = await runXhsMinitoolExportBuild(invoke, projectPath);
setArtifactReady(true);
// 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。
setNotice(
[
`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`,
describeOutputTail(result),
]
['代码包已重新生成,点「下载」保存到本地。', describeOutputTail(result)]
.filter((line) => line.trim() !== '')
.join('\n'),
);
} catch (error) {
const structured = asXhsMinitoolFailure(error);
setArtifactReady(false);
const structured = asXhsMinitoolBuildFailure(error);
if (!structured) {
throwToErrorPool(String(error), error);
return;
}
setFailure(failureState(structured));
setFailure(structured);
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
} finally {
setRunning(false);
}
}, [projectPath, throwToErrorPool]);
/** 只重新构建:失败卡片上的「重试」走这里,不夹带保存。 */
const runExport = useCallback(async (): Promise<void> => {
if (!projectPath) return;
setRunning(true);
try {
await buildOnce();
} finally {
setRunning(false);
}
}, [projectPath, buildOnce]);
/**
* 打包:先**现场复算**「有没有脚本」,再冲掉没保存的输入,最后跑构建。
*
* 「打包」永远可点(除了正在跑),因为「有脚本」是个当场才成立的事实:agent 可能刚把脚本写进
* 项目、用户可能刚把上一份删掉。点一下就现算一次,比让用户先找别的地方核对再回来点便宜。
*
* 没有脚本(或这次读不到)时不报错了事,而是把阻断弹窗重新武装起来——用户点「打包」的本意就是
* 「我要一份代码包」,这条路走不通时该当场把「让陶泥儿帮你调通」这个出口递给他。
*
* 这里不做任何表单校验——校验只发生在保存命令里(`persist`)。打包只关心「盘上的注册表是不是
* 用户现在这份」:有未保存输入就先 flush,flush 回 `false`(字段不合法、撞上冲突、宿主故障)
* 就停在表单上让用户处理;冲突或字段红字还没解决也一样停住。构建本身只认盘上的文件。
*/
const packageAll = useCallback(async (): Promise<void> => {
if (!projectPath) return;
setRunning(true);
try {
const hasScriptNow = await refresh();
if (hasScriptNow !== true) {
setAdaptPromptDismissed(false);
if (hasScriptNow === false) {
setNotice(
'项目还没配置好导出,先让陶泥儿帮你调通,之后就能直接打包了',
);
}
return;
}
// 先 flush 自动保存:用户刚敲完就点打包时,注册表里可能还是上一份。
if (dirty && form && !conflict && !fieldError) {
const flushed = await persist(form, baselineRef.current);
if (!flushed) return;
}
// 没解决的冲突 / 字段红字:盘上的内容和用户看到的不一致,先别打包。
if (conflict || fieldError) return;
await buildOnce();
} finally {
setRunning(false);
}
}, [
projectPath,
refresh,
dirty,
form,
conflict,
fieldError,
persist,
buildOnce,
]);
/**
* 把一条指令当用户消息发给项目会话。
*
* `sentNotice` 由调用方按「首次适配 / 失败修复」分别给:两条路径给 agent 的指令不同,给用户的
* 回执也不该长一个样——失败修复那条必须把这次的失败原因复述出来,否则失败卡片收掉之后用户只剩
* 一句「已交给 agent」。
*/
const sendToAgent = useCallback(
async (instruction: string): Promise<void> => {
async (instruction: string, sentNotice: string): Promise<void> => {
const handedOver = await submitTurn([
{ type: 'input_text', text: instruction },
]);
setNotice(
handedOver
? '已把任务交给 code agent,右侧会话里能看到它的进展'
: '这条任务没有发出去,原因在右侧会话里',
);
setNotice(handedOver ? sentNotice : '这次没能发出去,原因在右侧会话里');
},
[submitTurn],
);
/** 一键适配:把首次适配指令发给同一个项目会话。 */
/** 收起「还没适配」的阻断提示。用户选「我自己来」时走这里。 */
const dismissAdaptPrompt = useCallback((): void => {
setAdaptPromptDismissed(true);
}, []);
/**
* 「帮我填」:把手上没保存的输入先落盘,再让 agent 直接改注册表把表单三项填好。
*
* 这个动作的同步链只有一条:盘上那份注册表文件。agent 照它改,宿主每 2 秒读它一次带回界面——
* 所以我们没落盘的输入它看不见,而且 `dirty` 不清零时重读按规矩不覆盖表单,agent 写的新值也进
* 不来。**先落盘不是附带效果,是这条链的前半截。**
*
* 落盘结果不挡这次交接:agent 正是来收拾这份表单的,把它拦在门外只剩用户自己改,所以不看
* `persist` 的返回值——不合法、撞冲突的那些值随指令一起原样带给 agent。
*/
const fillForm = useCallback(async (): Promise<void> => {
if (!projectPath || !form) return;
// 冲突或字段红字挂着时不重复撞一次保存(自动保存此时也是停的),直接把现场交给 agent。
if (dirty && !conflict && !fieldError) {
await persist(form, baselineRef.current);
}
await sendToAgent(
buildXhsMinitoolFormFillInstruction(),
'已让陶泥儿来填这三项,它改完会自动出现在上面',
);
}, [projectPath, form, dirty, conflict, fieldError, persist, sendToAgent]);
/** 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 */
const adapt = useCallback((): void => {
if (!projectPath) return;
void sendToAgent(buildXhsMinitoolAdaptationInstruction(projectPath));
setAdaptPromptDismissed(true);
void sendToAgent(
buildXhsMinitoolAdaptationInstruction(),
'已让陶泥儿帮你调通导出,右侧会话里能看到它的进展',
);
}, [projectPath, sendToAgent]);
/** 按当前失败现场让 agent 修。失败卡片上的那颗按钮走这里。 */
/**
* 按当前失败现场让 agent 修(error fix)。失败卡片上的那颗按钮走这里。
*
* 只有 `CodeAgentRepairable` 才允许走这条路:冲突要用户在冲突面板上逐字段二选一、表单值不对要用户改、
* 项目权限策略拉黑了 `command.exec` 是用户自己的配置(agent 受同一条策略约束)、宿主没跑起来改代码没用——
* 这些叫 agent 进来它只会去改一份没问题可改的代码。
*
* 用户回执带失败原因:卡片随即被撤掉,这句提示是面板上唯一还留着「刚才为什么开这一轮」的地方。
*/
const repair = useCallback((): void => {
if (!projectPath || !failure) return;
if (
classifyXhsMinitoolFailure(failure) !==
XhsMinitoolFailureRepairability.CodeAgentRepairable
) {
return;
}
// 回执复述用户侧的说法;给 agent 的指令直接带原始错误,两者互不派生。
const reason = describeXhsMinitoolFailure(failure);
setFailure(null);
void sendToAgent(
buildXhsMinitoolRepairInstruction(projectPath, failure.error),
buildXhsMinitoolErrorFixInstruction(failure),
`已让陶泥儿来修。\n出错原因:${reason}`,
);
}, [projectPath, failure, sendToAgent]);
@@ -342,7 +506,7 @@ export function useXhsMinitoolExport({
* 对外只有界面要用的东西:内容(表单、脚本判据)、三种待处理的现场、跑动状态。
*
* 同步细节(写回基线、保存中、待保存)刻意不在这里:它们没有对应的界面,露出来只会诱使调用方
* 拿它做判断。刷新也不是对外动作——重读内容由自动刷新和「导出」之后的现算负责。
* 拿它做判断。刷新也不是对外动作——重读内容由自动刷新和「打包」之后的现算负责。
*/
return {
form,
@@ -352,11 +516,17 @@ export function useXhsMinitoolExport({
failure,
notice,
loading,
readSettled,
running,
artifactReady,
adaptPromptDismissed,
updateField,
resolveConflict,
runExport,
packageAll,
adapt,
repair,
fillForm,
dismissAdaptPrompt,
};
}
@@ -0,0 +1,48 @@
import { ThemedModal } from '../../../../../components/modal/ThemedModal';
import { PaneButton } from './PaneButton';
/**
* 「还没配置好导出」的阻断提示。
*
* 判据只有一个:宿主现算的 `hasScript` 为假。这件事只能由陶泥儿在项目里实验完成(用哪版 vite
* 配置、入口在哪、怎么调打包脚本都由它定),所以用户只有两条路:现在就让陶泥儿调通,或者自己处理。
*
* 是阻断层而不是卡片:不点一个选择就不放行,避免用户在不清楚状况时直接点「打包」。两个选择都必须
* 显式给出,背景点击与 Esc 不生效。
*
* 文案不出现脚本名、注册表、zip 这些实现词——用户要的是「怎么才能打包」。
*/
export function AdaptPromptDialog({
open,
onSendToAgent,
onDoItMyself,
}: {
open: boolean;
onSendToAgent: () => void;
onDoItMyself: () => void;
}) {
return (
<ThemedModal
open={open}
ariaLabel="这个项目还没有配置好导出"
onClose={onDoItMyself}
closeOnBackdrop={false}
closeOnEscape={false}
panelClassName="w-full max-w-md rounded-2xl border border-[#e2cbb8] p-4"
>
<h2 className="text-sm font-semibold text-[#3d1f10]">
这个项目还没有配置好导出
</h2>
<p className="mt-2 text-xs leading-relaxed text-[#6f5848]">
要让陶泥儿先在项目里帮你调通一次导出流程(用哪套配置、入口在哪、怎么打包都由它试出来)。
调通之后,每次点「打包」就能直接生成代码包。
</p>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<PaneButton onClick={onDoItMyself}>我自己来</PaneButton>
<PaneButton variant="primary" onClick={onSendToAgent}>
让陶泥儿帮我调通
</PaneButton>
</div>
</ThemedModal>
);
}
@@ -2,22 +2,24 @@ import { useState } from 'react';
import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport';
import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload';
import {
XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
XHS_MINITOOL_SCRIPT_NAME,
} from '../../state/xhsMinitoolInstruction';
import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction';
import { AdaptPromptDialog } from './AdaptPromptDialog';
import { ConflictCard } from './ConflictCard';
import { CopyButton } from './CopyButton';
import { FailureCard } from './FailureCard';
import { FormCard } from './FormCard';
import { PaneButton } from './PaneButton';
/**
* 小红书工作台左半:平台上传表单 + 交付产物 + 两颗显式按钮。
* 小红书工作台左半:平台上传表单 + 小工具代码包 + 「打包」。
*
* 两颗按钮各管一件事,**都没有自动跑**:适配(叫 code agent 进项目做首次实验)、导出(每次重新
* 构建)。其余动作是自动的,且都不碰 agent:表单停手后自动落盘、内容与脚本判据按固定间隔重读
* (所以没有刷新按钮)——判断结果直接反映在按钮的可用状态上,不用用户去核对一个同步状态。
* 「打包」(先校验落盘表单、再跑构建产出 zip,一次做完,小红书红)挂在「小工具代码包」段落里,
* 打成功了同一行才多出「下载代码包」——没打过就不给一个下不了的按钮。适配入口不在按钮行里——
* 未配置好导出时由阻断弹窗(`AdaptPromptDialog`)给出「让陶泥儿帮我调通 / 我自己来」两个选择,
* 失败现场由失败卡片的 `adapt` 入口兜底。其余动作是自动的,且都不碰 agent:表单停手后自动落盘、
* 内容与脚本判据按固定间隔重读(所以没有刷新按钮)。
*
* 「还没配置好导出」不再是一张常驻卡片,而是阻断弹窗:重读每 2 秒一次,卡片会跟着重读闪,弹窗则
* 只在用户真的需要做选择时出现一次。可见文案说用户的话,不出现脚本名、注册表、zip 这些实现词。
*/
export function ArtifactsPane({
minitool,
@@ -29,18 +31,21 @@ export function ArtifactsPane({
const [downloadNotice, setDownloadNotice] = useState('');
const { form, conflict } = minitool;
/**
* 未适配:那颗按钮搬进提示卡片,说明和入口放一起,用户不用在别处找。
*
* 判据带上 `form`:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目
* 下「还没适配」的结论——那种情况只该显示读取中或失败现场。
* 未适配:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目下
* 「还没适配」的结论——那种情况只该显示读取中或失败现场。
*
* 判据带上 `failure`:失败卡片带着宿主的现场和同一颗按钮,两张卡一起出现就是同一件事两个入口。
*/
const missingScript =
const needsAdaptation =
!minitool.loading &&
form !== null &&
!minitool.hasScript &&
minitool.failure === null;
/**
* 弹窗只弹一次:用户选过「发送提示词 / 我自己来」之后,重读回来仍是没有脚本也不许再拦。
* 选择留在面板根的 hook 上,切 tab 页再回来不会重新弹。
*/
const showAdaptPrompt = needsAdaptation && !minitool.adaptPromptDismissed;
const downloadIcon = (): void => {
if (!projectPath || !form) return;
@@ -48,7 +53,7 @@ export function ArtifactsPane({
projectPath,
relativePath: form.iconPath.trim(),
fallbackFileName: 'icon.png',
title: '保存 icon 文件',
title: '保存图标文件',
}).then(setDownloadNotice);
};
@@ -58,113 +63,104 @@ export function ArtifactsPane({
projectPath,
relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
fallbackFileName: 'xhs-minitool.zip',
title: '保存代码 zip',
title: '保存代码包',
}).then(setDownloadNotice);
};
return (
<div
className="flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-auto border-[#e2cbb8] bg-[#fffdf9]/40 p-3 md:border-r"
className="flex h-full min-h-0 min-w-0 flex-col border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r"
aria-label="导出内容区"
>
<div className="flex flex-wrap items-center gap-2">
{missingScript ? null : (
<PaneButton
variant={minitool.hasScript ? 'ghost' : 'primary'}
onClick={minitool.adapt}
>
适配
</PaneButton>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3">
{form ? (
<FormCard
form={form}
fieldError={minitool.fieldError}
projectPath={projectPath}
onChange={minitool.updateField}
onDownloadIcon={downloadIcon}
onAskAgentToFill={() => void minitool.fillForm()}
/>
) : !minitool.readSettled ? (
<p className="text-xs text-[#988476]">正在读取导出信息…</p>
) : // 读失败时上面不重复一句话:失败卡片拿着宿主的现场和下一步。
minitool.failure ? null : (
<p className="text-xs text-[#988476]">
{minitool.notice || '没能读到导出信息,正在自动重试…'}
</p>
)}
<PaneButton
variant={minitool.hasScript ? 'primary' : 'ghost'}
disabled={!minitool.hasScript || minitool.running}
title={
minitool.hasScript
? `重新运行 ${XHS_MINITOOL_SCRIPT_NAME}`
: `项目里还没有 ${XHS_MINITOOL_SCRIPT_NAME},用下面卡片里的按钮让 code agent 接入`
}
onClick={() => void minitool.runExport()}
<section
className="rounded-2xl border border-[#e2cbb8] bg-[#fffaf5] p-4"
aria-label="小工具代码包"
>
{minitool.running ? '导出中…' : '导出'}
</PaneButton>
<h3 className="text-sm font-bold text-[#3d1f10]">小工具代码包</h3>
<div className="mt-4 flex flex-col gap-2">
{/*
「打包」不因为有脚本判据没到位就变灰:有没有脚本是**点下去那一刻**才成立的事实
(agent 可能刚写完、用户可能刚删掉)。灰着按钮等于把「能不能开始」的判断推给用户,
而他手上没有任何办法去核对。点下去先现算一次,没有就当场把适配出口递过去。
*/}
<PaneButton
variant="brand"
size="hero"
disabled={minitool.running}
title={
minitool.hasScript
? '重新生成一份小红书小工具代码包'
: '项目还没配置好导出,先让陶泥儿帮你调通'
}
onClick={() => void minitool.packageAll()}
>
{minitool.running ? '打包中…' : '打包'}
</PaneButton>
{/* 下载入口只在打包成功之后出现:宿主不报告磁盘上有没有产物,没打过就不给一个下不了的按钮。 */}
{minitool.artifactReady ? (
<PaneButton
ariaLabel="下载代码包"
className="self-start"
onClick={downloadArtifact}
>
下载代码包
</PaneButton>
) : null}
</div>
</section>
{conflict && form ? (
<ConflictCard
key={conflict.contentHash}
conflict={conflict}
mine={form}
onResolve={minitool.resolveConflict}
/>
) : null}
{minitool.failure ? (
<FailureCard
failure={minitool.failure}
onAdapt={minitool.adapt}
onRepair={minitool.repair}
onRetry={() => void minitool.runExport()}
/>
) : null}
{downloadNotice || minitool.notice ? (
<p
role="status"
className="whitespace-pre-wrap break-all text-xs text-[#6f5848]"
>
{downloadNotice || minitool.notice}
</p>
) : null}
</div>
{missingScript ? (
<section
aria-label="还没有构建脚本"
className="flex flex-col items-start gap-2 rounded-xl border border-dashed border-[#e2cbb8] bg-[#fffaf5] p-3"
>
<p className="text-xs text-[#6f5848]">
{`项目里还没有 \`${XHS_MINITOOL_SCRIPT_NAME}\` 脚本——这就是「还没适配」的判据。这件事只能由 code agent 在项目里实验完成(用哪版 vite 配置、入口在哪、怎么调打包脚本都由它定):`}
</p>
<PaneButton variant="primary" onClick={minitool.adapt}>
交给 code agent 适配
</PaneButton>
</section>
) : null}
{form ? (
<FormCard
form={form}
fieldError={minitool.fieldError}
onChange={minitool.updateField}
onDownloadIcon={downloadIcon}
/>
) : (
<p className="text-xs text-[#988476]">
{minitool.notice || '正在读取导出注册表…'}
</p>
)}
<section
className="rounded-xl border border-[#e2cbb8] bg-[#fffaf5] p-3"
aria-label="交付产物"
>
<h3 className="text-sm font-semibold text-[#3d1f10]">代码 zip</h3>
<p className="mt-1 text-xs text-[#988476]">
{`点「导出」会重新运行 ${XHS_MINITOOL_SCRIPT_NAME} 覆盖这一份,不复用上次的产物。`}
</p>
<div className="mt-2 flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg border border-[#e2cbb8] bg-white px-2 py-1.5 text-xs text-[#3d1f10]">
{XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}
</code>
<CopyButton
value={XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}
label="产物路径"
/>
<PaneButton ariaLabel="下载代码 zip" onClick={downloadArtifact}>
下载
</PaneButton>
</div>
</section>
{conflict && form ? (
<ConflictCard
key={conflict.contentHash}
conflict={conflict}
mine={form}
onResolve={minitool.resolveConflict}
/>
) : null}
{minitool.failure ? (
<FailureCard
failure={minitool.failure}
onAdapt={minitool.adapt}
onRepair={minitool.repair}
onRetry={() => void minitool.runExport()}
/>
) : null}
{downloadNotice || minitool.notice ? (
<p
role="status"
className="whitespace-pre-wrap break-all text-xs text-[#6f5848]"
>
{downloadNotice || minitool.notice}
</p>
) : null}
<AdaptPromptDialog
open={showAdaptPrompt}
onSendToAgent={minitool.adapt}
onDoItMyself={minitool.dismissAdaptPrompt}
/>
</div>
);
}
@@ -1,34 +1,50 @@
import type { ReactNode } from 'react';
/**
* 面板里的小按钮。三种语气的样式收在这一处,别在每个卡片里各抄一遍 Tailwind 串。
* 面板里的按钮。语气与尺寸收在这一处,别在每个卡片里各抄一遍 Tailwind 串。
*
* `brand` 是小红书红;`hero` 只给「打包」那颗主行动按钮——整行宽、字号更大,是这一页唯一一个
* 「点下去就产出东西」的动作;其余是次要动作(复制、下载、重试),尺寸跟发布面板
* (`GameDistributionPublishPanel`)对齐:10px/14px 内边距、10px 圆角、600 字重。
*/
export function PaneButton({
children,
variant = 'ghost',
size = 'sm',
disabled,
title,
ariaLabel,
className,
onClick,
}: {
children: ReactNode;
variant?: 'primary' | 'ghost';
variant?: 'primary' | 'ghost' | 'brand';
size?: 'sm' | 'lg' | 'hero';
disabled?: boolean;
title?: string;
ariaLabel?: string;
className?: string;
onClick: () => void;
}) {
const tone =
variant === 'primary'
? 'border-[#c7653d] bg-[#c7653d] text-white hover:bg-[#b4562f]'
: 'border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]';
variant === 'brand'
? 'border-[#ff2442] bg-[#ff2442] text-white hover:bg-[#e01e3a]'
: variant === 'primary'
? 'border-[#c7653d] bg-[#c7653d] text-white hover:bg-[#b4562f]'
: 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]';
const sizing =
size === 'hero'
? 'w-full justify-center gap-2 rounded-xl px-6 py-4 text-lg font-bold tracking-wide shadow-md'
: size === 'lg'
? 'justify-center gap-2 rounded-xl px-6 py-3 text-base font-semibold shadow-sm'
: 'gap-1.5 rounded-[10px] px-3.5 py-2 text-xs font-semibold';
return (
<button
type="button"
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
{...(title ? { title } : {})}
disabled={disabled === true}
className={`inline-flex items-center gap-1 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-45 ${tone}`}
className={`inline-flex items-center border transition disabled:cursor-not-allowed disabled:opacity-45 ${tone} ${sizing}${className ? ` ${className}` : ''}`}
onClick={onClick}
>
{children}