Files
writer-work-flow/apps/web/components/chain/ChainStarter.tsx
Yaojia Wang 1a402f5ccc feat(web): F1/F2/F3 前端——拆书入库为规则 / 续写式链选择 / 模板库
- gen:api 纳入 /templates、chain continue_volume、teardown rules ingest 端点
- F1:GeneratorRunner 识别拆书为单对象入库(整 preview 作一条 teardown),
  ingestTable(BookTeardownResult)=rules,buildIngestRequest 组装 teardown 体,
  按 isSingleObjectIngest 隐藏勾选框、改文案「入库为规则」
- F2:ChainStarter 加链类型单选(draft_volume 从头写 / continue_volume 续写),
  chain_key 作 path 参传给 run(CHAIN_KINDS 对齐后端 SUPPORTED_CHAINS)
- F3:模板库页 app/templates + 全局 nav 入口 + TemplatesManager(列/建/删,乐观更新回滚);
  GeneratorRunner 加 TemplateFiller「从模板填入」(body 填进 brief/text,纯前端)
- vitest 覆盖 templates/ingest teardown/templateFillTarget/CHAIN_KINDS 纯逻辑
2026-06-23 20:29:48 +02:00

112 lines
3.8 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 { useState } from "react";
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
interface ChainStarterProps {
// 发起一条链(链类型 + 起始章 + 章数);父层负责 POST run + 轮询。
onStart: (chainKey: ChainKind, startChapterNo: number, count: number) => void;
// 链正在运行/等待裁决时禁用(避免重复发起)。
disabled: boolean;
}
const DEFAULT_START = 1;
const DEFAULT_COUNT = 3;
const MAX_COUNT = 50;
const DEFAULT_CHAIN: ChainKind = "draft_volume";
// 链发起表单(净新):选起始章号 + 连续写几章 → 调 onStart。
// count 1..50(对齐后端 ChainRunRequest Field 约束;前端先拦一道,越界后端 422 兜底)。
export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
const [chainKey, setChainKey] = useState<ChainKind>(DEFAULT_CHAIN);
const [start, setStart] = useState(String(DEFAULT_START));
const [count, setCount] = useState(String(DEFAULT_COUNT));
const startNo = Number.parseInt(start, 10);
const countNo = Number.parseInt(count, 10);
const valid =
Number.isInteger(startNo) &&
startNo >= 1 &&
Number.isInteger(countNo) &&
countNo >= 1 &&
countNo <= MAX_COUNT;
return (
<form
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
aria-label="发起多章链"
onSubmit={(e) => {
e.preventDefault();
if (valid && !disabled) onStart(chainKey, startNo, countNo);
}}
>
<div>
<h2 className="font-serif text-lg text-ink"></h2>
<p className="mt-1 text-sm text-ink-soft">
</p>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-4">
{CHAIN_KINDS.map((kind) => (
<label
key={kind.key}
className="flex items-start gap-2 text-sm text-ink"
>
<input
type="radio"
name="chain-kind"
value={kind.key}
checked={chainKey === kind.key}
onChange={() => setChainKey(kind.key)}
className="mt-1"
aria-label={kind.label}
/>
<span>
<span className="block">{kind.label}</span>
<span className="block text-xs text-ink-soft">
{kind.description}
</span>
</span>
</label>
))}
</div>
</fieldset>
<div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft">
<input
type="number"
min={1}
value={start}
onChange={(e) => setStart(e.target.value)}
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="起始章号"
/>
</label>
<label className="block text-sm text-ink-soft">
1..{MAX_COUNT}
<input
type="number"
min={1}
max={MAX_COUNT}
value={count}
onChange={(e) => setCount(e.target.value)}
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="连续章数"
/>
</label>
</div>
<button
type="submit"
disabled={disabled || !valid}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{disabled ? "运行中…" : "发起多章链"}
</button>
</form>
);
}