- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching) - useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉) - 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单, 分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|