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} +
+
+ +
+