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 仍为空 → 满屏错误框 + 重试。
+
+ 请检查后端连接后重试。
+
+
) : (
-
- {selected.map((entity) => (
-
- ))}
-
+ <>
+ {injection.saveError ? (
+ // 保存失败:悬浮提示,不替换列表;可关闭 + 重试上一次覆盖。
+
+
+
+
+
+
+ ) : null}
+
+ {selected.length === 0 ? (
+
+ 本章暂无注入实体(先在大纲点名设定/角色,或从下方排除项恢复)。
+
+ ) : (
+
+ {selected.map((entity) => (
+
+ ))}
+
+ )}
+ >
)}
{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,
+ };
}