导出面板:失败一律走阻断弹窗,去掉「已保存到 …」与整条通知行

- 失败现场从就地卡片改成阻断弹窗(`FailureDialog` 取代 `FailureCard`):左半本来就矮,卡片会被滚出视野,而失败是用户必须看见的东西;
- 弹窗按「谁有能力修」给按钮:项目还没适配给「让陶泥儿帮我调通」,适配过但这次没跑通给「让陶泥儿来修」,改不动的(权限策略拉黑 `command.exec`、宿主没跑起来)不给陶泥儿入口;每条都带「关闭」,关掉的现场记进 `dismissedFailure`,同一个现场不会因为下一次重读又跳回来——没有「重试」,关掉回面板点「打包」是同一件事;
- 一键适配也清掉失败现场:从「还没配置好导出」那条弹窗进来的就是同一个 `buildScriptMissing`,不清它弹窗关不上;
- 删掉通知行(那段 `role="status"`)与整个 `notice` 状态:保存产物、打包结果、入队结果都不再在导出面板里回执;读不到内容仍由那段占位文案交代,真故障仍走错误池;
- 保存产物不再返回「已保存到 …」:落点与结果都是用户在原生对话框里亲手给的,失败只留一条控制台痕迹;
- 输出尾部的拼句模块 `xhsMinitoolOutputTail.ts` 随之删除(唯一调用方就是那条通知);
- 用例跟着改:删掉截断拼句那条,失败相关改称弹窗并断言 `role="dialog"`,补一条「关掉同一个现场不再弹」。
This commit is contained in:
2026-10-06 16:09:58 +08:00
parent 66ff1d20aa
commit 7614f741e1
7 changed files with 194 additions and 212 deletions
@@ -36,7 +36,6 @@ import {
import type { XhsMinitoolCardFailure } from './xhsMinitoolFailure';
import {
classifyXhsMinitoolFailure,
describeXhsMinitoolFailure,
XhsMinitoolFailureRepairability,
} from './xhsMinitoolFailure';
import {
@@ -44,7 +43,6 @@ import {
buildXhsMinitoolErrorFixInstruction,
buildXhsMinitoolFormFillInstruction,
} from './xhsMinitoolInstruction';
import { describeOutputTail } from './xhsMinitoolOutputTail';
export type XhsMinitoolExportState = ReturnType<typeof useXhsMinitoolExport>;
@@ -57,8 +55,6 @@ const AUTOSAVE_DELAY_MS = 600;
* 每次只读内容(不含指纹),而且只在用户手上没有未保存输入时才采纳表单,所以轮询不会吃掉输入。
*/
const CONTENT_REFRESH_INTERVAL_MS = 2000;
const NO_HOST_NOTICE = '要在客户端里打开导出面板,才能保存信息并生成代码包';
const CONTENT_UNAVAILABLE_NOTICE = '没能读到导出信息,正在自动重试…';
/** 三个字段就是全部内容,逐字段比即可,不必引入深比较。 */
function sameForm(
@@ -89,9 +85,11 @@ export function useXhsMinitoolExport({
const [fieldError, setFieldError] = useState<FormInvalid | null>(null);
const [conflict, setConflict] = useState<SaveConflict | null>(null);
const [failure, setFailure] = useState<XhsMinitoolCardFailure | null>(null);
// 界面上「有事要说」的那一句:读不到内容、打包结果、指令没发出去、失败修复的原因。
// 用户刚点的动作本身不回执——「已交给陶泥儿」这类复述是噪音。
const [notice, setNotice] = useState('');
/**
* 被用户关掉的失败现场指纹:内容读不回来时每 2 秒重读一次,同一个现场会一直被送回来,
* 关掉之后就不该再弹;换了一个新的现场(指纹不同)照弹。
*/
const [dismissedFailure, setDismissedFailure] = useState('');
const [loading, setLoading] = useState(false);
/**
* 首次读取是否已经落定(无论成功、结构化失败还是认不出形状的拒绝)。
@@ -175,10 +173,7 @@ export function useXhsMinitoolExport({
const refresh = useCallback(async (): Promise<boolean | null> => {
if (!projectPath) return null;
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice(NO_HOST_NOTICE);
return null;
}
if (!invoke) return null;
// 只有首次读取才翻 `loading`:自动重读每 2 秒一次,每次都翻一下这个状态,界面上的
// 按钮与卡片就会跟着一闪一闪(未适配提示、按钮行的「适配」都以它为判据之一)。
const isInitialRead = !initialReadSettledRef.current;
@@ -201,11 +196,6 @@ export function useXhsMinitoolExport({
setFieldError(null);
setConflict(null);
setFailure(null);
// 只撤掉「读不到」那一句:打包结果、失败修复的原因也是 `notice`,读到内容不等于用户读过了它,
// 每 2 秒重读一次就把它们擦掉会让这些话根本来不及被看见。
setNotice((previous) =>
previous === CONTENT_UNAVAILABLE_NOTICE ? '' : previous,
);
baselineRef.current = await readXhsMinitoolExportHash(
invoke,
projectPath,
@@ -214,9 +204,8 @@ export function useXhsMinitoolExport({
} catch (error) {
const structured = asXhsMinitoolReadFailure(error);
if (!structured) {
// 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):面板留一句实话,
// 现场原样抛给错误池结算。没有这一句,面板会停在「正在读取」上装死。
setNotice(CONTENT_UNAVAILABLE_NOTICE);
// 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):现场原样抛给错误池
// 结算;面板那一侧退回「没能读到导出信息,正在自动重试…」,不会停在「正在读取」上装死。
throwToErrorPool(String(error), error);
return null;
}
@@ -257,10 +246,7 @@ export function useXhsMinitoolExport({
async (next: XHSMiniToolExportForm, baseHash: string): Promise<boolean> => {
if (!projectPath) return false;
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice(NO_HOST_NOTICE);
return false;
}
if (!invoke) return false;
try {
baselineRef.current = await saveXhsMinitoolExportForm(invoke, {
projectPath,
@@ -335,21 +321,11 @@ export function useXhsMinitoolExport({
const buildOnce = useCallback(async (): Promise<void> => {
if (!projectPath) return;
const invoke = resolveTauriInvoke();
if (!invoke) {
setNotice(NO_HOST_NOTICE);
return;
}
setNotice('');
if (!invoke) return;
setFailure(null);
try {
const result = await runXhsMinitoolExportBuild(invoke, projectPath);
await runXhsMinitoolExportBuild(invoke, projectPath);
setArtifactReady(true);
// 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。
setNotice(
['代码包已重新生成,点「下载」保存到本地。', describeOutputTail(result)]
.filter((line) => line.trim() !== '')
.join('\n'),
);
} catch (error) {
setArtifactReady(false);
const structured = asXhsMinitoolBuildFailure(error);
@@ -423,16 +399,12 @@ export function useXhsMinitoolExport({
/**
* 把一条指令当用户消息发给项目会话。
*
* 成功不回执:这几个动作都是用户刚点的那颗按钮,面板再复述一句「已交给陶泥儿」只是噪音。
* 留在界面上的只有两种话——发不出去时的实话,和失败修复必须留下的出错原因(失败卡片收掉之后,
* 那是唯一还写着「刚才为什么开这一轮」的地方)。
* 入队结果不在导出面板里复述:走的与会话输入框同一条 `submit`,忙态、队满、发不出去都由会话
* 那一侧告知;用户刚点的动作再铺一句回执只是噪音。
*/
const sendToAgent = useCallback(
async (instruction: string, sentNotice = ''): Promise<void> => {
const handedOver = await submitTurn([
{ type: 'input_text', text: instruction },
]);
setNotice(handedOver ? sentNotice : '这次没能发出去,原因在右侧会话里');
async (instruction: string): Promise<void> => {
await submitTurn([{ type: 'input_text', text: instruction }]);
},
[submitTurn],
);
@@ -442,6 +414,13 @@ export function useXhsMinitoolExport({
setAdaptPromptDismissed(true);
}, []);
/** 关掉失败弹窗:现场记进 `dismissedFailure`,同一个现场不会因为下一次重读又跳出来。 */
const dismissFailure = useCallback((): void => {
if (!failure) return;
setDismissedFailure(JSON.stringify(failure));
setFailure(null);
}, [failure]);
/**
* 「帮我填」:把手上没保存的输入先落盘,再让 agent 直接改注册表把表单三项填好。
*
@@ -461,10 +440,16 @@ export function useXhsMinitoolExport({
await sendToAgent(buildXhsMinitoolFormFillInstruction());
}, [projectPath, form, dirty, conflict, fieldError, persist, sendToAgent]);
/** 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 */
/**
* 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。
*
* 也清掉失败现场:入口可能来自失败弹窗(「还没配置好导出」那是同一个 `buildScriptMissing`),
* 留着它弹窗就关不上,而这一颗按钮已经把用户要交给陶泥儿的事交出去了。
*/
const adapt = useCallback((): void => {
if (!projectPath) return;
setAdaptPromptDismissed(true);
setFailure(null);
void sendToAgent(buildXhsMinitoolAdaptationInstruction());
}, [projectPath, sendToAgent]);
@@ -475,7 +460,7 @@ export function useXhsMinitoolExport({
* 项目权限策略拉黑了 `command.exec` 是用户自己的配置(agent 受同一条策略约束)、宿主没跑起来改代码没用——
* 这些叫 agent 进来它只会去改一份没问题可改的代码。
*
* 用户回执带失败原因:卡片随即被撤掉,这句提示是面板上唯一还留着「刚才为什么开这一轮」的地方。
* 失败现场当场清掉:弹窗由它开关,指令本身已经带着原始错误交给陶泥儿了。
*/
const repair = useCallback((): void => {
if (!projectPath || !failure) return;
@@ -485,10 +470,8 @@ export function useXhsMinitoolExport({
) {
return;
}
// 回执复述用户侧的说法;给 agent 的指令直接带原始错误,两者互不派生。
const reason = describeXhsMinitoolFailure(failure);
setFailure(null);
void sendToAgent(buildXhsMinitoolErrorFixInstruction(failure), reason);
void sendToAgent(buildXhsMinitoolErrorFixInstruction(failure));
}, [projectPath, failure, sendToAgent]);
/**
@@ -497,13 +480,16 @@ export function useXhsMinitoolExport({
* 同步细节(写回基线、保存中、待保存)刻意不在这里:它们没有对应的界面,露出来只会诱使调用方
* 拿它做判断。刷新也不是对外动作——重读内容由自动刷新和「打包」之后的现算负责。
*/
/** 对外只给没被关掉的那个现场:关掉的还是同一个就不弹。 */
const visibleFailure =
failure && JSON.stringify(failure) === dismissedFailure ? null : failure;
return {
form,
hasScript,
fieldError,
conflict,
failure,
notice,
failure: visibleFailure,
loading,
readSettled,
running,
@@ -517,5 +503,6 @@ export function useXhsMinitoolExport({
repair,
fillForm,
dismissAdaptPrompt,
dismissFailure,
};
}
@@ -3,13 +3,15 @@
*
* 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护
* 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。
*
* 不回话也不抛:落点与结果都是用户在原生对话框里亲手给的,面板不再补一句「已保存到 …」;
* 失败只留一条控制台痕迹,不打断用户手上的事。
*/
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import { resolveTauriInvoke } from '../../../../app/tauri';
import { resourceFileNameFromPath } from '../../../../features/resource-canvas/resourceCanvasAssetTransferModel';
/** 返回给用户看的一句话;用户取消对话框时返回空串(不打扰)。 */
export async function saveXhsMinitoolFileToDisk(input: {
projectPath: string;
/** 项目内相对路径,交给宿主的既有门禁复核。 */
@@ -17,9 +19,9 @@ export async function saveXhsMinitoolFileToDisk(input: {
/** 路径里取不出文件名时用的兜底名。 */
fallbackFileName: string;
title: string;
}): Promise<string> {
}): Promise<void> {
const invoke = resolveTauriInvoke();
if (!invoke) return '要在客户端里打开导出面板才能保存文件';
if (!invoke) return;
let destination: string | null = null;
try {
destination = await saveNativeFileDialog({
@@ -30,22 +32,20 @@ export async function saveXhsMinitoolFileToDisk(input: {
title: input.title,
});
} catch (error) {
return error instanceof Error ? error.message : String(error);
console.warn('导出产物保存对话框没能打开', error);
return;
}
if (!destination) return '';
// 用户取消对话框:什么都没发生,也没有话要说。
if (!destination) return;
try {
const result = await invoke<{ destinationPath: string }>(
'save_local_project_asset_file',
{
input: {
projectPath: input.projectPath,
relativePath: input.relativePath,
destinationPath: destination,
},
await invoke('save_local_project_asset_file', {
input: {
projectPath: input.projectPath,
relativePath: input.relativePath,
destinationPath: destination,
},
);
return `已保存到 ${result.destinationPath}`;
});
} catch (error) {
return error instanceof Error ? error.message : String(error);
console.warn('导出产物没能写到你选的位置', error);
}
}
@@ -1,16 +0,0 @@
/**
* 构建输出尾部的显示文本:宿主只给两个事实(原文 + 省略了多少字符),句子在这里拼。
*
* 宿主不预拼用户可见文案:省略量是事实,
* 「省略了多少」怎么说、放在前还是后、要不要提示「尾巴不完整」,都由拿得到现场的这一层决定。
* 失败卡片与导出成功提示共用这一份,避免两处各写一句。
*/
export function describeOutputTail(input: {
outputTail: string;
omittedCharacters: number;
}): string {
const tail = input.outputTail.trim();
if (input.omittedCharacters <= 0) return tail;
const note = `(内容较长,前面省略了 ${input.omittedCharacters} 个字符)`;
return tail ? `${note}\n${tail}` : note;
}
@@ -1,11 +1,9 @@
import { useState } from 'react';
import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport';
import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload';
import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction';
import { AdaptPromptDialog } from './AdaptPromptDialog';
import { ConflictCard } from './ConflictCard';
import { FailureCard } from './FailureCard';
import { FailureDialog } from './FailureDialog';
import { FormCard } from './FormCard';
import { PaneButton } from './PaneButton';
@@ -19,7 +17,9 @@ import { PaneButton } from './PaneButton';
* 内容与脚本判据按固定间隔重读(所以没有刷新按钮)。
*
* 「还没配置好导出」不再是一张常驻卡片,而是阻断弹窗:重读每 2 秒一次,卡片会跟着重读闪,弹窗则
* 只在用户真的需要做选择时出现一次。可见文案说用户的话,不出现脚本名、注册表、zip 这些实现词。
* 只在用户真的需要做选择时出现一次。失败现场同理走阻断弹窗(`FailureDialog`):左半本来就矮,
* 卡片会被滚出视野,而失败是用户必须看见的东西。可见文案说用户的话,不出现脚本名、注册表、zip
* 这些实现词。
*/
export function ArtifactsPane({
minitool,
@@ -28,8 +28,7 @@ export function ArtifactsPane({
minitool: XhsMinitoolExportState;
projectPath: string | null;
}) {
const [downloadNotice, setDownloadNotice] = useState('');
const { form, conflict } = minitool;
const { form, conflict, failure } = minitool;
/**
* 未适配:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目下
* 「还没适配」的结论——那种情况只该显示读取中或失败现场。
@@ -47,6 +46,7 @@ export function ArtifactsPane({
*/
const showAdaptPrompt = needsAdaptation && !minitool.adaptPromptDismissed;
// 保存不打回执:用户刚在原生对话框里选完落点,磁盘上有没有那个文件是他自己看得见的事实。
const downloadIcon = (): void => {
if (!projectPath || !form) return;
void saveXhsMinitoolFileToDisk({
@@ -54,7 +54,7 @@ export function ArtifactsPane({
relativePath: form.iconPath.trim(),
fallbackFileName: 'icon.png',
title: '保存图标文件',
}).then(setDownloadNotice);
});
};
const downloadArtifact = (): void => {
@@ -64,7 +64,7 @@ export function ArtifactsPane({
relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
fallbackFileName: 'xhs-minitool.zip',
title: '保存代码包',
}).then(setDownloadNotice);
});
};
return (
@@ -84,10 +84,10 @@ export function ArtifactsPane({
/>
) : !minitool.readSettled ? (
<p className="text-xs text-[#988476]">正在读取导出信息…</p>
) : // 读失败时上面不重复一句话:失败卡片拿着宿主的现场和下一步。
minitool.failure ? null : (
) : // 读失败时上面不重复一句话:失败弹窗拿着宿主的现场和下一步。
failure ? null : (
<p className="text-xs text-[#988476]">
{minitool.notice || '没能读到导出信息,正在自动重试…'}
没能读到导出信息,正在自动重试…
</p>
)}
@@ -136,24 +136,6 @@ export function ArtifactsPane({
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>
<AdaptPromptDialog
@@ -161,6 +143,13 @@ export function ArtifactsPane({
onSendToAgent={minitool.adapt}
onDoItMyself={minitool.dismissAdaptPrompt}
/>
<FailureDialog
failure={failure}
onAdapt={minitool.adapt}
onRepair={minitool.repair}
onDismiss={minitool.dismissFailure}
/>
</div>
);
}
@@ -1,59 +0,0 @@
import type { XhsMinitoolCardFailure } from '../../state/xhsMinitoolFailure';
import {
classifyXhsMinitoolFailure,
describeXhsMinitoolFailure,
XhsMinitoolFailureRepairability,
} from '../../state/xhsMinitoolFailure';
import { PaneButton } from './PaneButton';
/**
* 失败卡片:一句用户看得懂的话 + 一颗按「谁有能力修」选出来的按钮。
*
* 按钮由 `classifyXhsMinitoolFailure` 决定,不在这里按变体现猜:
* - `CodeAgentRepairable`:项目还没适配就给「让陶泥儿帮我调通」,适配过但这次没跑通就给「让陶泥儿来修」;
* - `OnlyHumanRepairable`(项目权限策略拉黑了 `command.exec`、宿主没跑起来):只给「重试」——
* 要改的东西不在项目代码里,别把陶泥儿拉进来改一份没问题可改的代码。
*
* 卡片只收 [`XhsMinitoolCardFailure`]:表单校验与保存冲突有各自的 UI,到不了这里。
* 宿主给的现场也不在这里展开:那是给 agent 的原始错误(`JSON.stringify` 进指令),不是给用户看的。
*/
export function FailureCard({
failure,
onAdapt,
onRepair,
onRetry,
}: {
failure: XhsMinitoolCardFailure;
onAdapt: () => void;
onRepair: () => void;
onRetry: () => void;
}) {
const repairability = classifyXhsMinitoolFailure(failure);
return (
<section
role="alert"
aria-label="导出失败"
className="rounded-2xl border border-[#c7653d] bg-[#fff5ef] p-4"
>
<p className="text-sm text-[#3d1f10]">
{describeXhsMinitoolFailure(failure)}
</p>
<div className="mt-2">
{repairability ===
XhsMinitoolFailureRepairability.OnlyHumanRepairable ? (
<PaneButton variant="primary" onClick={onRetry}>
重试
</PaneButton>
) : failure.type === 'buildScriptMissing' ? (
<PaneButton variant="primary" onClick={onAdapt}>
让陶泥儿帮我调通
</PaneButton>
) : (
<PaneButton variant="primary" onClick={onRepair}>
让陶泥儿来修
</PaneButton>
)}
</div>
</section>
);
}
@@ -0,0 +1,74 @@
import { ThemedModal } from '../../../../../components/modal/ThemedModal';
import type { XhsMinitoolCardFailure } from '../../state/xhsMinitoolFailure';
import {
classifyXhsMinitoolFailure,
describeXhsMinitoolFailure,
XhsMinitoolFailureRepairability,
} from '../../state/xhsMinitoolFailure';
import { PaneButton } from './PaneButton';
/**
* 失败弹窗:一句用户看得懂的话 + 一颗按「谁有能力修」选出来的按钮。
*
* 每一种失败都走这里(原来是一张就地卡片):左半本来就不高,卡片会被滚出视野,而失败恰恰是
* 用户必须看见的东西——尤其是「打完了却没有产物」那一种,他正等着下载。与「还没配置好导出」
* 同一种阻断层,背景点击与 Esc 不生效;两颗按钮都会清掉失败现场,所以弹窗随之关闭。
*
* 按钮由 `classifyXhsMinitoolFailure` 决定,不在这里按变体现猜:
* - `CodeAgentRepairable`:项目还没适配就给「让陶泥儿帮我调通」,适配过但这次没跑通就给「让陶泥儿来修」;
* - `OnlyHumanRepairable`(项目权限策略拉黑了 `command.exec`、宿主没跑起来):不给陶泥儿入口——
* 要改的东西不在项目代码里,把陶泥儿拉进来只会让它去改一份没问题可改的代码。
*
* 每条失败都带「关闭」:关掉之后弹窗收起、现场不再重复弹(下一次重读会再送同一个现场回来),
* 用户想自己重来就回面板点「打包」。没有「重试」——它跟关掉再点一次「打包」是同一件事。
*
* 只收 [`XhsMinitoolCardFailure`]:表单校验与保存冲突有各自的 UI,到不了这里。宿主给的现场也不在
* 这里展开:那是给 agent 的原始错误(`JSON.stringify` 进指令),不是给用户看的。
*/
export function FailureDialog({
failure,
onAdapt,
onRepair,
onDismiss,
}: {
/** 没有失败现场时为 `null`,弹窗关着。 */
failure: XhsMinitoolCardFailure | null;
onAdapt: () => void;
onRepair: () => void;
onDismiss: () => void;
}) {
const repairability = failure ? classifyXhsMinitoolFailure(failure) : null;
const agentAction =
!failure ||
repairability === XhsMinitoolFailureRepairability.OnlyHumanRepairable
? null
: failure.type === 'buildScriptMissing'
? '让陶泥儿帮我调通'
: '让陶泥儿来修';
return (
<ThemedModal
open={failure !== null}
ariaLabel="导出失败"
onClose={onDismiss}
closeOnBackdrop={false}
closeOnEscape={false}
panelClassName="w-full max-w-md rounded-2xl border border-[#e2cbb8] p-4"
>
<p className="text-sm leading-relaxed text-[#3d1f10]">
{failure ? describeXhsMinitoolFailure(failure) : null}
</p>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<PaneButton onClick={onDismiss}>关闭</PaneButton>
{agentAction === null ? null : (
<PaneButton
variant="primary"
onClick={agentAction === '让陶泥儿帮我调通' ? onAdapt : onRepair}
>
{agentAction}
</PaneButton>
)}
</div>
</ThemedModal>
);
}
@@ -4,7 +4,7 @@
*
* 三个切面各钉一块:
* - 交给 code agent 的指令正文(纯字符串组装,含三条硬契约与要拷的示例脚本);
* - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步);
* - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败弹窗的下一步);
* - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。
*/
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
@@ -462,7 +462,7 @@ describe('面板左半', () => {
).toBeNull();
});
it('读落定之后失败就不再假装还在读:上面交给失败卡片,不重复一句话', () => {
it('读落定之后失败就不再假装还在读:交给失败弹窗,不重复一句话', () => {
renderPane(
fakeMinitool({
form: null,
@@ -472,7 +472,8 @@ describe('面板左半', () => {
);
expect(screen.queryByText('正在读取导出信息…')).toBeNull();
expect(screen.getByRole('alert')).toBeTruthy();
// 失败一律走阻断弹窗:左半矮,就地卡片会被滚出视野。
expect(screen.getByRole('dialog', { name: '导出失败' })).toBeTruthy();
});
it('认不出形状的拒绝给一句实话,不装死在「正在读取」上', () => {
@@ -505,7 +506,7 @@ describe('面板左半', () => {
});
});
it('失败卡片按变体给出「让陶泥儿调通」或「让陶泥儿来修」', () => {
it('失败弹窗按变体给出「让陶泥儿调通」或「让陶泥儿来修」', () => {
// 还没配置好导出:入队的必须是首次适配指令,修复指令会引用一份还不存在的适配说明。
const neverAdapted = fakeMinitool({
hasScript: false,
@@ -517,7 +518,7 @@ describe('面板左半', () => {
});
renderPane(neverAdapted);
// 卡片说用户看得懂的话,不把脚本名与宿主找过哪些目录抖出来。
// 弹窗说用户看得懂的话,不把脚本名与宿主找过哪些目录抖出来。
screen.getByText(
'这个项目还没有配置好导出。先让陶泥儿帮你调通一次,之后就能直接打包了。',
);
@@ -526,6 +527,7 @@ describe('面板左半', () => {
fireEvent.click(screen.getByRole('button', { name: '让陶泥儿帮我调通' }));
expect(neverAdapted.adapt).toHaveBeenCalledTimes(1);
expect(neverAdapted.repair).not.toHaveBeenCalled();
expect(neverAdapted.dismissFailure).not.toHaveBeenCalled();
// 配置过但这次没跑通:入队修复指令,带上这次的现场。
cleanup();
@@ -544,9 +546,9 @@ describe('面板左半', () => {
expect(broken.adapt).not.toHaveBeenCalled();
});
it('只能人来改的失败,卡片不给任何「让陶泥儿」入口', () => {
it('只能人来改的失败,弹窗不给任何「让陶泥儿」入口,只给关闭', () => {
// command.exec 被项目权限策略拉黑,改策略的是用户,agent 受同一条策略约束;
// 宿主没跑起来更是只能重试。`saveConflict` 到不了这里——它有冲突面板。
// 宿主没跑起来也一样。`saveConflict` 到不了这里——它有冲突面板。
const humanOnlyFailures: XhsMinitoolCardFailure[] = [
{ type: 'commandDenied', commandId: 'command.exec' },
{ type: 'exportUnavailable', cause: '项目根不可用:找不到目录' },
@@ -560,13 +562,49 @@ describe('面板左半', () => {
expect(
screen.queryByRole('button', { name: '让陶泥儿帮我调通' }),
).toBeNull();
// 用户能做的只有重试;现场留着,等用户去界面上改。
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(minitool.runExport).toHaveBeenCalledTimes(1);
// 没有「重试」这颗按钮:关掉弹窗回面板自己点「打包」是同一件事。
expect(screen.queryByRole('button', { name: '重试' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(minitool.dismissFailure).toHaveBeenCalledTimes(1);
expect(minitool.runExport).not.toHaveBeenCalled();
expect(minitool.repair).not.toHaveBeenCalled();
expect(minitool.adapt).not.toHaveBeenCalled();
}
});
it('关掉失败弹窗后,下一次重读把同一个现场送回来也不再弹', async () => {
// 内容读不回来时每 2 秒重读一次,同一个现场会一直被送回来:关一次就该一直安静,
// 否则弹窗会自己跳出来跟用户抢界面。
const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
throw { type: 'registryMalformed', cause: 'bad json' };
});
installTauriRuntime({ core: { invoke } });
const { result } = renderHook(() =>
useXhsMinitoolExport({
enabled: true,
projectPath: PROJECT_PATH,
submitTurn: vi.fn(async () => true),
}),
);
await waitFor(() =>
expect(result.current.failure).toEqual({
type: 'registryMalformed',
cause: 'bad json',
}),
);
await act(async () => {
result.current.dismissFailure();
});
expect(result.current.failure).toBeNull();
// 「打包」会现算一次要不要构建,这条路上又读了一次内容——同一个现场不许再弹。
await act(async () => {
await result.current.packageAll();
});
expect(result.current.failure).toBeNull();
});
});
describe('命令失败各有一条表', () => {
@@ -779,34 +817,6 @@ describe('状态 hook', () => {
expect(result.current.artifactReady).toBe(false);
});
it('输出被宿主截断时,说明那句话由前端拼', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export') {
return { form: FORM, hasScript: true };
}
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
return { outputTail: 'zip 已生成', omittedCharacters: 4096 };
});
installTauriRuntime({ core: { invoke } });
const { result } = renderHook(() =>
useXhsMinitoolExport({
enabled: true,
projectPath: PROJECT_PATH,
submitTurn: vi.fn(async () => true),
}),
);
await waitFor(() => expect(result.current.form).toEqual(FORM));
await act(async () => {
await result.current.runExport();
});
// 宿主只回事实(原文 + 省略量),提示里那句「已省略」是这里拼的。
expect(result.current.notice).toBe(
'代码包已重新生成,点「下载」保存到本地。\n(内容较长,前面省略了 4096 个字符)\nzip 已生成',
);
});
it('打包先把没保存的输入落盘,成功后再跑构建', async () => {
const calls: string[] = [];
const invoke = vi.fn(async (command: string) => {
@@ -885,9 +895,7 @@ describe('状态 hook', () => {
expect(calls).toContain('read_xhs_minitool_export');
expect(calls).not.toContain('run_xhs_minitool_export_build');
// 还没有脚本就把「让陶泥儿帮你调通」重新递出来,而不是报个错把人晾在原地。
// 这一路只重新武装弹窗,状态行不再复述一遍。
expect(result.current.adaptPromptDismissed).toBe(false);
expect(result.current.notice).toBe('');
expect(result.current.running).toBe(false);
});
@@ -981,7 +989,6 @@ describe('状态 hook', () => {
await waitFor(() => expect(result.current.form).toEqual(FORM));
await expect(result.current.runExport()).rejects.toBe(transport);
expect(result.current.notice).toBe('');
expect(result.current.failure).toBeNull();
// 同一个故障只抛一次:轮询每 2 秒重读一次,不能让错误池的 count 变成轮询次数。
await expect(result.current.runExport()).resolves.toBeUndefined();
@@ -1230,7 +1237,6 @@ function fakeMinitool(
fieldError: null,
conflict: null,
failure: null,
notice: '',
loading: false,
readSettled: true,
running: false,
@@ -1244,6 +1250,7 @@ function fakeMinitool(
repair: vi.fn(),
fillForm: vi.fn(async () => undefined),
dismissAdaptPrompt: vi.fn(),
dismissFailure: vi.fn(),
...overrides,
};
}