fix(web): 注入面板保存失败不再清空已加载实体并支持重试

This commit is contained in:
Yaojia Wang
2026-06-29 16:52:43 +02:00
parent ab3f9e17b1
commit 4afbbe0fe7
3 changed files with 166 additions and 40 deletions

View File

@@ -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);
});
// 保存失败只设 saveErrordata 与 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" } });

View File

@@ -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<void>;
togglePin: (ref: InjectionEntityRef) => Promise<void>;
exclude: (ref: InjectionEntityRef) => Promise<void>;
restore: (ref: InjectionEntityRef) => Promise<void>;
@@ -35,14 +40,20 @@ export function useInjection(projectId: string, chapterNo: number): UseInjection
const [data, setData] = useState<InjectionResponse | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
// 重载计数loadError「重试」自增 → 触发 effect 重新拉取(不需要把 loader 提到 effect 外)。
const [reloadNonce, setReloadNonce] = useState(0);
// 并发锁:保存在途时 ref 同步置位,拦下连点/快速切换造成的乱序覆盖state 异步不可靠)。
const savingRef = useRef(false);
// 记下最近一次提交的覆盖,供 saveError「重试」重放不重复构造 override
const lastOverrideRef = useRef<InjectionOverrideRequest | null>(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 {
// 网络层失败(后端不可达/CORSopenapi-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,
};
}