写作工作台重构剩余 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%。
85 lines
2.8 KiB
TypeScript
85 lines
2.8 KiB
TypeScript
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),
|
||
}));
|
||
}
|