feat(foreshadow): 写作台本章伏笔主动提醒(前端)

- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching)
- useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉)
- 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单,
  分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
This commit is contained in:
Yaojia Wang
2026-07-12 17:53:00 +02:00
parent 9aa0cddeaf
commit b810a3fa3c
8 changed files with 507 additions and 4 deletions

View File

@@ -1,7 +1,9 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import {
ArrowUpRight,
Minus,
Pin,
PinOff,
@@ -15,6 +17,9 @@ import {
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { ChapterForeshadowList } from "@/components/workbench/ChapterForeshadowList";
import { chapterForeshadowTotal } from "@/lib/foreshadow/chapterForeshadow";
import { useChapterForeshadow } from "@/lib/foreshadow/useChapterForeshadow";
import { buttonClass } from "@/lib/ui/variants";
import {
isPinned,
@@ -183,6 +188,8 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
) : null}
</section>
<ChapterForeshadowSection projectId={projectId} chapterNo={chapterNo} />
<section className="mt-6 border-t border-line-soft pt-5">
<PanelLabel></PanelLabel>
<p className="mt-1.5 text-caption leading-6 text-ink-soft">
@@ -193,6 +200,55 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
);
}
// 「本章伏笔」主动提醒:按当前章号把伏笔分可回收 / 待回收 / 已逾期三类,
// 让作者在写这章时就想起该回收哪条、哪条已逾期。只读,编辑走「伏笔看板」。
function ChapterForeshadowSection({ projectId, chapterNo }: ChapterAssistantProps) {
const { groups, status, reload } = useChapterForeshadow(projectId, chapterNo);
const boardHref = `/projects/${projectId}/foreshadow`;
const isEmpty = chapterForeshadowTotal(groups) === 0;
return (
<section className="mt-6 border-t border-line-soft pt-5">
<PanelLabel
action={<span className="text-2xs text-muted-soft"></span>}
>
</PanelLabel>
{status === "loading" ? (
<p className="mt-2 text-caption text-ink-soft"></p>
) : status === "error" ? (
<StatusNote variant="danger" className="mt-2 text-xs" title="伏笔暂不可用">
<button
type="button"
onClick={reload}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : isEmpty ? (
<p className="mt-2 text-caption leading-6 text-ink-soft">
</p>
) : (
<div className="mt-3">
<ChapterForeshadowList groups={groups} />
</div>
)}
<Link
href={boardHref}
className="mt-3 inline-flex items-center gap-1 text-caption text-cinnabar hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"
>
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</section>
);
}
interface EntityRowProps {
entity: InjectionEntity;
pinned: boolean;

View File

@@ -0,0 +1,104 @@
import { Flag } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { StatusDot, type StatusTone } from "@/components/ui/StatusDot";
import {
chapterForeshadowTotal,
foreshadowStatusLabel,
type ChapterForeshadowGroups,
} from "@/lib/foreshadow/chapterForeshadow";
import type { ForeshadowView } from "@/lib/api/types";
import { badgeClass, cn, type BadgeVariant } from "@/lib/ui/variants";
// 「本章伏笔」分组只读清单:写作台右栏 + 速查抽屉共用DRY
// 三类各成一小块(空块不渲染);每条给状态点 + 编号 + 标题 + 状态徽标。
// 无动画,天然尊重 reduced-motion。空态由调用方决定文案。
interface GroupSpec {
key: keyof ChapterForeshadowGroups;
label: string;
tone: StatusTone;
// 已逾期用琥珀强调整块外框。
emphasize?: boolean;
// 可回收在标签前加 ⚑,提示「本章可安排回收」。
flag?: boolean;
}
// 顺序即优先级:可回收(现在能做)→ 已逾期(急)→ 待回收(在后,仅提示)。
const GROUP_SPECS: readonly GroupSpec[] = [
{ key: "recyclable", label: "可回收", tone: "success", flag: true },
{ key: "overdue", label: "已逾期", tone: "warning", emphasize: true },
{ key: "pending", label: "待回收", tone: "neutral" },
];
// 单条状态 → 徽标色OVERDUE 琥珀 / PARTIAL 朱 / OPEN 中性)。
function statusBadgeVariant(status: string): BadgeVariant {
if (status === "OVERDUE") return "warning";
if (status === "PARTIAL") return "accent";
return "neutral";
}
interface ChapterForeshadowListProps {
groups: ChapterForeshadowGroups;
}
export function ChapterForeshadowList({ groups }: ChapterForeshadowListProps) {
if (chapterForeshadowTotal(groups) === 0) return null;
return (
<div className="space-y-3">
{GROUP_SPECS.map((spec) => (
<ForeshadowGroupBlock key={spec.key} spec={spec} items={groups[spec.key]} />
))}
</div>
);
}
interface ForeshadowGroupBlockProps {
spec: GroupSpec;
items: ForeshadowView[];
}
function ForeshadowGroupBlock({ spec, items }: ForeshadowGroupBlockProps) {
if (items.length === 0) return null;
return (
<div>
<p className="flex items-center gap-1.5 text-2xs uppercase tracking-wide text-muted-soft">
{spec.flag ? (
<Flag className="h-3 w-3 text-pass" aria-hidden="true" />
) : (
<StatusDot tone={spec.tone} />
)}
<span>{spec.label}</span>
<span className="tabular-nums text-ink-soft">{items.length}</span>
</p>
<ul className="mt-1.5 space-y-1">
{items.map((item) => (
<li
key={item.code}
className={cn(
"rounded border px-2 py-1.5",
spec.emphasize
? "border-overdue/35 bg-overdue/10"
: "border-line bg-bg",
)}
>
<div className="flex items-center gap-2">
<span className="shrink-0 font-mono text-2xs text-ink-soft">
{item.code}
</span>
<span className="truncate text-sm text-ink">{item.title}</span>
<span
className={badgeClass({
variant: statusBadgeVariant(item.status),
className: "ml-auto shrink-0",
})}
>
{foreshadowStatusLabel(item.status)}
</span>
</div>
</li>
))}
</ul>
</div>
);
}

View File

@@ -22,12 +22,17 @@ 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
@@ -39,6 +44,7 @@ interface ContextDrawerProps {
// a11y 复用命令面板/Drawer 范式role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。
export function ContextDrawer({
projectId,
chapterNo,
open,
onClose,
triggerRef,
@@ -98,7 +104,12 @@ export function ContextDrawer({
<TabBar activeTab={activeTab} onSelect={setActiveTab} />
<div className="flex-1 overflow-auto overscroll-contain px-4 py-4">
<TabPanel projectId={projectId} activeTab={activeTab} data={data} />
<TabPanel
projectId={projectId}
chapterNo={chapterNo}
activeTab={activeTab}
data={data}
/>
</div>
</div>
</div>
@@ -144,12 +155,13 @@ function TabBar({ activeTab, onSelect }: TabBarProps) {
interface TabPanelProps {
projectId: string;
chapterNo: number;
activeTab: ContextTabKey;
data: ReturnType<typeof useContextDrawerData>;
}
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
function TabPanel({ projectId, chapterNo, activeTab, data }: TabPanelProps) {
const href = contextTabHref(projectId, activeTab);
return (
<div role="tabpanel" className="space-y-3">
@@ -160,7 +172,7 @@ function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
) : null}
{activeTab === "foreshadow" ? (
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" />
<ForeshadowPanel projectId={projectId} chapterNo={chapterNo} />
) : null}
{activeTab === "rules" ? (
<PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" />
@@ -307,7 +319,32 @@ function OutlinePanel({ resource, onRetry }: OutlinePanelProps) {
);
}
// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
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">

View File

@@ -444,6 +444,7 @@ export function Workbench({
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer
projectId={project.id}
chapterNo={chapterNo}
open={contextOpen}
onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef}