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