Files
writer-work-flow/apps/web/components/outline/OutlineEditor.tsx
2026-06-28 07:31:20 +02:00

179 lines
6.1 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 { 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);
// 视图卷过滤("全部" 或某卷)——只影响展示,与生成的目标卷 `volume` 解耦。
const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
const volumes = useMemo(
() => groupByVolume(filterByViewVolume(chapters, viewVolume)),
[chapters, viewVolume],
);
const generating = status === "generating";
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="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
actions={
<>
{availableVolumes.length > 0 ? (
<label
htmlFor="view-vol"
className="ml-auto text-xs text-ink-soft"
>
<Select
id="view-vol"
value={viewVolume === "all" ? "all" : String(viewVolume)}
onChange={(e) =>
setViewVolume(
e.target.value === "all"
? "all"
: Number(e.target.value),
)
}
controlSize="sm"
className="ml-1"
>
<option value="all"></option>
{availableVolumes.map((v) => (
<option key={v} value={String(v)}>
{v}
</option>
))}
</Select>
</label>
) : null}
<label
htmlFor="vol"
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
>
</label>
<TextInput
id="vol"
inputMode="numeric"
value={volume}
onChange={(e) =>
setVolume(Math.max(1, Number(e.target.value) || 1))
}
controlSize="sm"
className="w-16"
/>
<Button
disabled={generating}
onClick={() => void generate(project.id, volume)}
variant="primary"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "排大纲中…" : "AI 排大纲"}
</Button>
</>
}
/>
{error ? (
<p className="mb-4 text-sm text-conflict">
{error.code}{error.message}
{error.code === "LLM_UNAVAILABLE" ? (
<>
{" "}
<a
href="/settings/providers"
className="underline hover:text-cinnabar"
>
</a>
</>
) : null}
</p>
) : 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={() => void generate(project.id, volume)}
variant="primary"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "排大纲中…" : "AI 排大纲"}
</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>
);
}