From 2f0b84191aed605f092a32326dc474ec326aa2f2 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 7 Jul 2026 18:26:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=86=85=E3=80=8C=E6=B6=A6=E8=89=B2=20/=20=E5=86=8D=E6=B2=9F?= =?UTF-8?q?=E9=80=9A=E3=80=8D=E2=80=94=E2=80=94=E9=80=89=E6=AE=B5=E5=9B=9E?= =?UTF-8?q?=E7=82=89=20+=20=E8=BF=BD=E5=8A=A0=E6=84=8F=E8=A7=81=E8=BF=AD?= =?UTF-8?q?=E4=BB=A3=E5=87=BA=E7=89=88=E6=9C=AC=E6=A0=88=EF=BC=8C=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1(写作工作台重构):编辑器选中一段即可「润色选段」,复用同步 /refine 回炉 (只读、不写库,守不变量 #3);不满意可「再沟通」——以上一版产出为输入 + 作者新意见 再改一版,形成版本栈(useRefine,已单测)。接受回填按内容重锚落回草稿,原文已变则 提示重选、绝不盲替换(applyRefinement,已单测)。Editor 上报选区,结果以内联卡片展示。 --- apps/web/components/workbench/Editor.tsx | 23 ++- apps/web/components/workbench/RefinePanel.tsx | 133 ++++++++++++++++++ apps/web/components/workbench/Workbench.tsx | 58 +++++++- apps/web/lib/workbench/refineApply.test.ts | 37 +++++ apps/web/lib/workbench/refineApply.ts | 24 ++++ apps/web/lib/workbench/useRefine.test.ts | 131 +++++++++++++++++ apps/web/lib/workbench/useRefine.ts | 112 +++++++++++++++ 7 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/workbench/RefinePanel.tsx create mode 100644 apps/web/lib/workbench/refineApply.test.ts create mode 100644 apps/web/lib/workbench/refineApply.ts create mode 100644 apps/web/lib/workbench/useRefine.test.ts create mode 100644 apps/web/lib/workbench/useRefine.ts diff --git a/apps/web/components/workbench/Editor.tsx b/apps/web/components/workbench/Editor.tsx index 3ff184c..0f9a20a 100644 --- a/apps/web/components/workbench/Editor.tsx +++ b/apps/web/components/workbench/Editor.tsx @@ -4,17 +4,37 @@ import { useEffect, useRef } from "react"; import { cn, focusRing, proseBody } from "@/lib/ui/variants"; +export interface EditorSelection { + start: number; + end: number; + text: string; +} + interface EditorProps { value: string; onChange: (value: string) => void; streaming: boolean; + // 选区变化上报(供「润色选段」等选区级 AI 动作取材)。空选区也会上报(start===end)。 + onSelectionChange?: (selection: EditorSelection) => void; } // 中栏正文编辑器:宋体、720px 居中、行高 1.9(UX §2.3 / §6.3)。 // 流式中显示朱砂光标提示(prefers-reduced-motion 下不闪烁,见 globals.css)。 -export function Editor({ value, onChange, streaming }: EditorProps) { +export function Editor({ + value, + onChange, + streaming, + onSelectionChange, +}: EditorProps) { const ref = useRef(null); + const reportSelection = (el: HTMLTextAreaElement): void => { + if (!onSelectionChange) return; + const start = el.selectionStart; + const end = el.selectionEnd; + onSelectionChange({ start, end, text: value.slice(start, end) }); + }; + // 自适应高度:textarea 高度=内容高度(overflow-hidden 不内部滚动),由外层写作区 // 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。 // min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。 @@ -35,6 +55,7 @@ export function Editor({ value, onChange, streaming }: EditorProps) { id="chapter-editor" value={value} onChange={(e) => onChange(e.target.value)} + onSelect={(e) => reportSelection(e.currentTarget)} readOnly={streaming} aria-busy={streaming} placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。" diff --git a/apps/web/components/workbench/RefinePanel.tsx b/apps/web/components/workbench/RefinePanel.tsx new file mode 100644 index 0000000..5a31a1c --- /dev/null +++ b/apps/web/components/workbench/RefinePanel.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Check, MessageSquarePlus, RotateCcw, X } from "lucide-react"; + +import { ThinkingIndicator } from "@/components/ThinkingIndicator"; +import { Button } from "@/components/ui/Button"; +import { SectionHeader } from "@/components/ui/SectionHeader"; +import { StatusNote } from "@/components/ui/StatusNote"; +import { TextArea } from "@/components/ui/TextArea"; +import { useRefine } from "@/lib/workbench/useRefine"; + +interface RefinePanelProps { + projectId: string; + chapterNo: number; + // 选中原文;打开即对其回炉一次。 + original: string; + // 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。 + onAccept: (refined: string) => void; + onClose: () => void; +} + +// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版, +// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3),accept 才动草稿。 +export function RefinePanel({ + projectId, + chapterNo, + original, + onAccept, + onClose, +}: RefinePanelProps) { + const { status, versions, latest, refine, recommunicate } = useRefine(); + const [instruction, setInstruction] = useState(""); + const ranRef = useRef(false); + + // 打开即对选段回炉一次(仅一次,避免重复请求)。 + useEffect(() => { + if (ranRef.current) return; + ranRef.current = true; + void refine(projectId, chapterNo, original); + }, [projectId, chapterNo, original, refine]); + + const busy = status === "refining"; + + const onRecommunicate = (): void => { + const trimmed = instruction.trim(); + if (!trimmed || busy) return; + setInstruction(""); + void recommunicate(projectId, chapterNo, trimmed); + }; + + return ( +
+
+ + +
+ +
+
+
原文
+

{original}

+
+
+
+ 改写 + {versions.length > 0 ? ( + + 第 {versions.length} 版 + + ) : null} +
+ {busy && !latest ? ( + + ) : latest ? ( +

{latest.refined}

+ ) : status === "error" ? ( + + + + ) : null} +
+
+ +
+