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%。
This commit is contained in:
Yaojia Wang
2026-07-07 20:44:09 +02:00
parent 351fbc99e8
commit 3dd7d2861f
13 changed files with 1235 additions and 5 deletions

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import type { OutlineChapterView, WorldEntityCardView } from "@/lib/api/types";
import {
CONTEXT_TABS,
contextTabHref,
toCodexGroups,
toOutlineRows,
} from "./contextDrawer";
const entity = (
type: string,
name: string,
rules: string[] = [],
): WorldEntityCardView => ({ type, name, rules });
describe("contextTabHref", () => {
it("按 Tab key 拼出完整编辑页路由", () => {
// Arrange / Act / Assert
expect(contextTabHref("p1", "codex")).toBe("/projects/p1/codex");
expect(contextTabHref("p1", "foreshadow")).toBe("/projects/p1/foreshadow");
expect(contextTabHref("p1", "style")).toBe("/projects/p1/style");
});
it("五个 Tab 覆盖全部读为主入口", () => {
expect(CONTEXT_TABS.map((t) => t.key)).toEqual([
"codex",
"outline",
"foreshadow",
"rules",
"style",
]);
});
});
describe("toCodexGroups", () => {
it("空列表返回空分组", () => {
expect(toCodexGroups([])).toEqual([]);
});
it("按 type 归组并保持首次出现顺序", () => {
const entities = [
entity("势力", "雾港议会"),
entity("地理", "北境"),
entity("势力", "红手会"),
];
const groups = toCodexGroups(entities);
expect(groups.map((g) => g.type)).toEqual(["势力", "地理"]);
expect(groups[0]?.entities.map((e) => e.name)).toEqual([
"雾港议会",
"红手会",
]);
expect(groups[1]?.entities.map((e) => e.name)).toEqual(["北境"]);
});
it("空白/缺省 type 归入「未分类」", () => {
const groups = toCodexGroups([entity(" ", "无名之物")]);
expect(groups[0]?.type).toBe("未分类");
});
it("不修改入参数组", () => {
const entities = [entity("势力", "甲"), entity("势力", "乙")];
const snapshot = [...entities];
toCodexGroups(entities);
expect(entities).toEqual(snapshot);
});
});
describe("toOutlineRows", () => {
const chapter = (
no: number,
volume: number,
beats: string[],
codes: string[] = [],
): OutlineChapterView => ({
no,
volume,
beats,
foreshadow_windows: codes.map((code) => ({ code })),
});
it("空列表返回空数组", () => {
expect(toOutlineRows([])).toEqual([]);
});
it("按章号升序整形并抽出节拍与伏笔代号", () => {
const rows = toOutlineRows([
chapter(2, 1, ["决战"], ["F2"]),
chapter(1, 1, ["开场", "引入"], ["F1"]),
]);
expect(rows.map((r) => r.no)).toEqual([1, 2]);
expect(rows[0]?.beats).toEqual(["开场", "引入"]);
expect(rows[0]?.foreshadowCodes).toEqual(["F1"]);
expect(rows[1]?.foreshadowCodes).toEqual(["F2"]);
});
it("缺省 beats / foreshadow_windows 回退为空数组", () => {
const rows = toOutlineRows([{ no: 1, volume: 1 }]);
expect(rows[0]?.beats).toEqual([]);
expect(rows[0]?.foreshadowCodes).toEqual([]);
});
it("不修改入参数组", () => {
const chapters = [chapter(2, 1, []), chapter(1, 1, [])];
const snapshot = chapters.map((c) => c.no);
toOutlineRows(chapters);
expect(chapters.map((c) => c.no)).toEqual(snapshot);
});
});

View File

