- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching) - useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉) - 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单, 分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import type { ReactNode } from "react";
|
||
import Link from "next/link";
|
||
import {
|
||
ArrowUpRight,
|
||
Minus,
|
||
Pin,
|
||
PinOff,
|
||
Plus,
|
||
RotateCcw,
|
||
Sparkles,
|
||
Undo2,
|
||
X,
|
||
} from "lucide-react";
|
||
|
||
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,
|
||
kindLabel,
|
||
reasonLabel,
|
||
RECENT_N_MAX,
|
||
RECENT_N_MIN,
|
||
type InjectionEntity,
|
||
type InjectionEntityRef,
|
||
} from "@/lib/workbench/injection";
|
||
import { useInjection } from "@/lib/workbench/useInjection";
|
||
|
||
interface ChapterAssistantProps {
|
||
projectId: string;
|
||
chapterNo: number;
|
||
}
|
||
|
||
// 右栏「本章助手」(UX §6.3)。
|
||
// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标,
|
||
// 并让作者 📌置顶 / ✕排除 / 调近 N 章(可控版)——「看到的=写章用的」信任牌(不变量 #6)。
|
||
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。
|
||
export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) {
|
||
return (
|
||
<aside className="hidden border-l border-line bg-panel py-4 xl:block">
|
||
<AssistantContent projectId={projectId} chapterNo={chapterNo} />
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
// 窄栏小标题(P3-5):font-sans + eyebrow 字号/正字距(对齐 Eyebrow 眉题的视觉),
|
||
// 但保留 <h3> 语义(不降级为 <p>,守住标题大纲 a11y)。可带右侧行内控件(如步进器)。
|
||
function PanelLabel({
|
||
children,
|
||
action,
|
||
}: {
|
||
children: ReactNode;
|
||
action?: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="flex items-start justify-between gap-3">
|
||
<h3 className="font-sans text-eyebrow uppercase tracking-wide text-muted-soft">
|
||
{children}
|
||
</h3>
|
||
{action ? <div className="shrink-0">{action}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
|
||
const injection = useInjection(projectId, chapterNo);
|
||
const data = injection.data;
|
||
const selected = data?.selected ?? [];
|
||
const excluded = data?.excluded ?? [];
|
||
|
||
return (
|
||
<div className="px-4">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h2 className="font-sans text-title-md font-medium text-ink">
|
||
本章参考
|
||
</h2>
|
||
{injection.saving ? (
|
||
<ThinkingIndicator label="保存中" className="text-xs text-ink-soft" />
|
||
) : null}
|
||
</div>
|
||
|
||
<section className="mt-5">
|
||
<PanelLabel
|
||
action={
|
||
data ? (
|
||
<RecentStepper
|
||
value={data.recent_n}
|
||
disabled={injection.saving}
|
||
onChange={injection.setRecentN}
|
||
/>
|
||
) : null
|
||
}
|
||
>
|
||
AI 写这章会参考
|
||
</PanelLabel>
|
||
|
||
{injection.loading ? (
|
||
<p className="mt-2 text-caption text-ink-soft">加载参考信息…</p>
|
||
) : !data ? (
|
||
// 首次加载失败:data 仍为空 → 满屏错误框 + 重试。
|
||
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
|
||
<p>请检查后端连接后重试。</p>
|
||
<button
|
||
type="button"
|
||
onClick={injection.reload}
|
||
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||
重试
|
||
</button>
|
||
</StatusNote>
|
||
) : (
|
||
<>
|
||
{injection.saveError ? (
|
||
// 保存失败:悬浮提示,不替换列表;可关闭 + 重试上一次覆盖。
|
||
<StatusNote variant="danger" className="mt-2 text-xs" title={injection.saveError}>
|
||
<div className="mt-1 flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void injection.retrySave()}
|
||
disabled={injection.saving}
|
||
className={buttonClass({ variant: "outline", size: "sm" })}
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||
重试
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={injection.clearSaveError}
|
||
aria-label="关闭保存失败提示"
|
||
className={buttonClass({ variant: "ghost", size: "sm" })}
|
||
>
|
||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</StatusNote>
|
||
) : null}
|
||
|
||
{selected.length === 0 ? (
|
||
// 空态占位(不加标题,避免在 h3 子小节内注入 h2 破坏标题大纲)。
|
||
<div className="mt-2 flex flex-col items-center gap-2 py-4 text-center">
|
||
<Sparkles className="h-5 w-5 text-muted-soft" aria-hidden="true" />
|
||
<p className="max-w-xs text-caption leading-6 text-ink-soft">
|
||
这一章 AI 还没要参考的设定/角色。可在大纲里点名,或从下方「已排除」里恢复。
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<ul className="mt-3 space-y-2">
|
||
{selected.map((entity) => (
|
||
<EntityRow
|
||
key={`${entity.kind}:${entity.name}`}
|
||
entity={entity}
|
||
pinned={isPinned(
|
||
{ pinned: data.pinned, excluded: data.excluded, recent_n: data.recent_n },
|
||
entity,
|
||
)}
|
||
disabled={injection.saving}
|
||
onTogglePin={injection.togglePin}
|
||
onExclude={injection.exclude}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{excluded.length > 0 ? (
|
||
<div className="mt-4">
|
||
<PanelLabel>已排除(本章不参考)</PanelLabel>
|
||
<ul className="mt-2 space-y-1">
|
||
{excluded.map((ref) => (
|
||
<ExcludedRow
|
||
key={`${ref.kind}:${ref.name}`}
|
||
refItem={ref}
|
||
disabled={injection.saving}
|
||
onRestore={injection.restore}
|
||
/>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : 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">
|
||
写完这章,AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。
|
||
</p>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 「本章伏笔」主动提醒:按当前章号把伏笔分可回收 / 待回收 / 已逾期三类,
|
||
// 让作者在写这章时就想起该回收哪条、哪条已逾期。只读,编辑走「伏笔看板」。
|
||
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;
|
||
disabled: boolean;
|
||
onTogglePin: (ref: InjectionEntityRef) => void;
|
||
onExclude: (ref: InjectionEntityRef) => void;
|
||
}
|
||
|
||
function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityRowProps) {
|
||
const ref: InjectionEntityRef = { kind: entity.kind, name: entity.name };
|
||
return (
|
||
<li className="rounded border border-line bg-bg p-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="rounded bg-panel px-1.5 py-0.5 text-2xs text-ink-soft">
|
||
{kindLabel(entity.kind)}
|
||
</span>
|
||
<span className="text-sm text-ink">{entity.name}</span>
|
||
<div className="ml-auto flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={disabled}
|
||
aria-pressed={pinned}
|
||
aria-label={
|
||
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(一定参考)`
|
||
}
|
||
onClick={() => onTogglePin(ref)}
|
||
className={pinned ? "text-cinnabar" : undefined}
|
||
>
|
||
{pinned ? (
|
||
<Pin className="h-4 w-4" aria-hidden="true" />
|
||
) : (
|
||
<PinOff className="h-4 w-4" aria-hidden="true" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={disabled}
|
||
aria-label={`排除 ${entity.name}(本章不参考)`}
|
||
onClick={() => onExclude(ref)}
|
||
>
|
||
<X className="h-4 w-4" aria-hidden="true" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||
{(entity.reasons ?? []).map((reason) => (
|
||
<span
|
||
key={reason}
|
||
className="rounded bg-[var(--color-cinnabar-wash)] px-1.5 py-0.5 text-2xs text-cinnabar"
|
||
>
|
||
{reasonLabel(reason)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
interface ExcludedRowProps {
|
||
refItem: InjectionEntityRef;
|
||
disabled: boolean;
|
||
onRestore: (ref: InjectionEntityRef) => void;
|
||
}
|
||
|
||
function ExcludedRow({ refItem, disabled, onRestore }: ExcludedRowProps) {
|
||
return (
|
||
<li className="flex items-center gap-2 rounded border border-dashed border-line px-2 py-1">
|
||
<span className="text-2xs text-ink-soft">{kindLabel(refItem.kind)}</span>
|
||
<span className="text-xs text-ink-soft line-through">{refItem.name}</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={disabled}
|
||
aria-label={`恢复 ${refItem.name}(回到自动判定)`}
|
||
onClick={() => onRestore(refItem)}
|
||
className="ml-auto text-cinnabar"
|
||
>
|
||
<Undo2 className="h-4 w-4" aria-hidden="true" />
|
||
</Button>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
interface RecentStepperProps {
|
||
value: number;
|
||
disabled: boolean;
|
||
onChange: (n: number) => void;
|
||
}
|
||
|
||
// 「关联近 N 章」步进器:覆盖近况摘要回看章数(钳在 [MIN, MAX])。
|
||
function RecentStepper({ value, disabled, onChange }: RecentStepperProps) {
|
||
return (
|
||
<div className="flex items-center gap-1 text-2xs text-ink-soft">
|
||
<span>近</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={disabled || value <= RECENT_N_MIN}
|
||
aria-label="减少关联章数"
|
||
onClick={() => onChange(value - 1)}
|
||
>
|
||
<Minus className="h-4 w-4" aria-hidden="true" />
|
||
</Button>
|
||
<span className="w-4 text-center tabular-nums text-ink">{value}</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={disabled || value >= RECENT_N_MAX}
|
||
aria-label="增加关联章数"
|
||
onClick={() => onChange(value + 1)}
|
||
>
|
||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||
</Button>
|
||
<span>章</span>
|
||
</div>
|
||
);
|
||
}
|