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