@@ -0,0 +1,84 @@
import type { OutlineChapterView, WorldEntityCardView } from "@/lib/api/types";
// 速查抽屉ContextDrawer的 Tab 配置与纯整形逻辑。
// 视图无关、无副作用,供组件与单测复用(组件本身不进 vitest 范围)。
export type ContextTabKey =
| "codex"
| "outline"
| "foreshadow"
| "rules"
| "style";
export interface ContextTab {
key: ContextTabKey;
label: string;
// 完整编辑页路由后缀(拼在 /projects/{id}/ 之后)。
routeSegment: string;
}
// Tab 顺序即抽屉里的呈现顺序:读为主的设定入口,设定库/大纲在前(本期做完整)。
export const CONTEXT_TABS: readonly ContextTab[] = [
{ key: "codex", label: "设定库", routeSegment: "codex" },
{ key: "outline", label: "大纲", routeSegment: "outline" },
{ key: "foreshadow", label: "伏笔", routeSegment: "foreshadow" },
{ key: "rules", label: "规则", routeSegment: "rules" },
{ key: "style", label: "文风", routeSegment: "style" },
];
// 抽屉只读速查 →「去完整页编辑」链接。未知 key 回退用 key 作后缀(防御)。
export function contextTabHref(projectId: string, key: ContextTabKey): string {
const tab = CONTEXT_TABS.find((t) => t.key === key);
const segment = tab?.routeSegment ?? key;
return `/projects/${projectId}/${segment}`;
}
export interface CodexGroup {
type: string;
entities: WorldEntityCardView[];
}
// 设定库实体按 type 归组,保持首次出现顺序(紧凑分组展示)。空 type 归「未分类」。
// 不可变:不改入参,返回全新分组数组。
export function toCodexGroups(
entities: readonly WorldEntityCardView[],
): CodexGroup[] {
const groups: CodexGroup[] = [];
const indexByType = new Map<string, number>();
for (const entity of entities) {
const type = entity.type.trim() || "未分类";
const idx = indexByType.get(type);
if (idx === undefined) {
indexByType.set(type, groups.length);
groups.push({ type, entities: [entity] });
} else {
const group = groups.at(idx);
if (group) {
groups[idx] = { type: group.type, entities: [...group.entities, entity] };
}
}
}
return groups;
}
export interface OutlineRow {
no: number;
volume: number;
beats: string[];
foreshadowCodes: string[];
}
// 大纲章节整形为紧凑速查行:按章号升序,抽出节拍与该章伏笔窗口代号。
// 不可变:先复制再排序,不改入参。
export function toOutlineRows(
chapters: readonly OutlineChapterView[],
): OutlineRow[] {
return [...chapters]
.sort((a, b) => a.no - b.no)
.map((chapter) => ({
no: chapter.no,
volume: chapter.volume,
beats: chapter.beats ?? [],
foreshadowCodes: (chapter.foreshadow_windows ?? []).map((w) => w.code),
}));
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildManuscriptExcerpt,
DEFAULT_EXCERPT_MAX_CHARS,
} from "./manuscriptExcerpt";
describe("buildManuscriptExcerpt", () => {
it("短文未超界trim 后原样返回,无省略标记", () => {
const text = " 第一章 少年初入江湖。 ";
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toBe("第一章 少年初入江湖。");
expect(excerpt).not.toContain("中略");
});
it("空串与纯空白 → 返回 \"\"", () => {
expect(buildManuscriptExcerpt("", 1800)).toBe("");
expect(buildManuscriptExcerpt(" \n\t ", 1800)).toBe("");
});
it("正好等于上界 → 原样返回,不截断", () => {
const text = "字".repeat(1800);
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toBe(text);
expect(excerpt).not.toContain("中略");
});
it("超长 → 取首尾拼接、含省略标记,且首尾片段都保留", () => {
const head = "甲".repeat(1000);
const tail = "乙".repeat(1000);
const text = head + tail; // 2000 > 1800
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toContain("中略");
// 首段来自开头、尾段来自结尾。
expect(excerpt.startsWith("甲")).toBe(true);
expect(excerpt.endsWith("乙")).toBe(true);
});
it("超长摘录长度 ≤ 上界 + 少许标记冗余(正文部分不超界)", () => {
const text = "文".repeat(5000);
const maxChars = 1800;
const excerpt = buildManuscriptExcerpt(text, maxChars);
// 省略标记之外的正文字符数不得超过上界。
const bodyLen = excerpt.replace(/……(中略)……/g, "").replace(/\s+/g, "")
.length;
expect(bodyLen).toBeLessThanOrEqual(maxChars);
// 整体长度仅比上界多出标记长度这点冗余。
expect(excerpt.length).toBeLessThanOrEqual(maxChars + 20);
});
it("默认上界常量生效(不传 maxChars", () => {
const text = "字".repeat(DEFAULT_EXCERPT_MAX_CHARS + 500);
const excerpt = buildManuscriptExcerpt(text);
expect(excerpt).toContain("中略");
const bodyLen = excerpt.replace(/……(中略)……/g, "").replace(/\s+/g, "")
.length;
expect(bodyLen).toBeLessThanOrEqual(DEFAULT_EXCERPT_MAX_CHARS);
});
});

View File

