Files
writer-work-flow/apps/web/components/workbench/ContextDrawer.tsx
Yaojia Wang b810a3fa3c feat(foreshadow): 写作台本章伏笔主动提醒(前端)
- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching)
- useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉)
- 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单,
  分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
2026-07-12 17:53:00 +02:00

368 lines
12 KiB
TypeScript
Raw Permalink 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 { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
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";
import { chapterForeshadowTotal } from "@/lib/foreshadow/chapterForeshadow";
import { useChapterForeshadow } from "@/lib/foreshadow/useChapterForeshadow";
import { ChapterForeshadowList } from "./ChapterForeshadowList";
// 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。
export const CONTEXT_DRAWER_ENABLED = true;
interface ContextDrawerProps {
projectId: string;
// 当前章号:伏笔速查按此分「可回收 / 待回收 / 已逾期」。
chapterNo: number;
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,
chapterNo,
open,
onClose,
triggerRef,
}: ContextDrawerProps) {
const [activeTab, setActiveTab] = useState<ContextTabKey>("codex");
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const data = useContextDrawerData(projectId, activeTab, open);
useBodyScrollLock(open);
useRestoreFocus(open, triggerRef);
useEffect(() => {
if (!open) return;
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]);
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}
chapterNo={chapterNo}
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;
chapterNo: number;
activeTab: ContextTabKey;
data: ReturnType<typeof useContextDrawerData>;
}
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
function TabPanel({ projectId, chapterNo, 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" ? (
<ForeshadowPanel projectId={projectId} chapterNo={chapterNo} />
) : 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>
);
}
interface ForeshadowPanelProps {
projectId: string;
chapterNo: number;
}
// 伏笔速查(只读):按当前章号分「可回收 / 待回收 / 已逾期」,与右栏本章伏笔同数据源、同分类函数。
function ForeshadowPanel({ projectId, chapterNo }: ForeshadowPanelProps) {
const { groups, status, reload } = useChapterForeshadow(projectId, chapterNo);
if (status === "loading") return <LoadingNote />;
if (status === "error") {
return <ErrorNote message="伏笔暂不可用。" onRetry={reload} />;
}
if (chapterForeshadowTotal(groups) === 0) {
return <EmptyNote text="这一章暂无待回收或已逾期的伏笔(按已验收进度)。" />;
}
return (
<div>
<p className="mb-2 text-2xs uppercase tracking-wide text-muted-soft">
·
</p>
<ChapterForeshadowList groups={groups} />
</div>
);
}
// 占位面板(规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
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>
);
}