导出面板:失败一律走阻断弹窗,去掉「已保存到 …」与整条通知行
- 失败现场从就地卡片改成阻断弹窗(`FailureDialog` 取代 `FailureCard`):左半本来就矮,卡片会被滚出视野,而失败是用户必须看见的东西; - 弹窗按「谁有能力修」给按钮:项目还没适配给「让陶泥儿帮我调通」,适配过但这次没跑通给「让陶泥儿来修」,改不动的(权限策略拉黑 `command.exec`、宿主没跑起来)不给陶泥儿入口;每条都带「关闭」,关掉的现场记进 `dismissedFailure`,同一个现场不会因为下一次重读又跳回来——没有「重试」,关掉回面板点「打包」是同一件事; - 一键适配也清掉失败现场:从「还没配置好导出」那条弹窗进来的就是同一个 `buildScriptMissing`,不清它弹窗关不上; - 删掉通知行(那段 `role="status"`)与整个 `notice` 状态:保存产物、打包结果、入队结果都不再在导出面板里回执;读不到内容仍由那段占位文案交代,真故障仍走错误池; - 保存产物不再返回「已保存到 …」:落点与结果都是用户在原生对话框里亲手给的,失败只留一条控制台痕迹; - 输出尾部的拼句模块 `xhsMinitoolOutputTail.ts` 随之删除(唯一调用方就是那条通知); - 用例跟着改:删掉截断拼句那条,失败相关改称弹窗并断言 `role="dialog"`,补一条「关掉同一个现场不再弹」。
This commit is contained in:
+37
-50
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+16
-16
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-16
@@ -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;
|
||||
}
|
||||
+18
-29
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
-59
@@ -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>
|
||||
);
|
||||
}
|
||||
+74
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user