@@ -0,0 +1,31 @@
// 从作者【已写正文】抽一段代表性摘录,喂给内容感知型生成器(如书名反推)的 brief 字段。
// 纯函数、无副作用;硬上界 maxChars 控 token保证首尾都进入摘录。
/** 默认摘录上界(字符数),约束喂给 LLM 的 token 体量。 */
export const DEFAULT_EXCERPT_MAX_CHARS = 1800;
/** 首尾之间插入的省略标记,提示中间正文被截去。 */
const ELLIPSIS_MARKER = "\n\n……中略……\n\n";
/**
* 取正文的代表性摘录:
* - 空/纯空白 → 返回 ""。
* - 长度 ≤ maxChars → 原样返回trim 后)。
* - 超界 → 取「开头一段 + 结尾一段」拼接,中间插省略标记,保证首尾都在。
* 正文预算按 maxChars 均分给首尾两段;返回长度 ≤ maxChars + 标记长度。
*/
export function buildManuscriptExcerpt(
fullText: string,
maxChars: number = DEFAULT_EXCERPT_MAX_CHARS,
): string {
const trimmed = fullText.trim();
if (trimmed.length === 0) return "";
if (trimmed.length <= maxChars) return trimmed;
// 正文预算(不含标记)均分给首尾,向下取整避免超界。
const headBudget = Math.floor(maxChars / 2);
const tailBudget = maxChars - headBudget;
const head = trimmed.slice(0, headBudget).trimEnd();
const tail = trimmed.slice(trimmed.length - tailBudget).trimStart();
return `${head}${ELLIPSIS_MARKER}${tail}`;
}

View File

