fix(web): 大纲生成只替换目标卷+重排确认+窗口徽标用lucide + 伏笔看板中文化/单一登记入口/Field表单
This commit is contained in:
@@ -62,15 +62,17 @@ export function ForeshadowBoard({
|
|||||||
title="伏笔看板"
|
title="伏笔看板"
|
||||||
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
|
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
|
||||||
actions={
|
actions={
|
||||||
|
// registerOpen 时隐藏头部入口,避免与展开的表单重复(全局只一个 RegisterForm)。
|
||||||
|
registerOpen ? undefined : (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setRegisterOpen(true)}
|
onClick={() => setRegisterOpen(true)}
|
||||||
disabled={registerOpen}
|
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
登记伏笔
|
登记伏笔
|
||||||
</Button>
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{registerOpen ? (
|
{registerOpen ? (
|
||||||
@@ -90,11 +92,16 @@ export function ForeshadowBoard({
|
|||||||
title="还没有伏笔"
|
title="还没有伏笔"
|
||||||
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
|
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
|
||||||
action={
|
action={
|
||||||
<RegisterForm
|
registerOpen ? undefined : (
|
||||||
projectId={project.id}
|
<Button
|
||||||
busy={busy}
|
onClick={() => setRegisterOpen(true)}
|
||||||
onRegister={register}
|
variant="primary"
|
||||||
/>
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
登记伏笔
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
/>
|
/>
|
||||||
@@ -138,16 +145,15 @@ function ForeshadowBoardSummary({
|
|||||||
className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
|
className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||||
>
|
>
|
||||||
{LANES.map((status) => (
|
{LANES.map((status) => (
|
||||||
<Card key={status} className="px-3 py-2">
|
<Card key={status} data-status={status} className="px-3 py-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Badge variant={SUMMARY_VARIANTS[status]}>
|
<Badge variant={SUMMARY_VARIANTS[status]}>
|
||||||
{LANE_LABELS[status]}
|
{LANE_LABELS[status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-mono text-sm text-ink">{counts[status]}</span>
|
<span className="font-mono text-sm text-ink">{counts[status]}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] text-ink-soft">
|
<p className="mt-1 text-2xs text-ink-soft">
|
||||||
<span className="font-mono">{status}</span>
|
占比{" "}
|
||||||
{" · "}
|
|
||||||
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
|
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ export function KanbanColumn({
|
|||||||
const overdue = status === "OVERDUE";
|
const overdue = status === "OVERDUE";
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
data-status={status}
|
||||||
className="flex min-w-0 flex-col rounded border border-line bg-bg"
|
className="flex min-w-0 flex-col rounded border border-line bg-bg"
|
||||||
aria-label={`${status} 泳道`}
|
aria-label={`${LANE_LABELS[status]} 泳道`}
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
|
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
|
||||||
@@ -44,7 +45,6 @@ export function KanbanColumn({
|
|||||||
{LANE_LABELS[status]}
|
{LANE_LABELS[status]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-mono text-[10px] text-ink-soft">{status}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs">{items.length}</span>
|
<span className="font-mono text-xs">{items.length}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
import { TextInput } from "@/components/ui/TextInput";
|
import { TextInput } from "@/components/ui/TextInput";
|
||||||
import { cardClass } from "@/lib/ui/variants";
|
import { cardClass } from "@/lib/ui/variants";
|
||||||
import type { RegisterInput } from "@/lib/foreshadow/board";
|
import type { RegisterInput } from "@/lib/foreshadow/board";
|
||||||
@@ -71,8 +73,9 @@ export function RegisterForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSubmit =
|
const missingRequired =
|
||||||
form.code.trim().length > 0 && form.title.trim().length > 0 && !busy;
|
form.code.trim().length === 0 || form.title.trim().length === 0;
|
||||||
|
const canSubmit = !missingRequired && !busy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -83,59 +86,53 @@ export function RegisterForm({
|
|||||||
className={cardClass("p-4")}
|
className={cardClass("p-4")}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<FormSlot label="代号 *" id="f-code">
|
<Field label="代号" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="f-code"
|
|
||||||
value={form.code}
|
value={form.code}
|
||||||
onChange={(e) => set("code", e.target.value)}
|
onChange={(e) => set("code", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormSlot>
|
</Field>
|
||||||
<FormSlot label="标题 *" id="f-title" span2>
|
<Field label="标题" required className="col-span-2">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="f-title"
|
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={(e) => set("title", e.target.value)}
|
onChange={(e) => set("title", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormSlot>
|
</Field>
|
||||||
<FormSlot label="埋设章" id="f-planted">
|
<Field label="埋设章">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="f-planted"
|
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={form.plantedAt}
|
value={form.plantedAt}
|
||||||
onChange={(e) => set("plantedAt", e.target.value)}
|
onChange={(e) => set("plantedAt", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormSlot>
|
</Field>
|
||||||
<FormSlot label="回收窗口起" id="f-from">
|
<Field label="回收窗口起">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="f-from"
|
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={form.expectedCloseFrom}
|
value={form.expectedCloseFrom}
|
||||||
onChange={(e) => set("expectedCloseFrom", e.target.value)}
|
onChange={(e) => set("expectedCloseFrom", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormSlot>
|
</Field>
|
||||||
<FormSlot label="回收窗口止" id="f-to">
|
<Field label="回收窗口止">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="f-to"
|
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={form.expectedCloseTo}
|
value={form.expectedCloseTo}
|
||||||
onChange={(e) => set("expectedCloseTo", e.target.value)}
|
onChange={(e) => set("expectedCloseTo", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormSlot>
|
</Field>
|
||||||
<FormSlot label="重要度" id="f-imp">
|
<Field label="重要度">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="f-imp"
|
|
||||||
value={form.importance}
|
value={form.importance}
|
||||||
onChange={(e) => set("importance", e.target.value)}
|
onChange={(e) => set("importance", e.target.value)}
|
||||||
placeholder="主线/支线"
|
placeholder="主线/支线"
|
||||||
/>
|
/>
|
||||||
</FormSlot>
|
</Field>
|
||||||
<FormSlot label="线索内容" id="f-content" span3>
|
<Field label="线索内容" className="col-span-2 sm:col-span-3">
|
||||||
<TextInput
|
<TextArea
|
||||||
id="f-content"
|
rows={2}
|
||||||
value={form.content}
|
value={form.content}
|
||||||
onChange={(e) => set("content", e.target.value)}
|
onChange={(e) => set("content", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormSlot>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -146,38 +143,17 @@ export function RegisterForm({
|
|||||||
>
|
>
|
||||||
{busy ? "登记中…" : "登记"}
|
{busy ? "登记中…" : "登记"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={() => setOpen(false)} variant="secondary" size="sm">
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
|
{missingRequired && !busy ? (
|
||||||
|
<span className="text-xs text-ink-soft">先填代号与标题</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormSlotProps {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
span2?: boolean;
|
|
||||||
span3?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormSlot({ label, id, span2, span3, children }: FormSlotProps) {
|
|
||||||
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
|
|
||||||
return (
|
|
||||||
<div className={span}>
|
|
||||||
<label htmlFor={id} className="block text-xs text-ink-soft">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<div className="mt-0.5">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toInt(raw: string): number | null {
|
function toInt(raw: string): number | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (trimmed.length === 0) return null;
|
if (trimmed.length === 0) return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PenLine } from "lucide-react";
|
import { Flag, PenLine } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import type { OutlineChapterView } from "@/lib/api/types";
|
import type { OutlineChapterView } from "@/lib/api/types";
|
||||||
@@ -16,7 +16,7 @@ interface OutlineChapterRowProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单章大纲行(UX §6.7):章号 + beats + 伏笔窗口徽标(⚑)+ 「写此章」入口。
|
// 单章大纲行(UX §6.7):章号 + beats + 伏笔窗口徽标(lucide Flag 图标)+ 「写此章」入口。
|
||||||
// 接近回收窗口的徽标用琥珀 + 「可回收」提示(a11y:图标 + 文案,不单靠色)。
|
// 接近回收窗口的徽标用琥珀 + 「可回收」提示(a11y:图标 + 文案,不单靠色)。
|
||||||
export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps) {
|
export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps) {
|
||||||
const windows = chapter.foreshadow_windows ?? [];
|
const windows = chapter.foreshadow_windows ?? [];
|
||||||
@@ -41,6 +41,7 @@ export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps
|
|||||||
variant={close ? "warning" : "accent"}
|
variant={close ? "warning" : "accent"}
|
||||||
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
|
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
|
||||||
>
|
>
|
||||||
|
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||||
{windowBadgeLabel(w)}
|
{windowBadgeLabel(w)}
|
||||||
{close ? " · 可回收" : ""}
|
{close ? " · 可回收" : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -32,15 +32,52 @@ export function OutlineEditor({
|
|||||||
initialChapters,
|
initialChapters,
|
||||||
}: OutlineEditorProps) {
|
}: OutlineEditorProps) {
|
||||||
const { chapters, status, error, generate } = useOutline(initialChapters);
|
const { chapters, status, error, generate } = useOutline(initialChapters);
|
||||||
|
// 生成目标卷与「正在看的卷」共用一个心智:选具体卷即把生成目标设为该卷。
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
// 视图卷过滤("全部" 或某卷)——只影响展示,与生成的目标卷 `volume` 解耦。
|
|
||||||
const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
|
const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
|
||||||
|
// 「重新排大纲」破坏性覆盖的两段式确认(仅目标卷已有章节时出现)。
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
|
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
|
||||||
const volumes = useMemo(
|
const volumes = useMemo(
|
||||||
() => groupByVolume(filterByViewVolume(chapters, viewVolume)),
|
() => groupByVolume(filterByViewVolume(chapters, viewVolume)),
|
||||||
[chapters, viewVolume],
|
[chapters, viewVolume],
|
||||||
);
|
);
|
||||||
const generating = status === "generating";
|
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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -53,26 +90,25 @@ export function OutlineEditor({
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="大纲"
|
title="大纲"
|
||||||
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
|
description="按卷组织章节节拍,写作台会把当前章节拍注入生成上下文。"
|
||||||
actions={
|
/>
|
||||||
<>
|
|
||||||
|
{/* 卷/视图/生成控件独立成全宽工具栏行(窄屏自动换行,不用脆弱的 ml-auto)。 */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
{availableVolumes.length > 0 ? (
|
{availableVolumes.length > 0 ? (
|
||||||
<label
|
<label
|
||||||
htmlFor="view-vol"
|
htmlFor="view-vol"
|
||||||
className="ml-auto text-xs text-ink-soft"
|
className="flex items-center gap-1 text-xs text-ink-soft"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
<Select
|
<Select
|
||||||
id="view-vol"
|
id="view-vol"
|
||||||
value={viewVolume === "all" ? "all" : String(viewVolume)}
|
value={viewVolume === "all" ? "all" : String(viewVolume)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setViewVolume(
|
onViewVolumeChange(
|
||||||
e.target.value === "all"
|
e.target.value === "all" ? "all" : Number(e.target.value),
|
||||||
? "all"
|
|
||||||
: Number(e.target.value),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
controlSize="sm"
|
controlSize="sm"
|
||||||
className="ml-1"
|
|
||||||
>
|
>
|
||||||
<option value="all">全部</option>
|
<option value="all">全部</option>
|
||||||
{availableVolumes.map((v) => (
|
{availableVolumes.map((v) => (
|
||||||
@@ -83,33 +119,57 @@ export function OutlineEditor({
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{availableVolumes.length > 0 ? (
|
||||||
|
<span className="ml-1 h-4 w-px bg-line" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<label
|
<label
|
||||||
htmlFor="vol"
|
htmlFor="vol"
|
||||||
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
|
className="flex items-center gap-1 text-xs text-ink-soft"
|
||||||
>
|
>
|
||||||
卷号
|
生成到 卷
|
||||||
</label>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="vol"
|
id="vol"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) =>
|
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||||
setVolume(Math.max(1, Number(e.target.value) || 1))
|
|
||||||
}
|
|
||||||
controlSize="sm"
|
controlSize="sm"
|
||||||
className="w-16"
|
className="w-16"
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
onClick={() => void generate(project.id, volume)}
|
onClick={onGenerateClick}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
{generating ? "排大纲中…" : "AI 排大纲"}
|
{generateLabel}
|
||||||
</Button>
|
</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 ? (
|
{error ? (
|
||||||
<StatusNote variant="danger" title="大纲生成失败" className="mb-4">
|
<StatusNote variant="danger" title="大纲生成失败" className="mb-4">
|
||||||
@@ -145,12 +205,12 @@ export function OutlineEditor({
|
|||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
onClick={() => void generate(project.id, volume)}
|
onClick={onGenerateClick}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
{generating ? "排大纲中…" : "AI 排大纲"}
|
{generateLabel}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -83,17 +83,21 @@ describe("filterByViewVolume", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("windowBadgeLabel", () => {
|
describe("windowBadgeLabel", () => {
|
||||||
it("renders a range, half-open, or bare badge", () => {
|
it("renders a range, half-open, or bare badge without any glyph", () => {
|
||||||
expect(
|
expect(
|
||||||
windowBadgeLabel(win({ expected_close_from: 40, expected_close_to: 60 })),
|
windowBadgeLabel(win({ expected_close_from: 40, expected_close_to: 60 })),
|
||||||
).toBe("⚑ F-012 (40-60)");
|
).toBe("F-012 (40-60)");
|
||||||
expect(windowBadgeLabel(win({ expected_close_to: 60 }))).toBe(
|
expect(windowBadgeLabel(win({ expected_close_to: 60 }))).toBe("F-012 (≤60)");
|
||||||
"⚑ F-012 (≤60)",
|
|
||||||
);
|
|
||||||
expect(windowBadgeLabel(win({ expected_close_from: 40 }))).toBe(
|
expect(windowBadgeLabel(win({ expected_close_from: 40 }))).toBe(
|
||||||
"⚑ F-012 (≥40)",
|
"F-012 (≥40)",
|
||||||
);
|
);
|
||||||
expect(windowBadgeLabel(win({}))).toBe("⚑ F-012");
|
expect(windowBadgeLabel(win({}))).toBe("F-012");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never embeds the raw ⚑ glyph (icon comes from the render layer)", () => {
|
||||||
|
expect(
|
||||||
|
windowBadgeLabel(win({ expected_close_from: 40, expected_close_to: 60 })),
|
||||||
|
).not.toContain("⚑");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,17 @@ export function filterByViewVolume(
|
|||||||
return all.filter((ch) => ch.volume === view);
|
return all.filter((ch) => ch.volume === view);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 伏笔窗口徽标文案:`⚑ F-012 (40-60)` / `⚑ F-012 (≤60)` / `⚑ F-012`。
|
// 伏笔窗口徽标文案(纯文本,旗标图标由渲染层的 lucide Flag 承载,不掺字形):
|
||||||
|
// `F-012 (40-60)` / `F-012 (≤60)` / `F-012 (≥40)` / `F-012`。
|
||||||
export function windowBadgeLabel(window: ForeshadowWindowView): string {
|
export function windowBadgeLabel(window: ForeshadowWindowView): string {
|
||||||
const from = window.expected_close_from;
|
const from = window.expected_close_from;
|
||||||
const to = window.expected_close_to;
|
const to = window.expected_close_to;
|
||||||
if (typeof from === "number" && typeof to === "number") {
|
if (typeof from === "number" && typeof to === "number") {
|
||||||
return `⚑ ${window.code} (${from}-${to})`;
|
return `${window.code} (${from}-${to})`;
|
||||||
}
|
}
|
||||||
if (typeof to === "number") return `⚑ ${window.code} (≤${to})`;
|
if (typeof to === "number") return `${window.code} (≤${to})`;
|
||||||
if (typeof from === "number") return `⚑ ${window.code} (≥${from})`;
|
if (typeof from === "number") return `${window.code} (≥${from})`;
|
||||||
return `⚑ ${window.code}`;
|
return window.code;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 接近回收:本章号落在窗口 [from,to] 内(提示「本章可回收」,UX §6.7 ⚑ 可回收)。
|
// 接近回收:本章号落在窗口 [from,to] 内(提示「本章可回收」,UX §6.7 ⚑ 可回收)。
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const toast = vi.fn();
|
|||||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
function makeChapter(no: number): OutlineChapterView {
|
function makeChapter(no: number, volume = 1): OutlineChapterView {
|
||||||
return { no, volume: 1 };
|
return { no, volume };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useOutline", () => {
|
describe("useOutline", () => {
|
||||||
@@ -51,6 +51,25 @@ describe("useOutline", () => {
|
|||||||
expect(toast).toHaveBeenCalledWith("大纲已生成", "success");
|
expect(toast).toHaveBeenCalledWith("大纲已生成", "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("生成只替换目标卷,保留其它卷的已存章节", async () => {
|
||||||
|
const initial = [makeChapter(1, 1), makeChapter(2, 1), makeChapter(80, 2)];
|
||||||
|
const regenerated = [makeChapter(1, 1), makeChapter(2, 1), makeChapter(3, 1)];
|
||||||
|
post.mockResolvedValue({ data: { chapters: regenerated }, error: null });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOutline(initial));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.generate("p1", 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 卷 2 的第 80 章不被整列覆盖;卷 1 替换为新生成的三章。
|
||||||
|
expect(result.current.chapters).toEqual([
|
||||||
|
makeChapter(80, 2),
|
||||||
|
makeChapter(1, 1),
|
||||||
|
makeChapter(2, 1),
|
||||||
|
makeChapter(3, 1),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("data.chapters 为空时章节回退为空数组", async () => {
|
it("data.chapters 为空时章节回退为空数组", async () => {
|
||||||
post.mockResolvedValue({ data: { chapters: null }, error: null });
|
post.mockResolvedValue({ data: { chapters: null }, error: null });
|
||||||
const { result } = renderHook(() => useOutline([]));
|
const { result } = renderHook(() => useOutline([]));
|
||||||
|
|||||||
@@ -52,7 +52,12 @@ export function useOutline(initial: OutlineChapterView[]): UseOutline {
|
|||||||
toast(friendly.text, "error");
|
toast(friendly.text, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setChapters(data.chapters ?? []);
|
// 只替换目标卷的条目,保留其它卷(「AI 排大纲」是单卷操作,整列覆盖会误删别卷)。
|
||||||
|
const generated = data.chapters ?? [];
|
||||||
|
setChapters((prev) => [
|
||||||
|
...prev.filter((ch) => ch.volume !== volume),
|
||||||
|
...generated,
|
||||||
|
]);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
toast("大纲已生成", "success");
|
toast("大纲已生成", "success");
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user