Files
writer-work-flow/apps/web/components/outline/OutlineEditor.tsx

240 lines
7.9 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 { useMemo, useState } from "react";
import { ListTree, Sparkles } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import { Select } from "@/components/ui/Select";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextInput } from "@/components/ui/TextInput";
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
import {
distinctVolumes,
filterByViewVolume,
groupByVolume,
type ViewVolume,
} from "@/lib/outline/outline";
import { useOutline } from "@/lib/outline/useOutline";
import { OutlineChapterRow } from "./OutlineChapterRow";
interface OutlineEditorProps {
project: ProjectResponse;
initialChapters: OutlineChapterView[];
}
// 大纲编辑器主体UX §6.7。RSC 种入已存大纲Client 承载生成POST .../outline
// 无凭据 → 503 LLM_UNAVAILABLE 经 error 检出、引导去设置。
export function OutlineEditor({
project,
initialChapters,
}: OutlineEditorProps) {
const { chapters, status, error, generate } = useOutline(initialChapters);
// 生成目标卷与「正在看的卷」共用一个心智:选具体卷即把生成目标设为该卷。
const [volume, setVolume] = useState(1);
const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
// 「重新排大纲」破坏性覆盖的两段式确认(仅目标卷已有章节时出现)。
const [confirming, setConfirming] = useState(false);
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
const volumes = useMemo(
() => groupByVolume(filterByViewVolume(chapters, viewVolume)),
[chapters, viewVolume],
);
const generating = status === "generating";
// 目标卷已有章节 → 生成是破坏性替换,文案/确认都要变。
const targetHasChapters = useMemo(
() => filterByViewVolume(chapters, volume).length > 0,
[chapters, volume],
);
// 选具体卷时把生成目标对齐到所看卷,消除「看卷 A、却生成卷 B」的易混。
const onViewVolumeChange = (next: ViewVolume): void => {
setViewVolume(next);
setConfirming(false);
if (next !== "all") setVolume(next);
};
const onVolumeChange = (next: number): void => {
setVolume(Math.max(1, next || 1));
setConfirming(false);
};
const runGenerate = (): void => {
setConfirming(false);
void generate(project.id, volume);
};
// 安全卷直接生成;破坏性卷先进入两段式确认。
const onGenerateClick = (): void => {
if (targetHasChapters) {
setConfirming(true);
return;
}
runGenerate();
};
const generateLabel = generating
? "排大纲中…"
: `${targetHasChapters ? "重新排大纲" : "AI 排大纲"} · 卷 ${volume}`;
return (
<AppShell
title={`${project.title}`}
subtitle="大纲"
projectId={project.id}
activeNav="outline"
>
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
<PageHeader
title="大纲"
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
/>
{/* 卷/视图/生成控件独立成全宽工具栏行(窄屏自动换行,不用脆弱的 ml-auto。 */}
<div className="mb-4 flex flex-wrap items-center gap-2">
{availableVolumes.length > 0 ? (
<label
htmlFor="view-vol"
className="flex items-center gap-1 text-xs text-ink-soft"
>
<Select
id="view-vol"
value={viewVolume === "all" ? "all" : String(viewVolume)}
onChange={(e) =>
onViewVolumeChange(
e.target.value === "all" ? "all" : Number(e.target.value),
)
}
controlSize="sm"
>
<option value="all"></option>
{availableVolumes.map((v) => (
<option key={v} value={String(v)}>
{v}
</option>
))}
</Select>
</label>
) : null}
{availableVolumes.length > 0 ? (
<span className="ml-1 h-4 w-px bg-line" aria-hidden="true" />
) : null}
<label
htmlFor="vol"
className="flex items-center gap-1 text-xs text-ink-soft"
>
<TextInput
id="vol"
inputMode="numeric"
value={volume}
onChange={(e) => onVolumeChange(Number(e.target.value))}
controlSize="sm"
className="w-16"
/>
</label>
<Button
disabled={generating}
onClick={onGenerateClick}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generateLabel}
</Button>
</div>
{confirming ? (
<StatusNote
variant="warning"
title={`${volume} 已有章节`}
className="mb-4"
>
<p> {volume} </p>
<div className="mt-2 flex flex-wrap gap-2">
<Button onClick={runGenerate} variant="danger" size="sm">
{volume}
</Button>
<Button
onClick={() => setConfirming(false)}
variant="secondary"
size="sm"
>
</Button>
</div>
</StatusNote>
) : null}
{error ? (
<StatusNote variant="danger" title="大纲生成失败" className="mb-4">
{error.text}
{error.actionHref ? (
<>
{" "}
<a
href={error.actionHref}
className="underline hover:text-cinnabar"
>
{error.actionLabel}
</a>
</>
) : null}
</StatusNote>
) : null}
<div className="min-h-0 flex-1 overflow-auto">
{volumes.length === 0 ? (
<EmptyState
icon={ListTree}
title={
chapters.length > 0 && viewVolume !== "all"
? `${viewVolume} 暂无大纲`
: "暂无大纲"
}
description={
chapters.length > 0 && viewVolume !== "all"
? "切到「全部」查看其它卷,或为当前卷生成章节节拍。"
: "点击「AI 排大纲」生成逐章节拍与伏笔窗口,再回到写作台逐章推进。"
}
action={
<Button
disabled={generating}
onClick={onGenerateClick}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generateLabel}
</Button>
}
/>
) : (
volumes.map((group) => (
<section key={group.volume} className="mb-6">
<h2 className="mb-2 font-serif text-base text-ink">
{group.volume}
</h2>
<ul className="space-y-1">
{group.chapters.map((ch) => (
<OutlineChapterRow
key={ch.no}
chapter={ch}
projectId={project.id}
/>
))}
</ul>
</section>
))
)}
</div>
</div>
</AppShell>
);
}