写作工作台重构剩余 3 项(多 agent 并行构建各自新文件,主线统一集成): - WFW-5 内容感知书名(#6):buildManuscriptExcerpt 取正文首尾摘录(硬上界控 token); GeneratorRunner 加 manuscriptText,有 brief 字段时出现「用当前正文」按钮把摘录填入 brief,让 book-title 等据正文反推。纯前端零后端;HITL——只填表单,生成仍需作者触发。 - WFW-6 上下文速查抽屉(#7):ContextDrawer 视口无关右侧 slide-over,设定库/大纲完整 只读速查 + 伏笔/规则/文风摘要跳链;底栏「速查」按钮触发。加法式、CONTEXT_DRAWER_ENABLED 一键回滚、旧侧栏/全宽页路由全保留。 - WFW-7 genre 采集 + 无大纲降级:项目无题材时首次写章前弹 GenrePicker 采集(临时并入 本次 directive 不落库);chapters 为空时编辑器显式提示「尚无大纲,将按设定自由生成」。 各项 TDD:manuscriptExcerpt/useGenreGate/contextDrawer/useContextDrawer 共 32 新单测。 门禁全绿:vitest 629 / tsc / lint / build / coverage 95.43%。
146 lines
4.7 KiB
TypeScript
146 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
import { api } from "@/lib/api/client";
|
|
import { useToast } from "@/components/Toast";
|
|
import {
|
|
toCodexGroups,
|
|
toOutlineRows,
|
|
type CodexGroup,
|
|
type ContextTabKey,
|
|
type OutlineRow,
|
|
} from "./contextDrawer";
|
|
|
|
// 速查抽屉的数据层:抽屉打开且切到对应 Tab 时【懒加载】设定库 / 大纲(只读,绝不写库)。
|
|
// 加载成功后缓存(切走再切回不重复拉);项目切换清缓存;失败可 reload 重试。
|
|
|
|
export type ResourceStatus = "idle" | "loading" | "ready" | "error";
|
|
|
|
export interface ContextResource<T> {
|
|
status: ResourceStatus;
|
|
data: T;
|
|
}
|
|
|
|
export interface UseContextDrawerData {
|
|
codex: ContextResource<CodexGroup[]>;
|
|
outline: ContextResource<OutlineRow[]>;
|
|
reloadCodex: () => void;
|
|
reloadOutline: () => void;
|
|
}
|
|
|
|
const CODEX_PATH = "/projects/{project_id}/world_entities" as const;
|
|
const OUTLINE_PATH = "/projects/{project_id}/outline" as const;
|
|
const EMPTY_CODEX: CodexGroup[] = [];
|
|
const EMPTY_OUTLINE: OutlineRow[] = [];
|
|
|
|
export function useContextDrawerData(
|
|
projectId: string,
|
|
activeTab: ContextTabKey,
|
|
open: boolean,
|
|
): UseContextDrawerData {
|
|
const [codex, setCodex] = useState<ContextResource<CodexGroup[]>>({
|
|
status: "idle",
|
|
data: EMPTY_CODEX,
|
|
});
|
|
const [outline, setOutline] = useState<ContextResource<OutlineRow[]>>({
|
|
status: "idle",
|
|
data: EMPTY_OUTLINE,
|
|
});
|
|
const toast = useToast();
|
|
// 单飞哨兵:已发起过请求就不再重复(成功缓存 / 在途去重);出错时重置以允许重试。
|
|
const codexReqRef = useRef(false);
|
|
const outlineReqRef = useRef(false);
|
|
// reload 触发器:自增即重新拉取(配合把 req 哨兵复位)。
|
|
const [codexNonce, setCodexNonce] = useState(0);
|
|
const [outlineNonce, setOutlineNonce] = useState(0);
|
|
|
|
// 项目切换 → 清缓存 + 复位哨兵,允许对新项目重新拉取。声明在拉取 effect 之前,
|
|
// 保证同一次提交里先复位、后续 effect 才据此触发。
|
|
useEffect(() => {
|
|
codexReqRef.current = false;
|
|
outlineReqRef.current = false;
|
|
setCodex({ status: "idle", data: EMPTY_CODEX });
|
|
setOutline({ status: "idle", data: EMPTY_OUTLINE });
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
if (!open || activeTab !== "codex" || codexReqRef.current) return;
|
|
codexReqRef.current = true;
|
|
let cancelled = false;
|
|
setCodex({ status: "loading", data: EMPTY_CODEX });
|
|
void (async () => {
|
|
try {
|
|
const { data, error } = await api.GET(CODEX_PATH, {
|
|
params: { path: { project_id: projectId } },
|
|
});
|
|
if (cancelled) return;
|
|
if (error || !data) {
|
|
codexReqRef.current = false;
|
|
setCodex({ status: "error", data: EMPTY_CODEX });
|
|
toast("设定库加载失败,请重试。", "error");
|
|
return;
|
|
}
|
|
setCodex({
|
|
status: "ready",
|
|
data: toCodexGroups(data.world_entities ?? []),
|
|
});
|
|
} catch {
|
|
if (cancelled) return;
|
|
codexReqRef.current = false;
|
|
setCodex({ status: "error", data: EMPTY_CODEX });
|
|
toast("设定库加载异常,请检查网络。", "error");
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, activeTab, projectId, codexNonce, toast]);
|
|
|
|
useEffect(() => {
|
|
if (!open || activeTab !== "outline" || outlineReqRef.current) return;
|
|
outlineReqRef.current = true;
|
|
let cancelled = false;
|
|
setOutline({ status: "loading", data: EMPTY_OUTLINE });
|
|
void (async () => {
|
|
try {
|
|
const { data, error } = await api.GET(OUTLINE_PATH, {
|
|
params: { path: { project_id: projectId } },
|
|
});
|
|
if (cancelled) return;
|
|
if (error || !data) {
|
|
outlineReqRef.current = false;
|
|
setOutline({ status: "error", data: EMPTY_OUTLINE });
|
|
toast("大纲加载失败,请重试。", "error");
|
|
return;
|
|
}
|
|
setOutline({
|
|
status: "ready",
|
|
data: toOutlineRows(data.chapters ?? []),
|
|
});
|
|
} catch {
|
|
if (cancelled) return;
|
|
outlineReqRef.current = false;
|
|
setOutline({ status: "error", data: EMPTY_OUTLINE });
|
|
toast("大纲加载异常,请检查网络。", "error");
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, activeTab, projectId, outlineNonce, toast]);
|
|
|
|
const reloadCodex = (): void => {
|
|
codexReqRef.current = false;
|
|
setCodex({ status: "idle", data: EMPTY_CODEX });
|
|
setCodexNonce((n) => n + 1);
|
|
};
|
|
const reloadOutline = (): void => {
|
|
outlineReqRef.current = false;
|
|
setOutline({ status: "idle", data: EMPTY_OUTLINE });
|
|
setOutlineNonce((n) => n + 1);
|
|
};
|
|
|
|
return { codex, outline, reloadCodex, reloadOutline };
|
|
}
|