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,336 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState, type RefObject } from "react";
import { ArrowUpRight, BookMarked, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { badgeClass, cn, overlayScrim } from "@/lib/ui/variants";
import {
CONTEXT_TABS,
contextTabHref,
type CodexGroup,
type ContextTabKey,
type OutlineRow,
} from "@/lib/workbench/contextDrawer";
import {
useContextDrawerData,
type ContextResource,
} from "@/lib/workbench/useContextDrawer";
// 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。
export const CONTEXT_DRAWER_ENABLED = true;
interface ContextDrawerProps {
projectId: string;
open: boolean;
onClose: () => void;
// 关闭时把焦点还给触发按钮a11y
triggerRef?: RefObject<HTMLElement | null>;
}
// 从编辑器右侧滑出的【只读】上下文速查抽屉:不离开写作界面即可查设定库/大纲/伏笔/规则/文风。
// 视口无关(不同于 components/Drawer 的 lg:hidden 移动端抽屉),全只读、绝不写库。
// a11y 复用命令面板/Drawer 范式role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。
export function ContextDrawer({
projectId,
open,
onClose,
triggerRef,
}: ContextDrawerProps) {
const [activeTab, setActiveTab] = useState<ContextTabKey>("codex");
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const wasOpenRef = useRef(false);
const data = useContextDrawerData(projectId, activeTab, open);
useBodyScrollLock(open);
useEffect(() => {
if (!open) {
if (wasOpenRef.current) {
wasOpenRef.current = false;
triggerRef?.current?.focus();
}
return;
}
wasOpenRef.current = true;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose, triggerRef]);
if (!open) return null;
return (
<div className={overlayScrim} onClick={onClose}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="上下文速查"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (panelRef.current) handleTabTrap(panelRef.current, e);
}}
className="absolute right-0 top-0 flex h-full w-96 max-w-[90vw] flex-col border-l border-line bg-panel shadow-paper outline-none"
>
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<span className="flex items-center gap-2 font-serif text-base text-ink">
<BookMarked className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</span>
<Button
ref={closeRef}
onClick={onClose}
aria-label="关闭上下文速查"
variant="ghost"
size="icon"
>
<X className="h-5 w-5" aria-hidden="true" />
</Button>
</header>
<TabBar activeTab={activeTab} onSelect={setActiveTab} />
<div className="flex-1 overflow-auto overscroll-contain px-4 py-4">
<TabPanel projectId={projectId} activeTab={activeTab} data={data} />
</div>
</div>
</div>
);
}
interface TabBarProps {
activeTab: ContextTabKey;
onSelect: (key: ContextTabKey) => void;
}
// Tab 选择条role=tablist设定库/大纲做完整速查,伏笔/规则/文风为摘要+跳链。
function TabBar({ activeTab, onSelect }: TabBarProps) {
return (
<div
role="tablist"
aria-label="上下文分类"
className="flex gap-1 overflow-x-auto border-b border-line px-2 py-2"
>
{CONTEXT_TABS.map((tab) => {
const active = tab.key === activeTab;
return (
<button
key={tab.key}
role="tab"
type="button"
aria-selected={active}
onClick={() => onSelect(tab.key)}
className={cn(
"shrink-0 rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink-soft hover:text-cinnabar",
)}
>
{tab.label}
</button>
);
})}
</div>
);
}
interface TabPanelProps {
projectId: string;
activeTab: ContextTabKey;
data: ReturnType<typeof useContextDrawerData>;
}
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
const href = contextTabHref(projectId, activeTab);
return (
<div role="tabpanel" className="space-y-3">
{activeTab === "codex" ? (
<CodexPanel resource={data.codex} onRetry={data.reloadCodex} />
) : null}
{activeTab === "outline" ? (
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
) : null}
{activeTab === "foreshadow" ? (
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" />
) : null}
{activeTab === "rules" ? (
<PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" />
) : null}
{activeTab === "style" ? (
<PlaceholderPanel summary="文风指纹16 维文风画像,在文风页查看维度与证据。" />
) : null}
<FullPageLink href={href} />
</div>
);
}
// 加载中骨架(统一给设定库/大纲复用)。
function LoadingNote() {
return (
<div className="py-6 text-center">
<ThinkingIndicator label="加载中" className="text-sm text-ink-soft" />
</div>
);
}
interface ErrorNoteProps {
message: string;
onRetry: () => void;
}
function ErrorNote({ message, onRetry }: ErrorNoteProps) {
return (
<StatusNote variant="danger" title="加载失败">
<p>{message}</p>
<Button onClick={onRetry} variant="secondary" size="sm" className="mt-2">
</Button>
</StatusNote>
);
}
function EmptyNote({ text }: { text: string }) {
return (
<StatusNote variant="info">
<p>{text}</p>
</StatusNote>
);
}
interface CodexPanelProps {
resource: ContextResource<CodexGroup[]>;
onRetry: () => void;
}
// 设定库速查:按类型分组的实体名单(含硬规则条目),完整只读列表。
function CodexPanel({ resource, onRetry }: CodexPanelProps) {
if (resource.status === "loading" || resource.status === "idle") {
return <LoadingNote />;
}
if (resource.status === "error") {
return <ErrorNote message="设定库暂不可用。" onRetry={onRetry} />;
}
if (resource.data.length === 0) {
return <EmptyNote text="还没有世界观实体,去设定库添加后回来速查。" />;
}
return (
<div className="space-y-4">
{resource.data.map((group) => (
<section key={group.type}>
<h3 className="mb-1.5 flex items-center gap-2 text-sm font-medium text-ink">
{group.type}
<span className={badgeClass({ variant: "neutral" })}>
{group.entities.length}
</span>
</h3>
<ul className="space-y-1.5">
{group.entities.map((entity) => (
<li
key={`${group.type}-${entity.name}`}
className="rounded border border-line bg-bg px-3 py-2"
>
<p className="text-sm text-ink">{entity.name}</p>
{entity.rules && entity.rules.length > 0 ? (
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-xs text-ink-soft">
{entity.rules.map((rule, i) => (
<li key={i}>{rule}</li>
))}
</ul>
) : null}
</li>
))}
</ul>
</section>
))}
</div>
);
}
interface OutlinePanelProps {
resource: ContextResource<OutlineRow[]>;
onRetry: () => void;
}
// 大纲速查:逐章节拍紧凑列表 + 该章伏笔窗口代号徽标,完整只读。
function OutlinePanel({ resource, onRetry }: OutlinePanelProps) {
if (resource.status === "loading" || resource.status === "idle") {
return <LoadingNote />;
}
if (resource.status === "error") {
return <ErrorNote message="大纲暂不可用。" onRetry={onRetry} />;
}
if (resource.data.length === 0) {
return <EmptyNote text="还没有大纲,去大纲页生成后回来速查。" />;
}
return (
<ul className="space-y-1.5">
{resource.data.map((row) => (
<li
key={row.no}
className="rounded border border-line bg-bg px-3 py-2"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs text-ink-soft">
{row.no} · {row.volume}
</span>
{row.foreshadowCodes.length > 0 ? (
<span className="flex flex-wrap gap-1">
{row.foreshadowCodes.map((code) => (
<span key={code} className={badgeClass({ variant: "accent" })}>
{code}
</span>
))}
</span>
) : null}
</div>
{row.beats.length > 0 ? (
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-sm text-ink">
{row.beats.map((beat, i) => (
<li key={i}>{beat}</li>
))}
</ul>
) : (
<p className="mt-1 text-xs text-ink-soft"></p>
)}
</li>
))}
</ul>
);
}
// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
function PlaceholderPanel({ summary }: { summary: string }) {
return (
<StatusNote variant="info">
<p>{summary}</p>
</StatusNote>
);
}
// 「去完整页编辑 →」链接:抽屉只读速查 → 跳全宽页编辑。
function FullPageLink({ href }: { href: string }) {
return (
<Link
href={href}
className="inline-flex items-center gap-1 text-sm text-cinnabar hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"
>
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</Link>
);
}