Files
writer-work-flow/apps/web/lib/workbench/useContextDrawer.ts
Yaojia Wang 3dd7d2861f feat(frontend): 写作工作台补齐三项——内容感知书名 / 上下文速查抽屉 / genre采集
写作工作台重构剩余 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%。
2026-07-07 20:44:09 +02:00

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 };
}