导出面板失败通道按 ADR 分流:真故障原样抛出,复制失败不再静默

- 新增 state/xhsMinitoolOutputTail.ts:宿主只回尾部原文与省略量,「已省略前 N 个字符」这句话在前端拼,失败卡片与成功提示共用
- xhsMinitoolFailure.ts 接住新变体 commandDenied(策略拒绝是预期拒绝,句子在前端拼),commandFailed 的现场改走尾部说明
- useXhsMinitoolExport 的三处 catch:认不出形状的拒绝与 exportUnavailable 先给现场再原样抛出进错误池,按指纹去重(面板 2 秒一轮重读,不会把报告池 count 刷成轮询次数),不再把裸 message 当业务提示
- CopyButton 改用 Tauri 剪贴板插件并显示失败态:原来的 navigator.clipboard 在 WebView 里可能根本没有、失败还被吞掉
- 用例 18 → 19 例:省略说明由前端拼、未分类拒绝原样抛出、宿主故障先给卡片再抛一次、复制失败可见
This commit is contained in:
2026-10-05 19:25:22 +08:00
parent dffe4de555
commit df3a6826b1
5 changed files with 257 additions and 34 deletions
@@ -10,6 +10,11 @@
* - 注册表指纹是写回时的同步细节,不进对外状态:它只在 `baselineRef` 里,且只有进编辑会话或
* 内容真的被采纳时才单独取一次。自动刷新([CONTENT_REFRESH_INTERVAL_MS])读的是不含指纹的
* 内容命令,所以「刷新」在类型上就动不了写回基线。
*
* 失败按 ADR 的两道口子分流(`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`、
* `【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`):typed 变体里除了宿主侧事实故障
* (`exportUnavailable`)都留在面板里就地消化;**宿主故障与认不出形状的拒绝原样抛出**,由全局
* `unhandledrejection` 送进错误池,面板不把原始 message 当成业务提示糊给用户。
*/
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -36,6 +41,7 @@ import {
buildXhsMinitoolRepairInstruction,
XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
} from './xhsMinitoolInstruction';
import { describeOutputTail } from './xhsMinitoolOutputTail';
export type XhsMinitoolExportState = ReturnType<typeof useXhsMinitoolExport>;
@@ -104,6 +110,8 @@ export function useXhsMinitoolExport({
const baselineRef = useRef('');
/** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */
const adoptableRef = useRef(false);
/** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */
const reportedFailureRef = useRef('');
const formRef = useRef<XHSMiniToolExportForm | null>(null);
useEffect(() => {
@@ -114,6 +122,22 @@ export function useXhsMinitoolExport({
adoptableRef.current = !dirty && !conflict && !fieldError;
}, [dirty, conflict, fieldError]);
/**
* 真故障(认不出形状的拒绝、宿主侧事实故障)原样抛出,交给全局 `unhandledrejection` 结算。
*
* 面板每 2 秒重读一次内容,同一个故障会反复落到这里:按指纹去重,只把第一次抛出去,故障变了再抛。
* 否则报告池里的 `count` 统计的是「面板开了多久」,不是故障发生了几次。去重不改变「有人接手」的
* 判定——第一次已经报过,后续是同一个故障的重复现场。
*/
const throwToErrorPool = useCallback(
(fingerprint: string, error: unknown): void => {
if (reportedFailureRef.current === fingerprint) return;
reportedFailureRef.current = fingerprint;
throw error;
},
[],
);
/**
* 重读内容:注册表(宿主缺则补空表单)+ 脚本是否存在。首次进入与自动刷新走同一条。
*
@@ -151,12 +175,19 @@ export function useXhsMinitoolExport({
);
} catch (error) {
const structured = asXhsMinitoolFailure(error);
if (structured) setFailure(failureState(structured));
else setNotice(error instanceof Error ? error.message : String(error));
if (!structured) {
throwToErrorPool(String(error), error);
return;
}
setFailure(failureState(structured));
// 宿主侧事实故障:先让面板显示现场,再抛进错误池(策略拒绝等预期拒绝不走这一步)。
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
} finally {
setLoading(false);
}
}, [projectPath]);
}, [projectPath, throwToErrorPool]);
useEffect(() => {
if (!enabled) return;
@@ -193,15 +224,20 @@ export function useXhsMinitoolExport({
setDirty(false);
const structured = asXhsMinitoolFailure(error);
if (!structured) {
setNotice(error instanceof Error ? error.message : String(error));
throwToErrorPool(String(error), error);
return;
}
if (structured.type === 'formInvalid') setFieldError(structured);
else if (structured.type === 'saveConflict') setConflict(structured);
else setFailure(failureState(structured));
else {
setFailure(failureState(structured));
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
}
}
},
[projectPath],
[projectPath, throwToErrorPool],
);
useEffect(() => {
@@ -251,18 +287,27 @@ export function useXhsMinitoolExport({
const result = await runXhsMinitoolExportBuild(invoke, projectPath);
// 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。
setNotice(
[`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`, result.outputTail]
[
`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`,
describeOutputTail(result),
]
.filter((line) => line.trim() !== '')
.join('\n'),
);
} catch (error) {
const structured = asXhsMinitoolFailure(error);
if (structured) setFailure(failureState(structured));
else setNotice(error instanceof Error ? error.message : String(error));
if (!structured) {
throwToErrorPool(String(error), error);
return;
}
setFailure(failureState(structured));
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
} finally {
setRunning(false);
}
}, [projectPath]);
}, [projectPath, throwToErrorPool]);
const sendToAgent = useCallback(
async (instruction: string): Promise<void> => {
@@ -2,11 +2,18 @@
* 结构化失败 → 用户看得懂的一句话 + 下一步动作。
*
* 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷
* 字段只是拼句子的原料(路径、退出码、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。
* 字段只是拼句子的原料(路径、退出码、输出尾部、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。
*
* 这里是**纯映射**,不判「要不要上报」:上报判据写在 `useXhsMinitoolExport` 的 catch 里
* (策略拒绝留在面板,宿主侧事实故障给完现场后原样抛出进错误池)。它与
* [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`]「不抽 presenter」的差异是刻意的:
* 那条针对只有一个消费方的认证 catch,而这份映射有两个消费方——面板卡片与交给 agent 的修复指令
* (两者必须说同一句话,否则用户在面板里看到的和 agent 拿到的会分叉)。
*/
import { expectNever } from '../../../../app/expectNever';
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields';
import { describeOutputTail } from './xhsMinitoolOutputTail';
/**
* 失败之后最合适的下一步,也就是那颗按钮干什么:
@@ -52,13 +59,20 @@ export function describeXhsMinitoolFailure(
detail: '',
action: 'retry',
};
case 'commandDenied':
return {
// 句子在这里拼:宿主只给被拒的命令点位,不拼人话。
message: `项目权限策略拒绝执行:${error.commandId}`,
detail: '',
action: 'retry',
};
case 'commandFailed':
return {
message:
error.exitCode === null
? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。'
: `构建脚本没有正常退出(exit code ${error.exitCode})。`,
detail: error.outputTail,
detail: describeOutputTail(error),
action: 'repair',
};
case 'artifactMissing':
@@ -0,0 +1,16 @@
/**
* 构建输出尾部的显示文本:宿主只给两个事实(原文 + 省略了多少字符),句子在这里拼。
*
* 宿主不预拼用户可见文案([`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`]):省略量是事实,
* 「省略了多少」怎么说、放在前还是后、要不要提示「尾巴不完整」,都由拿得到现场的这一层决定。
* 失败卡片与导出成功提示共用这一份,避免两处各写一句。
*/
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,43 +1,63 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Check, Copy } from 'lucide-react';
import { useEffect, useState } from 'react';
/** 复制按钮:成功后显示一小会儿「已复制」,不弹提示、不动别处状态。 */
type CopyState = 'idle' | 'copied' | 'failed';
/**
* 复制按钮:成功后显示一小会儿「已复制」,失败也**必须**说一声。
*
* 走 Tauri 的 clipboard 插件而不是 `navigator.clipboard`:后者在 WebView 里是安全上下文相关的能力,
* 拿不到时只会静默失败,用户点了没反应;插件是仓库既有口径(`UiEditorCopyPathButton`、
* `useAccountReferral`、`RichInputArea` 三处),权限位为 `clipboard-manager:allow-write-text`。
* 失败不再吞掉:写剪贴板是宿主/环境事实,调用方给一行可见反馈就是这一层该做的事。
*/
export function CopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const [state, setState] = useState<CopyState>('idle');
useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(false), 1500);
if (state === 'idle') return;
const timer = setTimeout(() => setState('idle'), 1500);
return () => clearTimeout(timer);
}, [copied]);
}, [state]);
const text =
state === 'copied' ? '已复制' : state === 'failed' ? '复制失败' : '复制';
return (
<button
type="button"
aria-label={`复制${label}`}
title={`复制${label}`}
aria-label={`${text}${label}`}
title={
state === 'failed' ? `复制${label}失败,请手动复制` : `复制${label}`
}
disabled={!value}
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-[#e2cbb8] bg-[#fffaf5] px-2 py-1 text-xs text-[#6f5848] transition hover:border-[#c7653d] hover:text-[#c7653d] disabled:cursor-not-allowed disabled:opacity-45"
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 ${
state === 'failed'
? 'border-red-300 bg-red-50 text-red-600'
: 'border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]'
}`}
onClick={() => {
void copyText(value).then(setCopied);
setState('idle');
void copyText(value).then((copied) =>
setState(copied ? 'copied' : 'failed'),
);
}}
>
{copied ? (
{state === 'copied' ? (
<Check size={13} aria-hidden="true" />
) : (
<Copy size={13} aria-hidden="true" />
)}
<span>{copied ? '已复制' : '复制'}</span>
<span>{text}</span>
</button>
);
}
async function copyText(value: string): Promise<boolean> {
if (!value) return false;
const clipboard = navigator.clipboard;
if (!clipboard) return false;
try {
await clipboard.writeText(value);
await writeText(value);
return true;
} catch {
return false;
@@ -7,6 +7,7 @@
* - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步);
* - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。
*/
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import {
act,
@@ -40,6 +41,8 @@ import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaoh
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() }));
// 剪贴板走 Tauri 插件(`navigator.clipboard` 在 WebView 里可能根本没有)。
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ writeText: vi.fn() }));
const PROJECT_PATH = '/tmp/export-panel-project';
@@ -56,10 +59,7 @@ const FORM = {
};
beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn(async () => undefined) },
});
vi.mocked(writeText).mockResolvedValue(undefined);
vi.mocked(saveNativeFileDialog).mockResolvedValue('/tmp/icon.png');
});
@@ -125,12 +125,33 @@ describe('交给 code agent 的指令', () => {
type: 'commandFailed',
exitCode: 1,
outputTail: 'vite build failed',
omittedCharacters: 0,
}),
).toMatchObject({ action: 'repair', detail: 'vite build failed' });
// 截断是宿主给的事实,提示那句话在这里拼:载荷原文里不含任何宿主写的说明。
expect(
describeXhsMinitoolFailure({
type: 'commandFailed',
exitCode: 1,
outputTail: '最后一行报错',
omittedCharacters: 4096,
}).detail,
).toBe('(日志过长,已省略前 4096 个字符)\n最后一行报错');
// 策略拒绝是预期拒绝:宿主只给点位,句子在前端,且不进错误池。
expect(
describeXhsMinitoolFailure({
type: 'commandDenied',
commandId: 'command.exec',
}),
).toEqual({
message: '项目权限策略拒绝执行:command.exec',
detail: '',
action: 'retry',
});
expect(
describeXhsMinitoolFailure({
type: 'exportUnavailable',
cause: '项目权限策略拒绝执行:command.exec',
cause: '项目根不可用:找不到目录',
}).action,
).toBe('retry');
expect(
@@ -149,8 +170,23 @@ describe('面板左半', () => {
fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith('小工具'));
// 成功态也写进无障碍名,用户与读屏都看得到「已复制」。
await waitFor(() =>
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('小工具'),
expect(screen.getByRole('button', { name: '已复制名称' })).not.toBeNull(),
);
});
it('复制失败不静默吞掉,按钮自己说一声', async () => {
vi.mocked(writeText).mockRejectedValue(new Error('剪贴板不可用'));
renderPane();
fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
await waitFor(() =>
expect(
screen.getByRole('button', { name: '复制失败名称' }),
).not.toBeNull(),
);
});
@@ -278,6 +314,7 @@ describe('面板左半', () => {
type: 'commandFailed',
exitCode: 1,
outputTail: 'vite build failed',
omittedCharacters: 0,
}),
});
renderPane(broken);
@@ -425,10 +462,11 @@ describe('状态 hook', () => {
it('导出把打包脚本的输出尾部一并报给用户', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export') {
return { form: FORM, hasScript: true, contentHash: CONTENT_HASH };
return { form: FORM, hasScript: true };
}
return {
outputTail: `zip 已生成:${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}(3 个条目,0.42 MiB)`,
omittedCharacters: 0,
};
});
installTauriRuntime({ core: { invoke } });
@@ -455,6 +493,97 @@ describe('状态 hook', () => {
expect(result.current.running).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(
`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\n(日志过长,已省略前 4096 个字符)\nzip 已生成`,
);
});
it('认不出形状的拒绝原样抛出进错误池,不当成业务提示糊给用户', async () => {
// Tauri 传输失败 / 命令 panic / 参数序列化失败都是真 `Error`:它不是结构化拒绝,
// 面板不许把它的 message 当成业务失败的解释,只把它交回错误池。
const transport = new Error('ipc 通道断了');
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;
throw transport;
});
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 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();
});
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;
throw { type: 'exportUnavailable', cause: '项目根不可用:找不到目录' };
});
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));
// 卡片照常给现场,同时原样抛出(真实 WebView 里由全局 handler 结算进错误池)。
await act(async () => {
await expect(result.current.runExport()).rejects.toBeInstanceOf(Error);
});
expect(result.current.failure?.notice.message).toBe(
'宿主这次没能执行导出。',
);
expect(result.current.failure?.notice.detail).toBe(
'项目根不可用:找不到目录',
);
await act(async () => {
await expect(result.current.runExport()).resolves.toBeUndefined();
});
});
it('适配把指令当成一条用户消息交给同一个会话', async () => {
const submitTurn = vi.fn(async () => true);
installTauriRuntime({
@@ -462,7 +591,6 @@ describe('状态 hook', () => {
invoke: vi.fn(async () => ({
form: FORM,
hasScript: false,
contentHash: CONTENT_HASH,
})),
},
});