163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import { api } from "@/lib/api/client";
|
||
import {
|
||
currentOverride,
|
||
withExcluded,
|
||
withPinToggled,
|
||
withRecentN,
|
||
withRestored,
|
||
type InjectionEntityRef,
|
||
type InjectionOverrideRequest,
|
||
type InjectionResponse,
|
||
} from "./injection";
|
||
|
||
export interface UseInjection {
|
||
data: InjectionResponse | null;
|
||
loading: boolean;
|
||
saving: boolean;
|
||
// 首次加载失败(满屏错误框)与每次保存失败(悬浮提示)独立追踪,互不覆盖。
|
||
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>;
|
||
setRecentN: (n: number) => Promise<void>;
|
||
}
|
||
|
||
const INJECTION_PATH =
|
||
"/projects/{project_id}/chapters/{chapter_no}/injection" as const;
|
||
|
||
// 本章注入透明(B0 可控版):读确定性 SelectionTrace + 作者覆盖;
|
||
// pin/排除/recent_n 经 PUT 覆盖 → 服务端回放新的确定结果(看到的=写章用的)。
|
||
// 写失败给可读文案、不改本地状态(天然回滚),不抛。
|
||
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 [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);
|
||
setLoadError(null);
|
||
setSaveError(null);
|
||
|
||
void (async () => {
|
||
try {
|
||
const { data: body, error: err } = await api.GET(INJECTION_PATH, {
|
||
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||
});
|
||
if (cancelled) return;
|
||
if (err || !body) {
|
||
setLoadError("注入信息暂不可用");
|
||
setData(null);
|
||
} else {
|
||
setData(body);
|
||
}
|
||
} catch {
|
||
// 网络层失败(后端不可达/CORS):openapi-fetch 直接抛而非返回 error 信封。
|
||
if (cancelled) return;
|
||
setLoadError("注入信息暂不可用");
|
||
setData(null);
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, chapterNo, reloadNonce]);
|
||
|
||
const save = useCallback(
|
||
async (override: InjectionOverrideRequest) => {
|
||
// 已有保存在途 → 早返回(避免并发 PUT 互相覆盖、回放结果乱序)。
|
||
if (savingRef.current) return;
|
||
lastOverrideRef.current = override;
|
||
savingRef.current = true;
|
||
setSaving(true);
|
||
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) {
|
||
// 保存失败:保留已加载的 data,仅设保存态错误(天然回滚 + 可重试)。
|
||
setSaveError("保存注入设置失败,请重试");
|
||
} else {
|
||
setData(body); // 以服务端确定结果为准(不变量 #6)。
|
||
}
|
||
} catch {
|
||
// 网络层失败:本地状态不动(天然回滚),给可读文案、不抛。
|
||
setSaveError("保存注入设置失败,请重试");
|
||
} finally {
|
||
savingRef.current = false;
|
||
setSaving(false);
|
||
}
|
||
},
|
||
[projectId, chapterNo],
|
||
);
|
||
|
||
const mutate = useCallback(
|
||
(fn: (o: InjectionOverrideRequest) => InjectionOverrideRequest) =>
|
||
async (): Promise<void> => {
|
||
if (!data) return;
|
||
await save(fn(currentOverride(data)));
|
||
},
|
||
[data, save],
|
||
);
|
||
|
||
const togglePin = useCallback(
|
||
(ref: InjectionEntityRef) => mutate((o) => withPinToggled(o, ref))(),
|
||
[mutate],
|
||
);
|
||
const exclude = useCallback(
|
||
(ref: InjectionEntityRef) => mutate((o) => withExcluded(o, ref))(),
|
||
[mutate],
|
||
);
|
||
const restore = useCallback(
|
||
(ref: InjectionEntityRef) => mutate((o) => withRestored(o, ref))(),
|
||
[mutate],
|
||
);
|
||
const setRecentN = useCallback(
|
||
(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,
|
||
loadError,
|
||
saveError,
|
||
clearSaveError,
|
||
reload,
|
||
retrySave,
|
||
togglePin,
|
||
exclude,
|
||
restore,
|
||
setRecentN,
|
||
};
|
||
}
|