diff --git a/apps/web/components/workbench/ChapterAssistant.tsx b/apps/web/components/workbench/ChapterAssistant.tsx index 1935ad6..8d7ab12 100644 --- a/apps/web/components/workbench/ChapterAssistant.tsx +++ b/apps/web/components/workbench/ChapterAssistant.tsx @@ -1,7 +1,10 @@ "use client"; import Link from "next/link"; +import { RotateCcw, X } from "lucide-react"; +import { StatusNote } from "@/components/ui/StatusNote"; +import { buttonClass } from "@/lib/ui/variants"; import { isPinned, kindLabel, @@ -61,30 +64,69 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps {injection.loading ? (

加载注入信息…

- ) : injection.error ? ( -

- {injection.error} -

- ) : selected.length === 0 ? ( -

- 本章暂无注入实体(先在大纲点名设定/角色,或从下方排除项恢复)。 -

+ ) : !data ? ( + // 首次加载失败:data 仍为空 → 满屏错误框 + 重试。 + +

请检查后端连接后重试。

+ +
) : ( - + <> + {injection.saveError ? ( + // 保存失败:悬浮提示,不替换列表;可关闭 + 重试上一次覆盖。 + +
+ + +
+
+ ) : null} + + {selected.length === 0 ? ( +

+ 本章暂无注入实体(先在大纲点名设定/角色,或从下方排除项恢复)。 +

+ ) : ( + + )} + )} {excluded.length > 0 ? ( @@ -115,7 +157,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps

去审稿 → diff --git a/apps/web/lib/workbench/useInjection.test.ts b/apps/web/lib/workbench/useInjection.test.ts index fdca77a..579b045 100644 --- a/apps/web/lib/workbench/useInjection.test.ts +++ b/apps/web/lib/workbench/useInjection.test.ts @@ -43,7 +43,8 @@ describe("useInjection", () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.data).toEqual(body); - expect(result.current.error).toBeNull(); + expect(result.current.loadError).toBeNull(); + expect(result.current.saveError).toBeNull(); }); it("挂载后端返回 error:给可读文案、data 置空", async () => { @@ -52,7 +53,7 @@ describe("useInjection", () => { const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.error).toBe("注入信息暂不可用"); + expect(result.current.loadError).toBe("注入信息暂不可用"); expect(result.current.data).toBeNull(); }); @@ -62,7 +63,7 @@ describe("useInjection", () => { const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.error).toBe("注入信息暂不可用"); + expect(result.current.loadError).toBe("注入信息暂不可用"); expect(result.current.data).toBeNull(); }); @@ -81,7 +82,7 @@ describe("useInjection", () => { expect(put).toHaveBeenCalledTimes(1); expect(result.current.data).toEqual(updated); expect(result.current.saving).toBe(false); - expect(result.current.error).toBeNull(); + expect(result.current.saveError).toBeNull(); }); it("exclude / restore / setRecentN 各自经 PUT 提交覆盖", async () => { @@ -121,7 +122,7 @@ describe("useInjection", () => { await result.current.togglePin(CHAR); }); - expect(result.current.error).toBe("保存注入设置失败,请重试"); + expect(result.current.saveError).toBe("保存注入设置失败,请重试"); expect(result.current.data).toEqual(loaded); }); @@ -137,10 +138,60 @@ describe("useInjection", () => { await result.current.togglePin(CHAR); }); - expect(result.current.error).toBe("保存注入设置失败,请重试"); + expect(result.current.saveError).toBe("保存注入设置失败,请重试"); expect(result.current.data).toEqual(loaded); }); + it("保存失败不清空 data,且 loadError 与 saveError 互不影响", async () => { + const loaded = makeResponse({ recent_n: 4 }); + get.mockResolvedValue({ data: loaded, error: null }); + put.mockResolvedValue({ data: null, error: { detail: "422" } }); + + const { result } = renderHook(() => useInjection("p1", 3)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // 加载成功 → loadError 为空。 + expect(result.current.loadError).toBeNull(); + + await act(async () => { + await result.current.togglePin(CHAR); + }); + + // 保存失败只设 saveError;data 与 loadError 不受影响。 + expect(result.current.saveError).toBe("保存注入设置失败,请重试"); + expect(result.current.loadError).toBeNull(); + expect(result.current.data).toEqual(loaded); + + // clearSaveError 关闭悬浮提示,data 仍在。 + act(() => result.current.clearSaveError()); + expect(result.current.saveError).toBeNull(); + expect(result.current.data).toEqual(loaded); + }); + + it("retrySave 重放上一次失败的覆盖,成功后回放 data、清空 saveError", async () => { + const loaded = makeResponse({ recent_n: 4 }); + get.mockResolvedValue({ data: loaded, error: null }); + put.mockResolvedValueOnce({ data: null, error: { detail: "422" } }); + const updated = makeResponse({ recent_n: 4, pinned: [{ ...CHAR }] }); + put.mockResolvedValueOnce({ data: updated, error: null }); + + const { result } = renderHook(() => useInjection("p1", 3)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.togglePin(CHAR); + }); + expect(result.current.saveError).toBe("保存注入设置失败,请重试"); + + await act(async () => { + await result.current.retrySave(); + }); + + expect(put).toHaveBeenCalledTimes(2); + expect(result.current.saveError).toBeNull(); + expect(result.current.data).toEqual(updated); + }); + it("data 未就绪时 mutate 早返回、不发 PUT", async () => { get.mockResolvedValue({ data: null, error: { detail: "fail" } }); diff --git a/apps/web/lib/workbench/useInjection.ts b/apps/web/lib/workbench/useInjection.ts index c8bfeee..79a8134 100644 --- a/apps/web/lib/workbench/useInjection.ts +++ b/apps/web/lib/workbench/useInjection.ts @@ -18,7 +18,12 @@ export interface UseInjection { data: InjectionResponse | null; loading: boolean; saving: boolean; - error: string | null; + // 首次加载失败(满屏错误框)与每次保存失败(悬浮提示)独立追踪,互不覆盖。 + loadError: string | null; + saveError: string | null; + clearSaveError: () => void; + reload: () => void; + retrySave: () => Promise; togglePin: (ref: InjectionEntityRef) => Promise; exclude: (ref: InjectionEntityRef) => Promise; restore: (ref: InjectionEntityRef) => Promise; @@ -35,14 +40,20 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + // 重载计数:loadError「重试」自增 → 触发 effect 重新拉取(不需要把 loader 提到 effect 外)。 + const [reloadNonce, setReloadNonce] = useState(0); // 并发锁:保存在途时 ref 同步置位,拦下连点/快速切换造成的乱序覆盖(state 异步不可靠)。 const savingRef = useRef(false); + // 记下最近一次提交的覆盖,供 saveError「重试」重放(不重复构造 override)。 + const lastOverrideRef = useRef(null); useEffect(() => { let cancelled = false; setLoading(true); - setError(null); + setLoadError(null); + setSaveError(null); void (async () => { try { @@ -51,7 +62,7 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection }); if (cancelled) return; if (err || !body) { - setError("注入信息暂不可用"); + setLoadError("注入信息暂不可用"); setData(null); } else { setData(body); @@ -59,7 +70,7 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection } catch { // 网络层失败(后端不可达/CORS):openapi-fetch 直接抛而非返回 error 信封。 if (cancelled) return; - setError("注入信息暂不可用"); + setLoadError("注入信息暂不可用"); setData(null); } finally { if (!cancelled) setLoading(false); @@ -69,28 +80,30 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection return () => { cancelled = true; }; - }, [projectId, chapterNo]); + }, [projectId, chapterNo, reloadNonce]); const save = useCallback( async (override: InjectionOverrideRequest) => { // 已有保存在途 → 早返回(避免并发 PUT 互相覆盖、回放结果乱序)。 if (savingRef.current) return; + lastOverrideRef.current = override; savingRef.current = true; setSaving(true); - setError(null); + setSaveError(null); try { const { data: body, error: err } = await api.PUT(INJECTION_PATH, { params: { path: { project_id: projectId, chapter_no: chapterNo } }, body: override, }); if (err || !body) { - setError("保存注入设置失败,请重试"); + // 保存失败:保留已加载的 data,仅设保存态错误(天然回滚 + 可重试)。 + setSaveError("保存注入设置失败,请重试"); } else { setData(body); // 以服务端确定结果为准(不变量 #6)。 } } catch { // 网络层失败:本地状态不动(天然回滚),给可读文案、不抛。 - setError("保存注入设置失败,请重试"); + setSaveError("保存注入设置失败,请重试"); } finally { savingRef.current = false; setSaving(false); @@ -124,6 +137,26 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection (n: number) => mutate((o) => withRecentN(o, n))(), [mutate], ); + const clearSaveError = useCallback(() => setSaveError(null), []); + const reload = useCallback(() => setReloadNonce((n) => n + 1), []); + const retrySave = useCallback(async () => { + const override = lastOverrideRef.current; + if (!override) return; + await save(override); + }, [save]); - return { data, loading, saving, error, togglePin, exclude, restore, setRecentN }; + return { + data, + loading, + saving, + loadError, + saveError, + clearSaveError, + reload, + retrySave, + togglePin, + exclude, + restore, + setRecentN, + }; }