前端按命令的错误变体分流,卡片与出口只说事实

- `xhsMinitoolApi` / `xhsMinitoolFailure` 改成读三份载体,`expectNever` 钉死穷尽性,仍不对任何文案做判断;
- 失败卡片与冲突面板按变体分派按钮,文案改成用户看得懂的说法(注册表 → 这份导出信息、文件里的 → 项目里的);
- 输出尾部与另存为的句子不再出现实现词,复制按钮的成功 / 失败仍是按钮自己说。
This commit is contained in:
2026-10-06 14:31:34 +08:00
parent a0b791083f
commit d80ce78fd8
7 changed files with 191 additions and 137 deletions
@@ -3,11 +3,16 @@
*
* 只做两件事:把命令名与参数名(camelCase)拼对,把结构化失败装成可判别的 `Error`。命令名在这
* 里唯一收口,调用方不写字符串命令名、也不自己嗅探失败形状。
*
* **一条命令一个载体**:读、保存、构建各有自己的失败类型与 `as*` 守卫,`instanceof` 就是命令
* 边界。这样调用方拿到的失败集合天然被命令限定,不需要再按命令挑变体。
*/
import type { TauriInvoke } from '../../../../app/types';
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
import type { XHSMiniToolExportBuildError } from '../generated/XHSMiniToolExportBuildError';
import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm';
import type { XHSMiniToolExportReadError } from '../generated/XHSMiniToolExportReadError';
import type { XHSMiniToolExportRunResult } from '../generated/XHSMiniToolExportRunResult';
import type { XHSMiniToolExportSaveError } from '../generated/XHSMiniToolExportSaveError';
import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState';
/**
@@ -16,21 +21,53 @@ import type { XHSMiniToolExportState } from '../generated/XHSMiniToolExportState
* 与入队失败(`EnqueueFailureWrapper`)同一套路:构造时把整份载荷 `JSON.stringify` 进
* `Error.message`,分流只看 `error.type`,**不解析任何文案**。
*/
export class XhsMinitoolFailure extends Error {
readonly error: XHSMiniToolExportError;
export class XhsMinitoolReadFailure extends Error {
readonly error: XHSMiniToolExportReadError;
constructor(error: XHSMiniToolExportError) {
constructor(error: XHSMiniToolExportReadError) {
super(JSON.stringify(error));
this.name = 'XhsMinitoolFailure';
this.name = 'XhsMinitoolReadFailure';
this.error = error;
}
}
export class XhsMinitoolSaveFailure extends Error {
readonly error: XHSMiniToolExportSaveError;
constructor(error: XHSMiniToolExportSaveError) {
super(JSON.stringify(error));
this.name = 'XhsMinitoolSaveFailure';
this.error = error;
}
}
export class XhsMinitoolBuildFailure extends Error {
readonly error: XHSMiniToolExportBuildError;
constructor(error: XHSMiniToolExportBuildError) {
super(JSON.stringify(error));
this.name = 'XhsMinitoolBuildFailure';
this.error = error;
}
}
/** 认得出是结构化失败就取出载荷,否则返回 `null`(传输失败、命令 panic、参数序列化失败)。 */
export function asXhsMinitoolFailure(
export function asXhsMinitoolReadFailure(
error: unknown,
): XHSMiniToolExportError | null {
return error instanceof XhsMinitoolFailure ? error.error : null;
): XHSMiniToolExportReadError | null {
return error instanceof XhsMinitoolReadFailure ? error.error : null;
}
export function asXhsMinitoolSaveFailure(
error: unknown,
): XHSMiniToolExportSaveError | null {
return error instanceof XhsMinitoolSaveFailure ? error.error : null;
}
export function asXhsMinitoolBuildFailure(
error: unknown,
): XHSMiniToolExportBuildError | null {
return error instanceof XhsMinitoolBuildFailure ? error.error : null;
}
/** 读内容:面板渲染与自动刷新反复走的就是这一条,它拿不到注册表指纹。 */
@@ -38,8 +75,12 @@ export function readXhsMinitoolExport(
invoke: TauriInvoke,
projectPath: string,
): Promise<XHSMiniToolExportState> {
return guardStructuredFailure(() =>
invoke<XHSMiniToolExportState>('read_xhs_minitool_export', { projectPath }),
return guardStructuredFailure(
() =>
invoke<XHSMiniToolExportState>('read_xhs_minitool_export', {
projectPath,
}),
XhsMinitoolReadFailure,
);
}
@@ -53,8 +94,9 @@ export function readXhsMinitoolExportHash(
invoke: TauriInvoke,
projectPath: string,
): Promise<string> {
return guardStructuredFailure(() =>
invoke<string>('read_xhs_minitool_export_hash', { projectPath }),
return guardStructuredFailure(
() => invoke<string>('read_xhs_minitool_export_hash', { projectPath }),
XhsMinitoolReadFailure,
);
}
@@ -67,12 +109,14 @@ export function saveXhsMinitoolExportForm(
baseHash: string;
},
): Promise<string> {
return guardStructuredFailure(() =>
invoke<string>('save_xhs_minitool_export_form', {
projectPath: input.projectPath,
form: input.form,
baseHash: input.baseHash,
}),
return guardStructuredFailure(
() =>
invoke<string>('save_xhs_minitool_export_form', {
projectPath: input.projectPath,
form: input.form,
baseHash: input.baseHash,
}),
XhsMinitoolSaveFailure,
);
}
@@ -80,20 +124,28 @@ export function runXhsMinitoolExportBuild(
invoke: TauriInvoke,
projectPath: string,
): Promise<XHSMiniToolExportRunResult> {
return guardStructuredFailure(() =>
invoke<XHSMiniToolExportRunResult>('run_xhs_minitool_export_build', {
projectPath,
}),
return guardStructuredFailure(
() =>
invoke<XHSMiniToolExportRunResult>('run_xhs_minitool_export_build', {
projectPath,
}),
XhsMinitoolBuildFailure,
);
}
async function guardStructuredFailure<T>(call: () => Promise<T>): Promise<T> {
async function guardStructuredFailure<T, Payload>(
call: () => Promise<T>,
FailureClass: new (error: Payload) => Error,
): Promise<T> {
try {
return await call();
} catch (error) {
// 真 `Error` 不是结构化拒绝:`JSON.stringify(new Error(...))` 只会得到 `{}`,
// 装进 wrapper 就把真实原因丢了。原样抛出,交给调用方的 `instanceof` 分界。
if (error instanceof Error) throw error;
throw new XhsMinitoolFailure(error as XHSMiniToolExportError);
// 非对象同理(命令没注册、参数序列化失败时 Tauri 会拒绝一个裸字符串):装进 wrapper 只会
// 得到一份没有 `type` 的假变体,调用方按变体分流时会走到不该到的分支。
if (typeof error !== 'object' || error === null) throw error;
throw new FailureClass(error as Payload);
}
}
@@ -19,7 +19,7 @@ export async function saveXhsMinitoolFileToDisk(input: {
title: string;
}): Promise<string> {
const invoke = resolveTauriInvoke();
if (!invoke) return '需要在客户端内打开导出面板才能保存文件';
if (!invoke) return '要在客户端里打开导出面板才能保存文件';
let destination: string | null = null;
try {
destination = await saveNativeFileDialog({
@@ -1,98 +1,93 @@
/**
* 结构化失败 → 用户看得懂的一句话 + 下一步动作。
* 结构化失败 → 两件互不派生的事:给用户看的一句话,和这次该由谁修。
*
* 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷
* 字段只是拼句子的原料(路径、退出码、输出尾部、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。
* 输入是**会出现在失败卡片上的失败**([`XhsMinitoolCardFailure`])——它按命令把三条失败表拼起来,
* 但不含 `formInvalid`(字段红字)与 `saveConflict`(冲突面板):那两种有专属 UI,到不了卡片,
* 也就不该在这张表里再分一次类。
*
* 这里是**纯映射**,不判「要不要上报」:上报判据写在 `useXhsMinitoolExport` 的 catch 里
* (策略拒绝留在面板,宿主侧事实故障给完现场后原样抛出进错误池)。它与
* [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`]「不抽 presenter」的差异是刻意的:
* 那条针对只有一个消费方的认证 catch,而这份映射有两个消费方——面板卡片与交给 agent 的修复指令
* (两者必须说同一句话,否则用户在面板里看到的和 agent 拿到的会分叉)。
* 拆成两个纯函数,各答一件事:
* - `describeXhsMinitoolFailure` 只产用户侧的说法(一个字符串),句子按变体写死,**不从载荷里
* "读"文案**;
* - `classifyXhsMinitoolFailure` 只回答「谁有能力修」(`XhsMinitoolFailureRepairability`),这是
* 事实判断,不是按钮长什么样。
*
* 分流只看 `error.type`,穷尽性由 `expectNever` 在编译期钉住。
*
* 要交给 agent 的现场不从这里出——原始错误本身就是最好的现场,调用方直接 `JSON.stringify(error)`
* 把它塞进指令;别再抽一份「给 agent 的版本」,两份说法迟早会分叉。
*/
import { expectNever } from '../../../../app/expectNever';
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields';
import { describeOutputTail } from './xhsMinitoolOutputTail';
import type { XHSMiniToolExportBuildError } from '../generated/XHSMiniToolExportBuildError';
import type { XHSMiniToolExportReadError } from '../generated/XHSMiniToolExportReadError';
import type { XHSMiniToolExportSaveError } from '../generated/XHSMiniToolExportSaveError';
/**
* 失败之后最合适的下一步,也就是那颗按钮干什么:
* - `adapt`:项目还没适配(没有构建脚本),该入队的是首次适配指令;
* - `repair`:适配过了但这次没跑通(脚本非零退出、产物不在、注册表被改坏),该入队的是修复指令;
* - `retry`:不用 agent 掺和,用户自己改或原样重试。
* 会出现在失败卡片上的失败:读注册表、保存表单、跑构建三条命令里,除去有专属 UI 的两种。
*
* `formInvalid` 由保存命令在写盘前挡下,走 `FormCard` 的字段红字;`saveConflict` 走冲突面板。
* 两者都不是「失败卡片」的现场,所以不在这张表里。
*/
export type XhsMinitoolFailureAction = 'adapt' | 'repair' | 'retry';
export type XhsMinitoolCardFailure =
| XHSMiniToolExportReadError
| XHSMiniToolExportBuildError
| Exclude<
XHSMiniToolExportSaveError,
{ type: 'formInvalid' } | { type: 'saveConflict' }
>;
export type XhsMinitoolFailureNotice = {
/** 一句话说清哪儿不对。 */
message: string;
/** 宿主给的现场(路径、退出码、输出尾部、解析器原文);没有就是空串。 */
detail: string;
action: XhsMinitoolFailureAction;
};
/**
* 谁有能力修这次失败。判据只有一条:**要改的东西在不在用户项目的代码里**。
* - `CodeAgentRepairable`:毛病在项目代码 / 构建脚本 / 产物 / 导出信息文件里——还没适配、脚本跑不通、
* 产物没产出、注册表被改坏。用户改不动,只有 code agent 能碰。
* - `OnlyHumanRepairable`:要改的东西不在项目代码里,叫 agent 来它也没得改:
* - `commandDenied`:用户在项目权限策略(`.agent/policy.json` 的 `denied_commands`)里明确拉黑了
* `command.exec`。这是用户自己的配置,而且 agent 受同一条策略约束——叫它来跑脚本正是策略禁止的事。
* - `exportUnavailable`:宿主没跑起来,改代码没有用,只能重试。
*/
export enum XhsMinitoolFailureRepairability {
CodeAgentRepairable,
OnlyHumanRepairable,
}
export function describeXhsMinitoolFailure(
error: XHSMiniToolExportError,
): XhsMinitoolFailureNotice {
export function classifyXhsMinitoolFailure(
error: XhsMinitoolCardFailure,
): XhsMinitoolFailureRepairability {
switch (error.type) {
case 'registryMalformed':
return {
message: '导出注册表被改成了宿主认不出的形状,宿主拒绝猜测它的内容。',
detail: error.cause,
action: 'repair',
};
case 'formInvalid':
return {
message: `「${XHS_MINITOOL_FIELD_LABELS[error.field]}」不符合平台要求:${error.reason}`,
detail: '',
action: 'retry',
};
case 'buildScriptMissing':
return {
message: `项目里还没有 ${error.script} 脚本——这就是「还没适配」的判据。`,
detail: `宿主找过:${error.searched.join('、')}`,
action: 'adapt',
};
case 'saveConflict':
return {
message: '注册表在别处被改过,而且改的字段和你的输入不一样。',
detail: '',
action: 'retry',
};
case 'commandDenied':
return {
// 句子在这里拼:宿主只给被拒的命令点位,不拼人话。
message: `项目权限策略拒绝执行:${error.commandId}`,
detail: '',
action: 'retry',
};
case 'commandFailed':
return {
message:
error.exitCode === null
? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。'
: `构建脚本没有正常退出(exit code ${error.exitCode})。`,
detail: describeOutputTail(error),
action: 'repair',
};
case 'artifactMissing':
return {
message: `构建脚本跑完了,但 ${error.path} 不在。多数是把 zip 写到了别处。`,
detail: '',
action: 'repair',
};
return XhsMinitoolFailureRepairability.CodeAgentRepairable;
case 'commandDenied':
case 'exportUnavailable':
return {
message: '宿主这次没能执行导出。',
detail: error.cause,
action: 'retry',
};
return XhsMinitoolFailureRepairability.OnlyHumanRepairable;
default:
expectNever(error);
return {
message: '导出失败,原因不在已知失败表里。',
detail: '',
action: 'retry',
};
return XhsMinitoolFailureRepairability.OnlyHumanRepairable;
}
}
export function describeXhsMinitoolFailure(
error: XhsMinitoolCardFailure,
): string {
switch (error.type) {
case 'registryMalformed':
return '导出信息的内容被改坏了,读不出来。可以让陶泥儿重新生成一份。';
case 'buildScriptMissing':
// 用户不需要知道 npm 脚本叫什么、宿主在哪些目录里找过——那是内部实现。
return '这个项目还没有配置好导出。先让陶泥儿帮你调通一次,之后就能直接打包了。';
case 'commandDenied':
return '当前项目的权限设置不允许执行这次导出。';
case 'commandFailed':
return error.exitCode === null
? '导出没能启动,可能是运行环境的问题。'
: '导出中途出错了,没能生成代码包。';
case 'artifactMissing':
return '导出跑完了,但没有生成代码包。可以让陶泥儿看看。';
case 'exportUnavailable':
return '这次没能执行导出,请稍后重试。';
default:
expectNever(error);
return '导出失败,原因不在已知失败表里。';
}
}
@@ -1,7 +1,7 @@
/**
* 构建输出尾部的显示文本:宿主只给两个事实(原文 + 省略了多少字符),句子在这里拼。
*
* 宿主不预拼用户可见文案([`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`]):省略量是事实,
* 宿主不预拼用户可见文案:省略量是事实,
* 「省略了多少」怎么说、放在前还是后、要不要提示「尾巴不完整」,都由拿得到现场的这一层决定。
* 失败卡片与导出成功提示共用这一份,避免两处各写一句。
*/
@@ -11,6 +11,6 @@ export function describeOutputTail(input: {
}): string {
const tail = input.outputTail.trim();
if (input.omittedCharacters <= 0) return tail;
const note = `(日志过长,已省略前 ${input.omittedCharacters} 个字符)`;
const note = `(内容较长,前面省略了 ${input.omittedCharacters} 个字符)`;
return tail ? `${note}\n${tail}` : note;
}
@@ -8,10 +8,10 @@ import { PaneButton } from './PaneButton';
type Side = 'mine' | 'file';
/**
* 字段级冲突面板:文件在别处被改过,且和用户手里的值不同。
* 字段级冲突面板:这份信息在别处被改过,且和用户手里的值不同。
*
* 只列**真的不一样**的字段,每行二选一,默认「我的」——任何情况下都不静默覆盖用户输入,也不
* 静默丢掉文件里的改动。宿主只是在保存时发现指纹对不上,判定与选择都在这一层。
* 静默丢掉项目里的改动。宿主只是在保存时发现对不上,判定与选择都在这一层。
*
* 调用方用 `key={conflict.contentHash}` 挂载:换一次冲突就是一次全新的选择。
*/
@@ -40,14 +40,14 @@ export function ConflictCard({
return (
<section
role="alertdialog"
aria-label="注册表冲突"
className="rounded-xl border border-[#c7653d] bg-[#fff5ef] p-3"
aria-label="导出信息冲突"
className="rounded-2xl border border-[#c7653d] bg-[#fff5ef] p-4"
>
<h3 className="text-sm font-semibold text-[#3d1f10]">
注册表在别处被改过
这份导出信息被改过
</h3>
<p className="mt-1 text-xs text-[#6f5848]">
{`下面 ${differing.length} 个字段两边不一样,逐个选一个再保存;没列出来的字段两边一致。`}
{`下面 ${differing.length} 项两边不一样,各选一个再保存;没列出来的两边一致。`}
</p>
<div className="mt-3 flex flex-col gap-3">
{differing.map((meta) => (
@@ -70,7 +70,7 @@ export function ConflictCard({
/>
<Choice
label={meta.label}
caption="文件里的"
caption="项目里的"
selected={choice[meta.field] === 'file'}
value={conflict.current[meta.field]}
onSelect={() =>
@@ -32,10 +32,10 @@ export function CopyButton({ value, label }: { value: string; label: string }) {
state === 'failed' ? `复制${label}失败,请手动复制` : `复制${label}`
}
disabled={!value}
className={`inline-flex shrink-0 items-center gap-1 rounded-lg border px-2 py-1 text-xs transition disabled:cursor-not-allowed disabled:opacity-45 ${
className={`inline-flex shrink-0 items-center gap-1 rounded-[10px] border px-2.5 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-45 ${
state === 'failed'
? 'border-red-300 bg-red-50 text-red-600'
: 'border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]'
: 'border-[#e2cbb8] bg-white/80 text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]'
}`}
onClick={() => {
setState('idle');
@@ -1,12 +1,21 @@
import type { XhsMinitoolFailureState } from '../../state/useXhsMinitoolExport';
import type { XhsMinitoolCardFailure } from '../../state/xhsMinitoolFailure';
import {
classifyXhsMinitoolFailure,
describeXhsMinitoolFailure,
XhsMinitoolFailureRepairability,
} from '../../state/xhsMinitoolFailure';
import { PaneButton } from './PaneButton';
/**
* 失败卡片:宿主给的 typed 失败原文(有一句人话 + 现场),加一颗按变体选出来的按钮。
* 失败卡片:一句用户看得懂的话 + 一颗按「谁有能力修」选出来的按钮。
*
* 三颗按钮对应三种下一步:项目还没适配就入队首次适配指令,适配过但没跑通就入队修复指令,
* 宿主自身没跑起来(`exportUnavailable`)或用户能自己改的(表单、冲突)就只给重试——别把
* agent 拉进来瞎猜。
* 按钮由 `classifyXhsMinitoolFailure` 决定,不在这里按变体现猜:
* - `CodeAgentRepairable`:项目还没适配就给「让陶泥儿帮我调通」,适配过但这次没跑通就给「让陶泥儿来修」;
* - `OnlyHumanRepairable`(项目权限策略拉黑了 `command.exec`、宿主没跑起来):只给「重试」——
* 要改的东西不在项目代码里,别把陶泥儿拉进来改一份没问题可改的代码。
*
* 卡片只收 [`XhsMinitoolCardFailure`]:表单校验与保存冲突有各自的 UI,到不了这里。
* 宿主给的现场也不在这里展开:那是给 agent 的原始错误(`JSON.stringify` 进指令),不是给用户看的。
*/
export function FailureCard({
failure,
@@ -14,37 +23,35 @@ export function FailureCard({
onRepair,
onRetry,
}: {
failure: XhsMinitoolFailureState;
failure: XhsMinitoolCardFailure;
onAdapt: () => void;
onRepair: () => void;
onRetry: () => void;
}) {
const { notice } = failure;
const repairability = classifyXhsMinitoolFailure(failure);
return (
<section
role="alert"
aria-label="导出失败"
className="rounded-xl border border-[#c7653d] bg-[#fff5ef] p-3"
className="rounded-2xl border border-[#c7653d] bg-[#fff5ef] p-4"
>
<p className="text-sm text-[#3d1f10]">{notice.message}</p>
{notice.detail ? (
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-white/70 p-2 text-xs text-[#6f5848]">
{notice.detail}
</pre>
) : null}
<p className="text-sm text-[#3d1f10]">
{describeXhsMinitoolFailure(failure)}
</p>
<div className="mt-2">
{notice.action === 'adapt' ? (
<PaneButton variant="primary" onClick={onAdapt}>
交给 code agent 适配
</PaneButton>
) : notice.action === 'repair' ? (
<PaneButton variant="primary" onClick={onRepair}>
让 code agent 修
</PaneButton>
) : (
{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>