Files
writer-work-flow/apps/web/components/workbench/ContextDrawer.tsx
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

337 lines
11 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.

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