Files
writer-work-flow/apps/web/lib/workbench/contextDrawer.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

85 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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