From df3a6826b1a838771159da95cc60e60bac99de4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 19:25:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=80=9A=E9=81=93=E6=8C=89=20ADR=20=E5=88=86=E6=B5=81?= =?UTF-8?q?=EF=BC=9A=E7=9C=9F=E6=95=85=E9=9A=9C=E5=8E=9F=E6=A0=B7=E6=8A=9B?= =?UTF-8?q?=E5=87=BA=EF=BC=8C=E5=A4=8D=E5=88=B6=E5=A4=B1=E8=B4=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 state/xhsMinitoolOutputTail.ts:宿主只回尾部原文与省略量,「已省略前 N 个字符」这句话在前端拼,失败卡片与成功提示共用 - xhsMinitoolFailure.ts 接住新变体 commandDenied(策略拒绝是预期拒绝,句子在前端拼),commandFailed 的现场改走尾部说明 - useXhsMinitoolExport 的三处 catch:认不出形状的拒绝与 exportUnavailable 先给现场再原样抛出进错误池,按指纹去重(面板 2 秒一轮重读,不会把报告池 count 刷成轮询次数),不再把裸 message 当业务提示 - CopyButton 改用 Tauri 剪贴板插件并显示失败态:原来的 navigator.clipboard 在 WebView 里可能根本没有、失败还被吞掉 - 用例 18 → 19 例:省略说明由前端拼、未分类拒绝原样抛出、宿主故障先给卡片再抛一次、复制失败可见 --- .../export/state/useXhsMinitoolExport.ts | 65 ++++++-- .../export/state/xhsMinitoolFailure.ts | 18 ++- .../export/state/xhsMinitoolOutputTail.ts | 16 ++ .../export/tabs/xiaohongshu/CopyButton.tsx | 48 ++++-- .../tests/xhsMinitoolExport.test.tsx | 144 +++++++++++++++++- 5 files changed, 257 insertions(+), 34 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index ea1429078..7b34bc4f7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -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; @@ -104,6 +110,8 @@ export function useXhsMinitoolExport({ const baselineRef = useRef(''); /** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */ const adoptableRef = useRef(false); + /** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */ + const reportedFailureRef = useRef(''); const formRef = useRef(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 => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts index bf59f3f4f..6203ed1f8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFailure.ts @@ -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': diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts new file mode 100644 index 000000000..6acae1028 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolOutputTail.ts @@ -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; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx index 214c6fd34..8ca95feba 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/CopyButton.tsx @@ -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('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 ( ); } async function copyText(value: string): Promise { 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; diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 517abbe5e..fa976a559 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -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, })), }, });