@@ -0,0 +1,165 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useContextDrawerData } from "./useContextDrawer";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const get = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { GET: (...a: unknown[]) => get(...a) },
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const CODEX_PATH = "/projects/{project_id}/world_entities";
const OUTLINE_PATH = "/projects/{project_id}/outline";
// 按路径分流 codex / outline 两个端点的 mock 响应。
function routeGet(
responses: {
codex?: unknown;
outline?: unknown;
codexReject?: boolean;
outlineReject?: boolean;
} = {},
): void {
get.mockImplementation((path: string) => {
if (path === CODEX_PATH) {
if (responses.codexReject) return Promise.reject(new Error("net"));
return Promise.resolve(responses.codex ?? { data: null, error: null });
}
if (path === OUTLINE_PATH) {
if (responses.outlineReject) return Promise.reject(new Error("net"));
return Promise.resolve(responses.outline ?? { data: null, error: null });
}
return Promise.resolve({ data: null, error: null });
});
}
describe("useContextDrawerData", () => {
beforeEach(() => {
get.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("抽屉关闭时不发请求,两资源保持 idle", () => {
routeGet();
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", false),
);
expect(get).not.toHaveBeenCalled();
expect(result.current.codex.status).toBe("idle");
expect(result.current.outline.status).toBe("idle");
});
it("打开且在设定库 Tab拉取并归组到 ready", async () => {
routeGet({
codex: {
data: {
world_entities: [
{ type: "势力", name: "雾港议会", rules: [] },
{ type: "地理", name: "北境", rules: [] },
],
},
error: null,
},
});
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data.map((g) => g.type)).toEqual([
"势力",
"地理",
]);
expect(get).toHaveBeenCalledTimes(1);
expect(toast).not.toHaveBeenCalled();
});
it("打开且在大纲 Tab拉取并按章号升序整形", async () => {
routeGet({
outline: {
data: {
chapters: [
{ no: 2, volume: 1, beats: ["决战"], foreshadow_windows: [] },
{ no: 1, volume: 1, beats: ["开场"], foreshadow_windows: [] },
],
},
error: null,
},
});
const { result } = renderHook(() =>
useContextDrawerData("p1", "outline", true),
);
await waitFor(() => expect(result.current.outline.status).toBe("ready"));
expect(result.current.outline.data.map((r) => r.no)).toEqual([1, 2]);
});
it("data 为空时 ready 且为空数组", async () => {
routeGet({ codex: { data: { world_entities: null }, error: null } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data).toEqual([]);
});
it("后端返回 errorerror 态 + 错误 toast", async () => {
routeGet({ codex: { data: null, error: { detail: "boom" } } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("请求抛异常error 态 + 网络异常 toast", async () => {
routeGet({ codexReject: true });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(
"设定库加载异常,请检查网络。",
"error",
);
});
it("已加载后切走再切回不重复拉取(缓存)", async () => {
routeGet({
codex: {
data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] },
error: null,
},
});
const { result, rerender } = renderHook(
({ tab }: { tab: "codex" | "outline" }) =>
useContextDrawerData("p1", tab, true),
{ initialProps: { tab: "codex" } as { tab: "codex" | "outline" } },
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
rerender({ tab: "outline" });
rerender({ tab: "codex" });
expect(get.mock.calls.filter((c) => c[0] === CODEX_PATH)).toHaveLength(1);
});
it("reloadCodex 在出错后可重试拉取", async () => {
routeGet({ codex: { data: null, error: { detail: "boom" } } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
routeGet({
codex: {
data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] },
error: null,
},
});
act(() => result.current.reloadCodex());
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data).toHaveLength(1);
});
});

View File

@@ -0,0 +1,145 @@
"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 };
}

View File

@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { toGenreDirective, useGenreGate } from "./useGenreGate";
describe("toGenreDirective", () => {
it("有题材时形如「本章题材:<genre>」", () => {
expect(toGenreDirective("玄幻")).toBe("本章题材:玄幻");
});
it("空/纯空白/null → 空串", () => {
expect(toGenreDirective(null)).toBe("");
expect(toGenreDirective("")).toBe("");
expect(toGenreDirective(" ")).toBe("");
});
});
describe("useGenreGate", () => {
it("项目已有 genreneedsGenre=falsegenreDirective 反映项目题材", () => {
const { result } = renderHook(() => useGenreGate("仙侠"));
expect(result.current.needsGenre).toBe(false);
expect(result.current.effectiveGenre).toBe("仙侠");
expect(result.current.genreDirective).toBe("本章题材:仙侠");
});
it("项目 genre 为纯空白也视为缺失", () => {
const { result } = renderHook(() => useGenreGate(" "));
expect(result.current.needsGenre).toBe(true);
expect(result.current.effectiveGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("无 genreneedsGenre=true初始无生效题材与题材指令", () => {
const { result } = renderHook(() => useGenreGate(null));
expect(result.current.needsGenre).toBe(true);
expect(result.current.chosenGenre).toBeNull();
expect(result.current.effectiveGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("无 genre 时 setChosenGenre 后 effectiveGenre/genreDirective 更新", () => {
const { result } = renderHook(() => useGenreGate(null));
act(() => result.current.setChosenGenre("都市"));
expect(result.current.chosenGenre).toBe("都市");
expect(result.current.effectiveGenre).toBe("都市");
expect(result.current.genreDirective).toBe("本章题材:都市");
});
it("setChosenGenre 传空串 → 回落 null不产生题材指令", () => {
const { result } = renderHook(() => useGenreGate(null));
act(() => result.current.setChosenGenre("科幻"));
act(() => result.current.setChosenGenre(" "));
expect(result.current.chosenGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("项目 genre 优先于作者点选", () => {
const { result } = renderHook(() => useGenreGate("历史"));
act(() => result.current.setChosenGenre("悬疑"));
// needsGenre=false 时 UI 不会调 setChosenGenre但即便调了项目题材仍优先。
expect(result.current.effectiveGenre).toBe("历史");
expect(result.current.genreDirective).toBe("本章题材:历史");
});
});

View File

@@ -0,0 +1,55 @@
"use client";
import { useCallback, useMemo, useState } from "react";
// 极轻 genre 采集门WFW-7纯前端零契约。首次写章前若项目无 genre
// 让作者点一个题材仅【临时并入本次写作指令】——MVP 不落库(无项目更新端点),
// 避免无题材 slop。纯状态无副作用组件只负责渲染 chips + 触发 onWrite。
// 有 effectiveGenre 时的题材指令前缀(并进 composeDirective。空题材 → ""。
export function toGenreDirective(genre: string | null): string {
const trimmed = genre?.trim() ?? "";
return trimmed.length > 0 ? `本章题材:${trimmed}` : "";
}
export interface GenreGate {
// 项目缺 genre需先采集。为空串/纯空白亦视为缺失。
needsGenre: boolean;
// 作者本次临时点选的题材(未选 = null
chosenGenre: string | null;
setChosenGenre: (genre: string) => void;
// 生效题材:项目 genre 优先,其次作者点选,都无 → null。
effectiveGenre: string | null;
// 并入本次 directive 的题材串(形如「本章题材:玄幻」);无生效题材 → ""。
genreDirective: string;
}
export function useGenreGate(projectGenre: string | null): GenreGate {
const [chosenGenre, setChosenGenreState] = useState<string | null>(null);
const setChosenGenre = useCallback((genre: string): void => {
const trimmed = genre.trim();
setChosenGenreState(trimmed.length > 0 ? trimmed : null);
}, []);
const needsGenre = (projectGenre?.trim() ?? "").length === 0;
const effectiveGenre = useMemo<string | null>(() => {
const fromProject = projectGenre?.trim() ?? "";
if (fromProject.length > 0) return fromProject;
return chosenGenre;
}, [projectGenre, chosenGenre]);
const genreDirective = useMemo(
() => toGenreDirective(effectiveGenre),
[effectiveGenre],
);
return {
needsGenre,
chosenGenre,
setChosenGenre,
effectiveGenre,
genreDirective,
};
}