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 05938b2bc..67f404d05 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 @@ -134,6 +134,11 @@ export function useXhsMinitoolExport({ /** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */ const reportedFailureRef = useRef(''); const formRef = useRef(null); + /** + * 当前项目路径的即时值。异步结果(读内容、取指纹、保存、构建)回来时用它判断结果还属不属于 + * 当前项目:面板根跨项目不卸载,旧请求回来时绝不能把上一个项目的表单 / 基线写进新项目。 + */ + const projectPathRef = useRef(projectPath); useEffect(() => { formRef.current = form; @@ -143,6 +148,36 @@ export function useXhsMinitoolExport({ adoptableRef.current = !dirty && !conflict && !fieldError; }, [dirty, conflict, fieldError]); + /** + * 换项目时把整套状态与本地判据清回初值。 + * + * 面板根跨项目不卸载,而这个 hook 的状态默认都属于「当前项目」:不清就会拿 A 的表单、写回基线 + * 与产物判据去操作 B。清完之后,下面依赖 `projectPath` 的轮询效果会立刻为新项目重读一次。 + * + * `initialReadSettledRef` 必须一起复位:它决定 `loading` 与首次采纳,留着 true 会让新项目的 + * 第一次读取被当成「非首次轮询」而直接早返回,旧表单就会继续留在界面上。 + */ + useEffect(() => { + projectPathRef.current = projectPath; + setForm(null); + setHasScript(false); + setFieldError(null); + setConflict(null); + setFailure(null); + setDismissedFailure(''); + setLoading(false); + setReadSettled(false); + setRunning(false); + setArtifactReady(false); + setDirty(false); + setAdaptPromptDismissed(false); + baselineRef.current = ''; + hashPendingRef.current = false; + initialReadSettledRef.current = false; + reportedFailureRef.current = ''; + formRef.current = null; + }, [projectPath]); + // 脚本出现之后就把提示重新武装:它要是又被删掉,用户该再看到一次阻断提示。 useEffect(() => { if (hasScript) setAdaptPromptDismissed(false); @@ -192,6 +227,8 @@ export function useXhsMinitoolExport({ invoke, projectPath, ); + // 读回来之前换了项目:这份内容属于上一个项目,不能写进新项目的状态。 + if (projectPathRef.current !== projectPath) return null; setHasScript(content.hasScript); // 内容读回来了,说明宿主与注册表现在读得动:读来源的失败(注册表坏了 / 宿主不可用)就地 // 清掉,别让弹窗卡在已经好了的现场上。用函数式更新拿最新 `failure`,既不把 `failure` 加进 @@ -221,19 +258,25 @@ export function useXhsMinitoolExport({ setConflict(null); setFailure(null); try { - baselineRef.current = await readXhsMinitoolExportHash( + const contentHash = await readXhsMinitoolExportHash( invoke, projectPath, ); + // 基线属于项目:换过项目就不能拿这份指纹去当新项目的写回基线。 + if (projectPathRef.current !== projectPath) return null; + baselineRef.current = contentHash; hashPendingRef.current = false; } catch (error) { // 指纹没取到:表单已经采纳、基线却还停在旧值。标成 pending,下一次轮询跳过上面的早返回 // 把指纹补上;失败本身照旧交给外层 catch 报给用户。 + if (projectPathRef.current !== projectPath) return null; hashPendingRef.current = true; throw error; } return content.hasScript; } catch (error) { + // 旧项目的失败不该记到新项目头上(也不该为新项目弹卡片)。 + if (projectPathRef.current !== projectPath) return null; const structured = asXhsMinitoolReadFailure(error); if (!structured) { // 认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败):现场原样抛给错误池 @@ -249,7 +292,8 @@ export function useXhsMinitoolExport({ setFailure(structured); return null; } finally { - if (isInitialRead) { + // 旧项目的收尾不翻新项目的「首次读取」判据:新项目那次 refresh 会自己收尾。 + if (isInitialRead && projectPathRef.current === projectPath) { initialReadSettledRef.current = true; setLoading(false); setReadSettled(true); @@ -281,12 +325,15 @@ export function useXhsMinitoolExport({ const invoke = resolveTauriInvoke(); if (!invoke) return false; try { - baselineRef.current = await saveXhsMinitoolExportForm(invoke, { + const nextBaseline = await saveXhsMinitoolExportForm(invoke, { projectPath, form: next, baseHash, }); + // 保存期间换了项目:这份返回值属于上一个项目,不能拿来当新项目的写回基线。 + if (projectPathRef.current !== projectPath) return false; // 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。 + baselineRef.current = nextBaseline; hashPendingRef.current = false; setFieldError(null); setConflict(null); @@ -294,6 +341,8 @@ export function useXhsMinitoolExport({ setDirty(false); return true; } catch (error) { + // 旧项目的失败不该记到新项目头上。 + if (projectPathRef.current !== projectPath) return false; // 三种结果都要停下重试,等用户或下一次输入推着走:字段不合法等用户改、冲突等用户选、 // 其余按 typed 失败给建议。共同点是 `dirty` 归零,避免同一份输入反复打宿主。 setDirty(false); @@ -358,8 +407,12 @@ export function useXhsMinitoolExport({ setFailure(null); try { await runXhsMinitoolExportBuild(invoke, projectPath); + // 构建期间换了项目:这次产物属于上一个项目,不能让新项目出现可下载的按钮。 + if (projectPathRef.current !== projectPath) return; setArtifactReady(true); } catch (error) { + // 旧项目的构建失败不该记到新项目头上。 + if (projectPathRef.current !== projectPath) return; setArtifactReady(false); const structured = asXhsMinitoolBuildFailure(error); if (!structured) { @@ -393,6 +446,8 @@ export function useXhsMinitoolExport({ setRunning(true); try { const hasScriptNow = await refresh(); + // 读期间换了项目:这次「有没有脚本」的结论属于上一个项目。 + if (projectPathRef.current !== projectPath) return; if (hasScriptNow !== true) { // 弹窗重新武装起来就是给用户的交代,状态行不再复述一遍。 setAdaptPromptDismissed(false); @@ -407,7 +462,8 @@ export function useXhsMinitoolExport({ if (conflict || fieldError) return; await buildOnce(); } finally { - setRunning(false); + // 旧项目那一次打包的收尾不解新项目的锁。 + if (projectPathRef.current === projectPath) setRunning(false); } }, [ projectPath, diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 56c5315ee..19abffa35 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -798,6 +798,49 @@ describe('状态 hook', () => { expect(result.current.form).toEqual(FORM); }); + it('换项目时清掉上一份表单与产物判据,并读回新项目的内容', async () => { + const OTHER_PATH = '/tmp/export-panel-project-2'; + const OTHER_FORM = { + name: '另一个小工具', + introduction: '另一个简介', + iconPath: 'assets/other.png', + }; + const invoke = vi.fn( + async (command: string, args?: { projectPath?: string }) => { + if (command === 'read_xhs_minitool_export') { + return args?.projectPath === PROJECT_PATH + ? { form: FORM, hasScript: true } + : { form: OTHER_FORM, hasScript: false }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + return { outputTail: 'zip 已生成', omittedCharacters: 0 }; + }, + ); + installTauriRuntime({ core: { invoke } }); + const { result, rerender } = renderHook( + ({ projectPath }: { projectPath: string }) => + useXhsMinitoolExport({ + enabled: true, + projectPath, + submitTurn: vi.fn(async () => true), + }), + { initialProps: { projectPath: PROJECT_PATH } }, + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + await act(async () => { + await result.current.packageAll(); + }); + expect(result.current.artifactReady).toBe(true); + + rerender({ projectPath: OTHER_PATH }); + // 新项目第一次读取落定之前,界面不能还显示上一个项目的表单与可下载产物。 + expect(result.current.form).toBeNull(); + expect(result.current.artifactReady).toBe(false); + + await waitFor(() => expect(result.current.form).toEqual(OTHER_FORM)); + expect(result.current.hasScript).toBe(false); + }); + it('打包成功才有下载入口,失败会收回去', async () => { let fail = false; const invoke = vi.fn(async (command: string) => {