fix(web): 大纲生成只替换目标卷+重排确认+窗口徽标用lucide + 伏笔看板中文化/单一登记入口/Field表单
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { Flag, PenLine } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
@@ -16,7 +16,7 @@ interface OutlineChapterRowProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
// 单章大纲行(UX §6.7):章号 + beats + 伏笔窗口徽标(⚑)+ 「写此章」入口。
|
||||
// 单章大纲行(UX §6.7):章号 + beats + 伏笔窗口徽标(lucide Flag 图标)+ 「写此章」入口。
|
||||
// 接近回收窗口的徽标用琥珀 + 「可回收」提示(a11y:图标 + 文案,不单靠色)。
|
||||
export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps) {
|
||||
const windows = chapter.foreshadow_windows ?? [];
|
||||
@@ -41,6 +41,7 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
|
||||
variant={close ? "warning" : "accent"}
|
||||
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
|
||||
>
|
||||
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||
{windowBadgeLabel(w)}
|
||||
{close ? " · 可回收" : ""}
|
||||
</Badge>
|
||||
|
||||
@@ -32,15 +32,52 @@ export function OutlineEditor({
|
||||
initialChapters,
|
||||
}: OutlineEditorProps) {
|
||||
const { chapters, status, error, generate } = useOutline(initialChapters);
|
||||
// 生成目标卷与「正在看的卷」共用一个心智:选具体卷即把生成目标设为该卷。
|
||||
const [volume, setVolume] = useState(1);
|
||||
// 视图卷过滤("全部" 或某卷)——只影响展示,与生成的目标卷 `volume` 解耦。
|
||||
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
|
||||
@@ -53,63 +90,86 @@ export function OutlineEditor({
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* 卷/视图/生成控件独立成全宽工具栏行(窄屏自动换行,不用脆弱的 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) =>
|
||||
setVolume(Math.max(1, Number(e.target.value) || 1))
|
||||
onViewVolumeChange(
|
||||
e.target.value === "all" ? "all" : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
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 排大纲"}
|
||||
<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">
|
||||
@@ -145,12 +205,12 @@ export function OutlineEditor({
|
||||
action={
|
||||
<Button
|
||||
disabled={generating}
|
||||
onClick={() => void generate(project.id, volume)}
|
||||
onClick={onGenerateClick}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{generating ? "排大纲中…" : "AI 排大纲"}
|
||||
{generateLabel}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user