feat(ux): T4-b 前端 — 本章指令框 + 风格快捷预设传入 draft 生成

新 lib/workbench/directive.ts (STYLE_PRESETS + composeDirective 纯函数+测);useDraftStream.start 加 directive 参数(非空时 POST JSON body);Workbench 加「本章指令」textarea + 风格预设 chips,写本章时 composeDirective 传入。TDD: directive 测。前端门禁绿。docs(progress): 记 Tier4 完成。
This commit is contained in:
Yaojia Wang
2026-06-20 18:27:47 +02:00
parent 2155193cdc
commit cc0aabdd9a
5 changed files with 214 additions and 49 deletions

View File

@@ -10,6 +10,7 @@ import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream";
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor";
@@ -32,6 +33,9 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
const [text, setText] = useState(initialText);
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]);
const autosave = useAutosave(project.id, chapterNo);
const stream = useDraftStream();
const lastStreamText = useRef("");
@@ -52,7 +56,17 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
}, [stream.state.phase, stream.state.text, autosave]);
const onWrite = (): void => {
void stream.start(project.id, chapterNo);
void stream.start(
project.id,
chapterNo,
composeDirective(directive, presetIds),
);
};
const togglePreset = (id: string): void => {
setPresetIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const onEditorChange = (value: string): void => {
@@ -74,6 +88,12 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
<ChapterList currentChapterNo={chapterNo} />
<section className="flex min-w-0 flex-col bg-bg">
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
presetIds={presetIds}
onTogglePreset={togglePreset}
/>
<div className="flex-1 overflow-auto px-6 py-8">
<Editor
value={text}
@@ -120,6 +140,62 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
);
}
interface DirectivePanelProps {
directive: string;
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
}
// 本章指令面板T4-b可折叠的 textarea + 风格快捷预设 chips。
// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成。
function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
}: DirectivePanelProps) {
return (
<details className="border-b border-line bg-panel px-6 py-2">
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar">
</summary>
<div className="mt-2 space-y-2">
<label className="block">
<span className="sr-only"></span>
<textarea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink placeholder:text-ink-soft focus:border-cinnabar focus:outline-none"
/>
</label>
<div className="flex flex-wrap gap-2">
{STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onTogglePreset(preset.id)}
className={
active
? "rounded-full border border-cinnabar bg-cinnabar px-3 py-1 text-xs text-panel"
: "rounded-full border border-line px-3 py-1 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
}
>
{preset.label}
</button>
);
})}
</div>
</div>
</details>
);
}
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({
streamError,