feat(ui): 工作台精修 + 拆分(P3-3..7)
- Workbench.tsx 937→467 行,抽出 AiToolbar/WorkbenchToolbar/MobileContextBar - P3-3 AI 工具条分组留白 + 眉题 + 主 CTA 凸显 - P3-4 专注写作模式(useFocusMode hook+vitest,隐藏侧栏加宽正文)+ 左双栏面色区分 - P3-5 右栏本章参考小标题去衬线(font-sans) + 空态引导(无标题保 h2→h3 大纲) - P3-6 底栏信息/操作/状态三区 + TOC 当前章高亮;TOC 状态点因无每章状态数据跳过 - P3-7 展示层流式跟随滚动(不触碰 SSE 逻辑,尊重 reduced-motion) - AppShell 加可选 hideNav(加法式,默认 false)
This commit is contained in:
@@ -18,6 +18,9 @@ interface AppShellProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
// 当前激活的项目级入口键(用于高亮)。
|
// 当前激活的项目级入口键(用于高亮)。
|
||||||
activeNav?: ActiveNav;
|
activeNav?: ActiveNav;
|
||||||
|
// 专注写作模式(P3-4):隐藏桌面左侧图标导航以加宽正文;顶栏汉堡/命令面板仍可达导航。
|
||||||
|
// 缺省 false → 其余页面外观不变(加法式可选项)。
|
||||||
|
hideNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。
|
// 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。
|
||||||
@@ -27,6 +30,7 @@ export function AppShell({
|
|||||||
subtitle,
|
subtitle,
|
||||||
projectId,
|
projectId,
|
||||||
activeNav,
|
activeNav,
|
||||||
|
hideNav = false,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -80,7 +84,7 @@ export function AppShell({
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
{hideNav ? null : <LeftNav projectId={projectId} activeNav={activeNav} />}
|
||||||
<main
|
<main
|
||||||
id="main"
|
id="main"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|||||||
309
apps/web/components/workbench/AiToolbar.tsx
Normal file
309
apps/web/components/workbench/AiToolbar.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
HelpCircle,
|
||||||
|
PenLine,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
Square,
|
||||||
|
Wand2,
|
||||||
|
WrapText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
|
import { PLOT_PRESETS, STYLE_PRESETS, type Preset } from "@/lib/workbench/directive";
|
||||||
|
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||||
|
|
||||||
|
interface DirectivePanelProps {
|
||||||
|
directive: string;
|
||||||
|
onDirectiveChange: (value: string) => void;
|
||||||
|
presetIds: readonly string[];
|
||||||
|
onTogglePreset: (id: string) => void;
|
||||||
|
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
|
||||||
|
defaultOpen: boolean;
|
||||||
|
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
|
||||||
|
streaming: boolean;
|
||||||
|
onWrite: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本章写作指令面板(P0-2 / P3-3 提升):正文正上方常驻的「和 AI 说话」输入条。
|
||||||
|
// 眉题「和 AI 说话」+ 主标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;
|
||||||
|
// 主 CTA「写本章」突出(md 尺寸、独占右侧),空章默认展开露出输入框。
|
||||||
|
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
|
||||||
|
export function DirectivePanel({
|
||||||
|
directive,
|
||||||
|
onDirectiveChange,
|
||||||
|
presetIds,
|
||||||
|
onTogglePreset,
|
||||||
|
defaultOpen,
|
||||||
|
streaming,
|
||||||
|
onWrite,
|
||||||
|
onStop,
|
||||||
|
}: DirectivePanelProps) {
|
||||||
|
const panelId = useId();
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
|
||||||
|
return (
|
||||||
|
<section className="border-b border-line bg-panel px-4 py-4 sm:px-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Eyebrow className="mb-1">和 AI 说话</Eyebrow>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
className={`group flex min-w-0 cursor-pointer items-center gap-2 rounded text-left font-serif text-title-md text-ink transition-colors duration-fast ease-standard hover:text-cinnabar ${focusRing}`}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 shrink-0 text-muted-soft transition-transform duration-fast ease-standard group-hover:text-cinnabar ${
|
||||||
|
open ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="truncate">告诉 AI 这章想怎么写</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{activeCount > 0 ? (
|
||||||
|
<span className="hidden rounded-full border border-line bg-bg px-2.5 py-0.5 text-2xs text-ink-soft sm:inline">
|
||||||
|
{activeCount} 项指令
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{/* 常驻主 CTA = 写本章:始终可见(折叠时也在),md 尺寸凸显为本条主动作,逐字流式写进正文。 */}
|
||||||
|
{streaming ? (
|
||||||
|
<Button onClick={onStop} variant="danger" size="md">
|
||||||
|
<Square className="h-4 w-4" aria-hidden="true" />
|
||||||
|
停
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onWrite} variant="primary" size="md">
|
||||||
|
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||||
|
写本章
|
||||||
|
<span className="text-2xs font-normal text-panel/75">
|
||||||
|
整章·从头写
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<HitlHelp />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-caption text-muted-soft">
|
||||||
|
AI 给草稿,你决定采不采用,原文永远你说了算。
|
||||||
|
</p>
|
||||||
|
{open ? (
|
||||||
|
<div id={panelId} className="mt-4 space-y-4">
|
||||||
|
<SectionHeader
|
||||||
|
title="你的写作要求"
|
||||||
|
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
|
||||||
|
/>
|
||||||
|
<PresetGroup
|
||||||
|
label="文风"
|
||||||
|
presets={STYLE_PRESETS}
|
||||||
|
presetIds={presetIds}
|
||||||
|
onToggle={onTogglePreset}
|
||||||
|
/>
|
||||||
|
<PresetGroup
|
||||||
|
label="剧情需求"
|
||||||
|
presets={PLOT_PRESETS}
|
||||||
|
presetIds={presetIds}
|
||||||
|
onToggle={onTogglePreset}
|
||||||
|
/>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-sm text-ink-soft">自己的要求</span>
|
||||||
|
<TextArea
|
||||||
|
value={directive}
|
||||||
|
onChange={(e) => onDirectiveChange(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="写点要求,AI 更懂你(留空则按大纲写)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{activeCount > 0 ? (
|
||||||
|
<StatusNote variant="info">
|
||||||
|
写本章时会把已选文风/剧情预设和你的要求合并进本次生成请求。
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiVerbsRowProps {
|
||||||
|
streaming: boolean;
|
||||||
|
onContinue: () => void;
|
||||||
|
onRefineSelection: () => void;
|
||||||
|
onRewrite: () => void;
|
||||||
|
onToolbox: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「或让 AI:」次级动作行(P1-1 / P3-3):常驻贴在中央输入条下方,把续写/润色/整章重写/工具箱
|
||||||
|
// 归为「同一个 AI 的其它用法」(act-on-chapter 与导航分家)。用较浅面色 + 更软分隔成组,
|
||||||
|
// 与上方主指令条拉开层级。每个动词带可见范围提示(P1-4)。生成中禁用(避免与流式写章并发)。
|
||||||
|
export function AiVerbsRow({
|
||||||
|
streaming,
|
||||||
|
onContinue,
|
||||||
|
onRefineSelection,
|
||||||
|
onRewrite,
|
||||||
|
onToolbox,
|
||||||
|
}: AiVerbsRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line-soft bg-surface-soft px-4 py-2.5 sm:px-6">
|
||||||
|
<span className="text-eyebrow uppercase tracking-wide text-muted-soft">
|
||||||
|
或让 AI
|
||||||
|
</span>
|
||||||
|
<VerbButton
|
||||||
|
icon={WrapText}
|
||||||
|
label="续写"
|
||||||
|
hint="接着往下写"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={onContinue}
|
||||||
|
/>
|
||||||
|
<VerbButton
|
||||||
|
icon={Wand2}
|
||||||
|
label="润色选段"
|
||||||
|
hint="选中段·打磨"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={onRefineSelection}
|
||||||
|
/>
|
||||||
|
<VerbButton
|
||||||
|
icon={RefreshCw}
|
||||||
|
label="整章重写"
|
||||||
|
hint="整章·基于现有重写"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={onRewrite}
|
||||||
|
/>
|
||||||
|
<VerbButton
|
||||||
|
icon={Sparkles}
|
||||||
|
label="工具箱"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={onToolbox}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerbButtonProps {
|
||||||
|
icon: typeof WrapText;
|
||||||
|
label: string;
|
||||||
|
// 可见(非 hover-only)范围提示:让作者点前就能预判对草稿的影响(P1-4)。工具箱无范围提示。
|
||||||
|
hint?: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
|
||||||
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach)——解释「和 AI 说话」的闭环。
|
||||||
|
// 按钮 + 轻量弹层,Esc / 点击外部关闭,还原焦点。
|
||||||
|
function HitlHelp() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const popId = useId();
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPointerDown = (e: PointerEvent): void => {
|
||||||
|
const target = e.target;
|
||||||
|
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={popId}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={buttonClass({
|
||||||
|
variant: "ghost",
|
||||||
|
size: "sm",
|
||||||
|
className: "gap-1 text-xs text-ink-soft",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
这是什么
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
id={popId}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="怎么和 AI 说话"
|
||||||
|
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded-lg border border-line bg-panel p-4 text-sm text-ink-soft shadow-paper"
|
||||||
|
>
|
||||||
|
<p className="mb-1 font-serif text-title-md text-ink">怎么和 AI 说话</p>
|
||||||
|
<p className="leading-6">
|
||||||
|
在这条写下要求,点「写本章」——AI 会把草稿逐字写进正文。草稿随时能改;
|
||||||
|
满意了去「审稿」验收。原文永远你说了算。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetGroupProps {
|
||||||
|
label: string;
|
||||||
|
presets: readonly Preset[];
|
||||||
|
presetIds: readonly string[];
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一组预设 chips(文风 / 剧情需求):点选 toggle,选中高亮。id 统一进 presetIds。
|
||||||
|
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-sm text-ink-soft">{label}</p>
|
||||||
|
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
|
||||||
|
{presets.map((preset) => {
|
||||||
|
const active = presetIds.includes(preset.id);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onToggle(preset.id)}
|
||||||
|
variant={active ? "outline" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Minus,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Sparkles,
|
||||||
|
Undo2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
|
||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
import { buttonClass } from "@/lib/ui/variants";
|
import { buttonClass } from "@/lib/ui/variants";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +44,25 @@ export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 窄栏小标题(P3-5):font-sans + eyebrow 字号/正字距(对齐 Eyebrow 眉题的视觉),
|
||||||
|
// 但保留 <h3> 语义(不降级为 <p>,守住标题大纲 a11y)。可带右侧行内控件(如步进器)。
|
||||||
|
function PanelLabel({
|
||||||
|
children,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h3 className="font-sans text-eyebrow uppercase tracking-wide text-muted-soft">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
{action ? <div className="shrink-0">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
|
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
|
||||||
const injection = useInjection(projectId, chapterNo);
|
const injection = useInjection(projectId, chapterNo);
|
||||||
const data = injection.data;
|
const data = injection.data;
|
||||||
@@ -43,21 +71,17 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<SectionHeader
|
<div className="flex items-center justify-between gap-2">
|
||||||
title="本章参考"
|
<h2 className="font-sans text-title-md font-medium text-ink">
|
||||||
action={
|
本章参考
|
||||||
injection.saving ? (
|
</h2>
|
||||||
<ThinkingIndicator
|
{injection.saving ? (
|
||||||
label="保存中"
|
<ThinkingIndicator label="保存中" className="text-xs text-ink-soft" />
|
||||||
className="text-xs text-ink-soft"
|
) : null}
|
||||||
/>
|
</div>
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="mt-4">
|
<section className="mt-5">
|
||||||
<SectionHeader
|
<PanelLabel
|
||||||
title="AI 写这章会参考"
|
|
||||||
action={
|
action={
|
||||||
data ? (
|
data ? (
|
||||||
<RecentStepper
|
<RecentStepper
|
||||||
@@ -67,10 +91,12 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
AI 写这章会参考
|
||||||
|
</PanelLabel>
|
||||||
|
|
||||||
{injection.loading ? (
|
{injection.loading ? (
|
||||||
<p className="mt-2 text-xs text-ink-soft">加载参考信息…</p>
|
<p className="mt-2 text-caption text-ink-soft">加载参考信息…</p>
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
// 首次加载失败:data 仍为空 → 满屏错误框 + 重试。
|
// 首次加载失败:data 仍为空 → 满屏错误框 + 重试。
|
||||||
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
|
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
|
||||||
@@ -113,11 +139,15 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selected.length === 0 ? (
|
{selected.length === 0 ? (
|
||||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
// 空态占位(不加标题,避免在 h3 子小节内注入 h2 破坏标题大纲)。
|
||||||
这一章 AI 还没有要参考的设定/角色。可在大纲里点名,或从下方「已排除」里恢复。
|
<div className="mt-2 flex flex-col items-center gap-2 py-4 text-center">
|
||||||
</p>
|
<Sparkles className="h-5 w-5 text-muted-soft" aria-hidden="true" />
|
||||||
|
<p className="max-w-xs text-caption leading-6 text-ink-soft">
|
||||||
|
这一章 AI 还没要参考的设定/角色。可在大纲里点名,或从下方「已排除」里恢复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
{selected.map((entity) => (
|
{selected.map((entity) => (
|
||||||
<EntityRow
|
<EntityRow
|
||||||
key={`${entity.kind}:${entity.name}`}
|
key={`${entity.kind}:${entity.name}`}
|
||||||
@@ -137,9 +167,9 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{excluded.length > 0 ? (
|
{excluded.length > 0 ? (
|
||||||
<div className="mt-3">
|
<div className="mt-4">
|
||||||
<SectionHeader title="已排除(本章不参考)" />
|
<PanelLabel>已排除(本章不参考)</PanelLabel>
|
||||||
<ul className="mt-1 space-y-1">
|
<ul className="mt-2 space-y-1">
|
||||||
{excluded.map((ref) => (
|
{excluded.map((ref) => (
|
||||||
<ExcludedRow
|
<ExcludedRow
|
||||||
key={`${ref.kind}:${ref.name}`}
|
key={`${ref.kind}:${ref.name}`}
|
||||||
@@ -153,11 +183,11 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6 border-t border-line-soft pt-5">
|
||||||
<SectionHeader
|
<PanelLabel>写完后检查</PanelLabel>
|
||||||
title="写完后检查"
|
<p className="mt-1.5 text-caption leading-6 text-ink-soft">
|
||||||
description="写完这章,AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。"
|
写完这章,AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。
|
||||||
/>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function ChapterList({
|
|||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="hidden shrink-0 border-r border-line bg-panel py-4 xl:block"
|
className="hidden shrink-0 border-r border-line bg-surface-soft py-4 xl:block"
|
||||||
aria-label="目录(已收起)"
|
aria-label="目录(已收起)"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -38,7 +38,7 @@ export function ChapterList({
|
|||||||
aria-expanded={false}
|
aria-expanded={false}
|
||||||
aria-label="展开目录"
|
aria-label="展开目录"
|
||||||
title="展开目录"
|
title="展开目录"
|
||||||
className={`mx-auto flex h-8 w-8 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
|
className={`mx-auto flex h-8 w-8 items-center justify-center rounded-md border border-line bg-panel text-ink-soft transition-colors duration-fast ease-standard hover:border-cinnabar hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
|
||||||
>
|
>
|
||||||
<PanelLeftOpen className="h-4 w-4" aria-hidden="true" />
|
<PanelLeftOpen className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -46,7 +46,7 @@ export function ChapterList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<aside className="hidden border-r border-line bg-panel py-4 xl:block">
|
<aside className="hidden border-r border-line bg-surface-soft py-4 xl:block">
|
||||||
<ChapterListContent
|
<ChapterListContent
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
@@ -73,7 +73,7 @@ export function ChapterListContent({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between px-4">
|
<div className="flex items-center justify-between px-4">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
<h2 className="font-sans text-eyebrow uppercase tracking-wide text-muted-soft">
|
||||||
目录
|
目录
|
||||||
</h2>
|
</h2>
|
||||||
{onCollapse ? (
|
{onCollapse ? (
|
||||||
@@ -82,7 +82,7 @@ export function ChapterListContent({
|
|||||||
onClick={onCollapse}
|
onClick={onCollapse}
|
||||||
aria-label="收起目录"
|
aria-label="收起目录"
|
||||||
title="收起目录"
|
title="收起目录"
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
|
className={`flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink-soft transition-colors duration-fast ease-standard hover:border-cinnabar hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
|
||||||
>
|
>
|
||||||
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
|
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -98,8 +98,8 @@ export function ChapterListContent({
|
|||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={
|
className={
|
||||||
active
|
active
|
||||||
? "flex flex-col gap-0.5 border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] px-4 py-2 text-sm text-cinnabar"
|
? `flex flex-col gap-0.5 border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] px-4 py-2 text-sm font-medium text-cinnabar transition-colors duration-fast ease-standard ${focusRing}`
|
||||||
: "flex flex-col gap-0.5 border-l-2 border-transparent px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
: `flex flex-col gap-0.5 border-l-2 border-transparent px-4 py-2 text-sm text-ink transition-colors duration-fast ease-standard hover:border-line-soft hover:bg-panel/60 hover:text-cinnabar ${focusRing}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|||||||
49
apps/web/components/workbench/MobileContextBar.tsx
Normal file
49
apps/web/components/workbench/MobileContextBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { PanelLeft, PanelRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
interface MobileContextBarProps {
|
||||||
|
chapterNo: number;
|
||||||
|
onOpenChapters: () => void;
|
||||||
|
onOpenAssistant: () => void;
|
||||||
|
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窄屏/中屏顶部上下文条:目录 / 本章参考经抽屉触达;≥xl 直显三栏、此条隐藏。
|
||||||
|
export function MobileContextBar({
|
||||||
|
chapterNo,
|
||||||
|
onOpenChapters,
|
||||||
|
onOpenAssistant,
|
||||||
|
chapterTriggerRef,
|
||||||
|
assistantTriggerRef,
|
||||||
|
}: MobileContextBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 xl:hidden">
|
||||||
|
<Button
|
||||||
|
ref={chapterTriggerRef}
|
||||||
|
onClick={onOpenChapters}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
目录
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
|
||||||
|
第 {chapterNo} 章
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
ref={assistantTriggerRef}
|
||||||
|
onClick={onOpenAssistant}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<PanelRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
助手
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useEffect, useId, useRef, useState, type RefObject } from "react";
|
|
||||||
import {
|
|
||||||
BookMarked,
|
|
||||||
ChevronDown,
|
|
||||||
ClipboardCheck,
|
|
||||||
FileText,
|
|
||||||
HelpCircle,
|
|
||||||
MessagesSquare,
|
|
||||||
PanelLeft,
|
|
||||||
PanelRight,
|
|
||||||
PenLine,
|
|
||||||
RefreshCw,
|
|
||||||
Sparkles,
|
|
||||||
Square,
|
|
||||||
Wand2,
|
|
||||||
WrapText,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { Drawer } from "@/components/Drawer";
|
import { Drawer } from "@/components/Drawer";
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
|
||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
import { TextArea } from "@/components/ui/TextArea";
|
|
||||||
import type { ProjectResponse } from "@/lib/api/types";
|
import type { ProjectResponse } from "@/lib/api/types";
|
||||||
import { friendlyError } from "@/lib/errors/messages";
|
import { friendlyError } from "@/lib/errors/messages";
|
||||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||||
@@ -35,14 +14,8 @@ import {
|
|||||||
WORKBENCH_CHAPTER_NO,
|
WORKBENCH_CHAPTER_NO,
|
||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
} from "@/lib/workbench/chapter";
|
} from "@/lib/workbench/chapter";
|
||||||
import {
|
import { composeDirective } from "@/lib/workbench/directive";
|
||||||
composeDirective,
|
|
||||||
PLOT_PRESETS,
|
|
||||||
STYLE_PRESETS,
|
|
||||||
type Preset,
|
|
||||||
} from "@/lib/workbench/directive";
|
|
||||||
import { applyRefinement } from "@/lib/workbench/refineApply";
|
import { applyRefinement } from "@/lib/workbench/refineApply";
|
||||||
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
|
||||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||||
import { Editor, type EditorSelection } from "./Editor";
|
import { Editor, type EditorSelection } from "./Editor";
|
||||||
@@ -50,7 +23,7 @@ import { RefinePanel } from "./RefinePanel";
|
|||||||
import { ContinuePanel } from "./ContinuePanel";
|
import { ContinuePanel } from "./ContinuePanel";
|
||||||
import { InlineToolbox } from "./InlineToolbox";
|
import { InlineToolbox } from "./InlineToolbox";
|
||||||
import { ChapterRewritePanel } from "./ChapterRewritePanel";
|
import { ChapterRewritePanel } from "./ChapterRewritePanel";
|
||||||
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
|
import { ContextDrawer } from "./ContextDrawer";
|
||||||
import {
|
import {
|
||||||
AiConversationDrawer,
|
AiConversationDrawer,
|
||||||
AI_CONVERSATION_ENABLED,
|
AI_CONVERSATION_ENABLED,
|
||||||
@@ -60,6 +33,10 @@ import { GENRES } from "@/lib/wizard/wizard";
|
|||||||
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
|
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
|
||||||
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse";
|
import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse";
|
||||||
|
import { useFocusMode } from "@/lib/workbench/useFocusMode";
|
||||||
|
import { DirectivePanel, AiVerbsRow } from "./AiToolbar";
|
||||||
|
import { MobileContextBar } from "./MobileContextBar";
|
||||||
|
import { Toolbar } from "./WorkbenchToolbar";
|
||||||
|
|
||||||
interface WorkbenchProps {
|
interface WorkbenchProps {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
@@ -107,6 +84,8 @@ export function Workbench({
|
|||||||
const conversation = useAiConversation(project.id, chapterNo);
|
const conversation = useAiConversation(project.id, chapterNo);
|
||||||
// P2-2:桌面「目录」列可收起(记忆状态);收起时把宽让给正文,窄轨留一个展开入口。
|
// P2-2:桌面「目录」列可收起(记忆状态);收起时把宽让给正文,窄轨留一个展开入口。
|
||||||
const chapterList = useChapterListCollapse();
|
const chapterList = useChapterListCollapse();
|
||||||
|
// P3-4:专注写作开关(记忆状态)——开启隐藏左侧图标导航 + 目录 TOC,让宽给正文。
|
||||||
|
const focusMode = useFocusMode();
|
||||||
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
|
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||||
@@ -116,6 +95,8 @@ export function Workbench({
|
|||||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const contextTriggerRef = useRef<HTMLButtonElement>(null);
|
const contextTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
// P3-7 流式跟随:正文滚动容器引用(纯展示层,不触碰 SSE/流式数据)。
|
||||||
|
const editorScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,6 +113,15 @@ export function Workbench({
|
|||||||
}
|
}
|
||||||
}, [stream.state.phase, stream.state.text, autosave]);
|
}, [stream.state.phase, stream.state.text, autosave]);
|
||||||
|
|
||||||
|
// P3-7 流式跟随:流式且正文增长时把滚动容器贴到底,让新生成的段落保持可见。
|
||||||
|
// 纯展示层:只读既有 text/streaming 展示态,瞬时定位(无动画,天然尊重 prefers-reduced-motion)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stream.isStreaming) return;
|
||||||
|
const el = editorScrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}, [text, stream.isStreaming]);
|
||||||
|
|
||||||
// 题材 + 三槽指令合并为一条写章指令(题材前置);effectiveGenre 无 → 仅原指令。
|
// 题材 + 三槽指令合并为一条写章指令(题材前置);effectiveGenre 无 → 仅原指令。
|
||||||
const startWrite = (effectiveGenre: string | null): void => {
|
const startWrite = (effectiveGenre: string | null): void => {
|
||||||
const base = composeDirective(directive, presetIds);
|
const base = composeDirective(directive, presetIds);
|
||||||
@@ -271,6 +261,13 @@ export function Workbench({
|
|||||||
const wordCount = text.replace(/\s+/g, "").length;
|
const wordCount = text.replace(/\s+/g, "").length;
|
||||||
const closePanel = (): void => setMobilePanel(null);
|
const closePanel = (): void => setMobilePanel(null);
|
||||||
|
|
||||||
|
// 桌面栅格列宽:专注模式撤掉目录 TOC 列(仅正文 + 右栏);否则按目录列收起/展开取宽。
|
||||||
|
const gridCols = focusMode.focus
|
||||||
|
? "xl:grid-cols-[1fr_18rem]"
|
||||||
|
: chapterList.collapsed
|
||||||
|
? "xl:grid-cols-[2.75rem_1fr_18rem]"
|
||||||
|
: "xl:grid-cols-[12rem_1fr_18rem]";
|
||||||
|
|
||||||
// 流式里程碑播报(仅终结句,非逐 token):完成/停止/失败时写入 role=status 区。
|
// 流式里程碑播报(仅终结句,非逐 token):完成/停止/失败时写入 role=status 区。
|
||||||
const liveMessage = ((): string => {
|
const liveMessage = ((): string => {
|
||||||
switch (stream.state.phase) {
|
switch (stream.state.phase) {
|
||||||
@@ -294,21 +291,20 @@ export function Workbench({
|
|||||||
subtitle={`第 ${chapterNo} 章`}
|
subtitle={`第 ${chapterNo} 章`}
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
activeNav="write"
|
activeNav="write"
|
||||||
|
hideNav={focusMode.focus}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${
|
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${gridCols}`}
|
||||||
chapterList.collapsed
|
|
||||||
? "xl:grid-cols-[2.75rem_1fr_18rem]"
|
|
||||||
: "xl:grid-cols-[12rem_1fr_18rem]"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<ChapterList
|
{focusMode.focus ? null : (
|
||||||
projectId={project.id}
|
<ChapterList
|
||||||
chapters={chapters}
|
projectId={project.id}
|
||||||
currentChapterNo={chapterNo}
|
chapters={chapters}
|
||||||
collapsed={chapterList.collapsed}
|
currentChapterNo={chapterNo}
|
||||||
onToggle={chapterList.toggle}
|
collapsed={chapterList.collapsed}
|
||||||
/>
|
onToggle={chapterList.toggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="flex min-w-0 flex-col bg-bg">
|
<section className="flex min-w-0 flex-col bg-bg">
|
||||||
<MobileContextBar
|
<MobileContextBar
|
||||||
@@ -386,7 +382,7 @@ export function Workbench({
|
|||||||
onClose={() => setRewriteOpen(false)}
|
onClose={() => setRewriteOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex-1 overflow-auto px-6 py-8">
|
<div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8">
|
||||||
{chapters.length === 0 ? (
|
{chapters.length === 0 ? (
|
||||||
<StatusNote variant="info" className="mb-4">
|
<StatusNote variant="info" className="mb-4">
|
||||||
尚无大纲,将按设定自由生成。
|
尚无大纲,将按设定自由生成。
|
||||||
@@ -409,6 +405,8 @@ export function Workbench({
|
|||||||
streamError={stream.state.error}
|
streamError={stream.state.error}
|
||||||
liveMessage={liveMessage}
|
liveMessage={liveMessage}
|
||||||
onStop={stream.stop}
|
onStop={stream.stop}
|
||||||
|
focus={focusMode.focus}
|
||||||
|
onToggleFocus={focusMode.toggle}
|
||||||
onOpenContext={() => setContextOpen(true)}
|
onOpenContext={() => setContextOpen(true)}
|
||||||
contextTriggerRef={contextTriggerRef}
|
contextTriggerRef={contextTriggerRef}
|
||||||
onOpenConversation={() => setConversationOpen(true)}
|
onOpenConversation={() => setConversationOpen(true)}
|
||||||
@@ -467,471 +465,3 @@ export function Workbench({
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileContextBarProps {
|
|
||||||
chapterNo: number;
|
|
||||||
onOpenChapters: () => void;
|
|
||||||
onOpenAssistant: () => void;
|
|
||||||
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
|
|
||||||
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileContextBar({
|
|
||||||
chapterNo,
|
|
||||||
onOpenChapters,
|
|
||||||
onOpenAssistant,
|
|
||||||
chapterTriggerRef,
|
|
||||||
assistantTriggerRef,
|
|
||||||
}: MobileContextBarProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 xl:hidden">
|
|
||||||
<Button
|
|
||||||
ref={chapterTriggerRef}
|
|
||||||
onClick={onOpenChapters}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
|
||||||
目录
|
|
||||||
</Button>
|
|
||||||
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
|
|
||||||
第 {chapterNo} 章
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
ref={assistantTriggerRef}
|
|
||||||
onClick={onOpenAssistant}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<PanelRight className="h-4 w-4" aria-hidden="true" />
|
|
||||||
助手
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DirectivePanelProps {
|
|
||||||
directive: string;
|
|
||||||
onDirectiveChange: (value: string) => void;
|
|
||||||
presetIds: readonly string[];
|
|
||||||
onTogglePreset: (id: string) => void;
|
|
||||||
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
|
|
||||||
defaultOpen: boolean;
|
|
||||||
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
|
|
||||||
streaming: boolean;
|
|
||||||
onWrite: () => void;
|
|
||||||
onStop: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 本章写作指令面板(P0-2 提升):正文正上方常驻的「和 AI 说话」输入条。
|
|
||||||
// 标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;空章默认展开露出输入框。
|
|
||||||
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
|
|
||||||
function DirectivePanel({
|
|
||||||
directive,
|
|
||||||
onDirectiveChange,
|
|
||||||
presetIds,
|
|
||||||
onTogglePreset,
|
|
||||||
defaultOpen,
|
|
||||||
streaming,
|
|
||||||
onWrite,
|
|
||||||
onStop,
|
|
||||||
}: DirectivePanelProps) {
|
|
||||||
const panelId = useId();
|
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
|
||||||
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
|
|
||||||
return (
|
|
||||||
<section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={panelId}
|
|
||||||
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded font-serif text-base text-ink hover:text-cinnabar ${focusRing}`}
|
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-4 w-4 shrink-0 text-ink-soft transition-transform ${
|
|
||||||
open ? "rotate-180" : ""
|
|
||||||
}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="truncate">告诉 AI 这章想怎么写</span>
|
|
||||||
</button>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{activeCount > 0 ? (
|
|
||||||
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
|
||||||
{activeCount} 项指令
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{/* 常驻「发送键」= 写本章:始终可见(折叠时也在),逐字流式写进正文。带可见范围提示(P1-4)。 */}
|
|
||||||
{streaming ? (
|
|
||||||
<Button onClick={onStop} variant="danger" size="sm">
|
|
||||||
<Square className="h-4 w-4" aria-hidden="true" />
|
|
||||||
停
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={onWrite} variant="primary" size="sm">
|
|
||||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
|
||||||
写本章
|
|
||||||
<span className="text-2xs font-normal text-panel/75">
|
|
||||||
整章·从头写
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<HitlHelp />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-ink-soft">
|
|
||||||
AI 给草稿,你决定采不采用,原文永远你说了算。
|
|
||||||
</p>
|
|
||||||
{open ? (
|
|
||||||
<div id={panelId} className="mt-3 space-y-3">
|
|
||||||
<SectionHeader
|
|
||||||
title="你的写作要求"
|
|
||||||
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
|
|
||||||
/>
|
|
||||||
<PresetGroup
|
|
||||||
label="文风"
|
|
||||||
presets={STYLE_PRESETS}
|
|
||||||
presetIds={presetIds}
|
|
||||||
onToggle={onTogglePreset}
|
|
||||||
/>
|
|
||||||
<PresetGroup
|
|
||||||
label="剧情需求"
|
|
||||||
presets={PLOT_PRESETS}
|
|
||||||
presetIds={presetIds}
|
|
||||||
onToggle={onTogglePreset}
|
|
||||||
/>
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-sm text-ink-soft">自己的要求</span>
|
|
||||||
<TextArea
|
|
||||||
value={directive}
|
|
||||||
onChange={(e) => onDirectiveChange(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
placeholder="写点要求,AI 更懂你(留空则按大纲写)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{activeCount > 0 ? (
|
|
||||||
<StatusNote variant="info">
|
|
||||||
写本章时会把已选文风/剧情预设和你的要求合并进本次生成请求。
|
|
||||||
</StatusNote>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AiVerbsRowProps {
|
|
||||||
streaming: boolean;
|
|
||||||
onContinue: () => void;
|
|
||||||
onRefineSelection: () => void;
|
|
||||||
onRewrite: () => void;
|
|
||||||
onToolbox: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 「或让 AI:」次级动作行(P1-1):常驻贴在中央输入条下方,把续写/润色/整章重写/工具箱
|
|
||||||
// 归为「同一个 AI 的其它用法」(act-on-chapter 与导航分家)。每个动词带可见范围提示(P1-4)。
|
|
||||||
// 生成中禁用(避免与流式写章并发),触发逻辑沿用 Workbench 既有 open* 回调(仅搬家、不改行为)。
|
|
||||||
function AiVerbsRow({
|
|
||||||
streaming,
|
|
||||||
onContinue,
|
|
||||||
onRefineSelection,
|
|
||||||
onRewrite,
|
|
||||||
onToolbox,
|
|
||||||
}: AiVerbsRowProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-panel px-4 py-2 sm:px-6">
|
|
||||||
<span className="text-xs text-ink-soft">或让 AI:</span>
|
|
||||||
<VerbButton
|
|
||||||
icon={WrapText}
|
|
||||||
label="续写"
|
|
||||||
hint="接着往下写"
|
|
||||||
disabled={streaming}
|
|
||||||
onClick={onContinue}
|
|
||||||
/>
|
|
||||||
<VerbButton
|
|
||||||
icon={Wand2}
|
|
||||||
label="润色选段"
|
|
||||||
hint="选中段·打磨"
|
|
||||||
disabled={streaming}
|
|
||||||
onClick={onRefineSelection}
|
|
||||||
/>
|
|
||||||
<VerbButton
|
|
||||||
icon={RefreshCw}
|
|
||||||
label="整章重写"
|
|
||||||
hint="整章·基于现有重写"
|
|
||||||
disabled={streaming}
|
|
||||||
onClick={onRewrite}
|
|
||||||
/>
|
|
||||||
<VerbButton
|
|
||||||
icon={Sparkles}
|
|
||||||
label="工具箱"
|
|
||||||
disabled={streaming}
|
|
||||||
onClick={onToolbox}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VerbButtonProps {
|
|
||||||
icon: typeof WrapText;
|
|
||||||
label: string;
|
|
||||||
// 可见(非 hover-only)范围提示:让作者点前就能预判对草稿的影响(P1-4)。工具箱无范围提示。
|
|
||||||
hint?: string;
|
|
||||||
disabled: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
|
|
||||||
return (
|
|
||||||
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
|
|
||||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
||||||
{label}
|
|
||||||
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach)——解释「和 AI 说话」的闭环。
|
|
||||||
// 仿 AiToolbarMoreMenu:按钮 + 轻量弹层,Esc / 点击外部关闭,还原焦点。
|
|
||||||
function HitlHelp() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const popId = useId();
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onKeyDown = (e: KeyboardEvent): void => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setOpen(false);
|
|
||||||
buttonRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onPointerDown = (e: PointerEvent): void => {
|
|
||||||
const target = e.target;
|
|
||||||
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
window.addEventListener("pointerdown", onPointerDown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
window.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={rootRef} className="relative">
|
|
||||||
<button
|
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={popId}
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className={buttonClass({
|
|
||||||
variant: "ghost",
|
|
||||||
size: "sm",
|
|
||||||
className: "gap-1 text-xs text-ink-soft",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-4 w-4" aria-hidden="true" />
|
|
||||||
这是什么
|
|
||||||
</button>
|
|
||||||
{open ? (
|
|
||||||
<div
|
|
||||||
id={popId}
|
|
||||||
role="dialog"
|
|
||||||
aria-label="怎么和 AI 说话"
|
|
||||||
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded border border-line bg-panel p-3 text-sm text-ink-soft shadow-paper"
|
|
||||||
>
|
|
||||||
<p className="mb-1 font-serif text-sm text-ink">怎么和 AI 说话</p>
|
|
||||||
<p>
|
|
||||||
在这条写下要求,点「写本章」——AI 会把草稿逐字写进正文。草稿随时能改;
|
|
||||||
满意了去「审稿」验收。原文永远你说了算。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PresetGroupProps {
|
|
||||||
label: string;
|
|
||||||
presets: readonly Preset[];
|
|
||||||
presetIds: readonly string[];
|
|
||||||
onToggle: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一组预设 chips(文风 / 剧情需求):点选 toggle,选中高亮。id 统一进 presetIds。
|
|
||||||
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 text-sm text-ink-soft">{label}</p>
|
|
||||||
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
|
|
||||||
{presets.map((preset) => {
|
|
||||||
const active = presetIds.includes(preset.id);
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={active}
|
|
||||||
onClick={() => onToggle(preset.id)}
|
|
||||||
variant={active ? "outline" : "secondary"}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写章失败提示:友好文案(不暴露 raw code)+ 可选「去设置」动作(F4)。
|
|
||||||
function StreamErrorNote({
|
|
||||||
streamError,
|
|
||||||
}: {
|
|
||||||
streamError: { code: string; message: string };
|
|
||||||
}) {
|
|
||||||
const friendly = friendlyError(streamError.code, streamError.message);
|
|
||||||
return (
|
|
||||||
<p className="mb-2 text-sm text-conflict">
|
|
||||||
{friendly.text}
|
|
||||||
{friendly.actionHref ? (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
<Link
|
|
||||||
href={friendly.actionHref}
|
|
||||||
className="underline hover:text-cinnabar"
|
|
||||||
>
|
|
||||||
{friendly.actionLabel}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底栏瘦身(P1-1):act-on-chapter 动词与纯导航都已上移/移除,底栏只余
|
|
||||||
// 状态(字数/保存/播报)+ 流式时的「停」+ 唯一强调的「审稿」前进 CTA + 速查/对话记录抽屉入口。
|
|
||||||
interface ToolbarProps {
|
|
||||||
projectId: string;
|
|
||||||
chapterNo: number;
|
|
||||||
wordCount: number;
|
|
||||||
savedLabel: string | null;
|
|
||||||
saveStatus: ReturnType<typeof useAutosave>["status"];
|
|
||||||
streaming: boolean;
|
|
||||||
streamError: { code: string; message: string } | null;
|
|
||||||
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
|
|
||||||
liveMessage: string;
|
|
||||||
// 流式时的「停」:底栏 sticky,滚到长正文下方也够得着(中央条会滚出视野)。
|
|
||||||
onStop: () => void;
|
|
||||||
// 打开上下文速查抽屉(WFW-6)。
|
|
||||||
onOpenContext: () => void;
|
|
||||||
contextTriggerRef: RefObject<HTMLButtonElement | null>;
|
|
||||||
// 打开对话记录抽屉(AC-3)。
|
|
||||||
onOpenConversation: () => void;
|
|
||||||
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({
|
|
||||||
projectId,
|
|
||||||
chapterNo,
|
|
||||||
wordCount,
|
|
||||||
savedLabel,
|
|
||||||
saveStatus,
|
|
||||||
streaming,
|
|
||||||
streamError,
|
|
||||||
liveMessage,
|
|
||||||
onStop,
|
|
||||||
onOpenContext,
|
|
||||||
contextTriggerRef,
|
|
||||||
onOpenConversation,
|
|
||||||
conversationTriggerRef,
|
|
||||||
}: ToolbarProps) {
|
|
||||||
return (
|
|
||||||
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
|
||||||
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
|
|
||||||
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
||||||
{streaming ? (
|
|
||||||
<Button onClick={onStop} variant="danger" size="sm">
|
|
||||||
<Square className="h-4 w-4" aria-hidden="true" />
|
|
||||||
停
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{streaming ? (
|
|
||||||
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
|
||||||
// 易变字数 aria-hidden,仅供视觉,不逐 token 打扰屏读。
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" />
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="font-mono text-xs text-cinnabar"
|
|
||||||
>
|
|
||||||
{wordCount} 字
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono text-xs text-ink-soft">
|
|
||||||
字数 {wordCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
|
|
||||||
<span className="sr-only" role="status" aria-live="polite">
|
|
||||||
{liveMessage}
|
|
||||||
</span>
|
|
||||||
{CONTEXT_DRAWER_ENABLED ? (
|
|
||||||
<Button
|
|
||||||
ref={contextTriggerRef}
|
|
||||||
onClick={onOpenContext}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
|
|
||||||
>
|
|
||||||
<BookMarked className="h-4 w-4" aria-hidden="true" />
|
|
||||||
速查
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{AI_CONVERSATION_ENABLED ? (
|
|
||||||
<Button
|
|
||||||
ref={conversationTriggerRef}
|
|
||||||
onClick={onOpenConversation}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
|
|
||||||
>
|
|
||||||
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
|
|
||||||
对话记录
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA(导航去重后仅此一处强调,左栏另有入口)。 */}
|
|
||||||
<Link
|
|
||||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
|
||||||
className={buttonClass({ variant: "outline", size: "sm" })}
|
|
||||||
>
|
|
||||||
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
|
|
||||||
审稿
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto"
|
|
||||||
>
|
|
||||||
<FileText className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
{saveStatus === "saving"
|
|
||||||
? "保存中…"
|
|
||||||
: saveStatus === "error"
|
|
||||||
? "保存失败"
|
|
||||||
: (savedLabel ?? "尚未保存")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
209
apps/web/components/workbench/WorkbenchToolbar.tsx
Normal file
209
apps/web/components/workbench/WorkbenchToolbar.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import {
|
||||||
|
BookMarked,
|
||||||
|
ClipboardCheck,
|
||||||
|
FileText,
|
||||||
|
Maximize2,
|
||||||
|
MessagesSquare,
|
||||||
|
Minimize2,
|
||||||
|
Square,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import type { useAutosave } from "@/lib/autosave/useAutosave";
|
||||||
|
import { friendlyError } from "@/lib/errors/messages";
|
||||||
|
import { buttonClass } from "@/lib/ui/variants";
|
||||||
|
import { CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
|
||||||
|
import { AI_CONVERSATION_ENABLED } from "./AiConversationDrawer";
|
||||||
|
|
||||||
|
// 写章失败提示:友好文案(不暴露 raw code)+ 可选「去设置」动作(F4)。
|
||||||
|
function StreamErrorNote({
|
||||||
|
streamError,
|
||||||
|
}: {
|
||||||
|
streamError: { code: string; message: string };
|
||||||
|
}) {
|
||||||
|
const friendly = friendlyError(streamError.code, streamError.message);
|
||||||
|
return (
|
||||||
|
<p className="mb-2 text-sm text-conflict">
|
||||||
|
{friendly.text}
|
||||||
|
{friendly.actionHref ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<Link
|
||||||
|
href={friendly.actionHref}
|
||||||
|
className="underline hover:text-cinnabar"
|
||||||
|
>
|
||||||
|
{friendly.actionLabel}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 专注写作开关(P3-4):图标按钮,进入=隐藏左侧图标导航 + 目录 TOC 加宽正文,退出还原。
|
||||||
|
// aria-pressed 表状态、aria-label 表动作,键盘可达(复用共享 Button 焦点环)。
|
||||||
|
function FocusToggle({
|
||||||
|
focus,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
focus: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={onToggle}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={focus}
|
||||||
|
aria-label={focus ? "退出专注写作模式" : "进入专注写作模式(隐藏侧栏,加宽正文)"}
|
||||||
|
title={focus ? "退出专注写作" : "专注写作"}
|
||||||
|
>
|
||||||
|
{focus ? (
|
||||||
|
<Minimize2 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
专注
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底栏(P1-1 瘦身 + P3-6 分区):按「信息 | 操作 | 状态」三区排布。
|
||||||
|
// 信息=字数/生成中;操作=速查/对话记录/审稿/专注(+流式时的「停」);状态=保存态(右侧)。
|
||||||
|
interface ToolbarProps {
|
||||||
|
projectId: string;
|
||||||
|
chapterNo: number;
|
||||||
|
wordCount: number;
|
||||||
|
savedLabel: string | null;
|
||||||
|
saveStatus: ReturnType<typeof useAutosave>["status"];
|
||||||
|
streaming: boolean;
|
||||||
|
streamError: { code: string; message: string } | null;
|
||||||
|
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
|
||||||
|
liveMessage: string;
|
||||||
|
// 流式时的「停」:底栏 sticky,滚到长正文下方也够得着(中央条会滚出视野)。
|
||||||
|
onStop: () => void;
|
||||||
|
// 专注写作开关(P3-4)。
|
||||||
|
focus: boolean;
|
||||||
|
onToggleFocus: () => void;
|
||||||
|
// 打开上下文速查抽屉(WFW-6)。
|
||||||
|
onOpenContext: () => void;
|
||||||
|
contextTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
// 打开对话记录抽屉(AC-3)。
|
||||||
|
onOpenConversation: () => void;
|
||||||
|
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toolbar({
|
||||||
|
projectId,
|
||||||
|
chapterNo,
|
||||||
|
wordCount,
|
||||||
|
savedLabel,
|
||||||
|
saveStatus,
|
||||||
|
streaming,
|
||||||
|
streamError,
|
||||||
|
liveMessage,
|
||||||
|
onStop,
|
||||||
|
focus,
|
||||||
|
onToggleFocus,
|
||||||
|
onOpenContext,
|
||||||
|
contextTriggerRef,
|
||||||
|
onOpenConversation,
|
||||||
|
conversationTriggerRef,
|
||||||
|
}: ToolbarProps) {
|
||||||
|
return (
|
||||||
|
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
||||||
|
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
|
||||||
|
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
|
||||||
|
{/* 信息区:字数 / 生成中(含流式时的「停」)。 */}
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
{streaming ? (
|
||||||
|
<Button onClick={onStop} variant="danger" size="sm">
|
||||||
|
<Square className="h-4 w-4" aria-hidden="true" />
|
||||||
|
停
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{streaming ? (
|
||||||
|
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
||||||
|
// 易变字数 aria-hidden,仅供视觉,不逐 token 打扰屏读。
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ThinkingIndicator
|
||||||
|
label="生成中"
|
||||||
|
className="text-xs text-cinnabar"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="font-mono text-xs text-cinnabar"
|
||||||
|
>
|
||||||
|
{wordCount} 字
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs text-ink-soft">
|
||||||
|
字数 {wordCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
|
||||||
|
<span className="sr-only" role="status" aria-live="polite">
|
||||||
|
{liveMessage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作区:速查 / 对话记录 / 审稿 / 专注;与信息区以细分隔线拉开。 */}
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:border-l sm:border-line-soft sm:pl-3">
|
||||||
|
{CONTEXT_DRAWER_ENABLED ? (
|
||||||
|
<Button
|
||||||
|
ref={contextTriggerRef}
|
||||||
|
onClick={onOpenContext}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
|
||||||
|
>
|
||||||
|
<BookMarked className="h-4 w-4" aria-hidden="true" />
|
||||||
|
速查
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{AI_CONVERSATION_ENABLED ? (
|
||||||
|
<Button
|
||||||
|
ref={conversationTriggerRef}
|
||||||
|
onClick={onOpenConversation}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
|
||||||
|
对话记录
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<FocusToggle focus={focus} onToggle={onToggleFocus} />
|
||||||
|
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA(导航去重后仅此一处强调,左栏另有入口)。 */}
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
||||||
|
className={buttonClass({ variant: "outline", size: "sm" })}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
|
||||||
|
审稿
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态区:保存态,右侧对齐。 */}
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto"
|
||||||
|
>
|
||||||
|
<FileText className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{saveStatus === "saving"
|
||||||
|
? "保存中…"
|
||||||
|
: saveStatus === "error"
|
||||||
|
? "保存失败"
|
||||||
|
: (savedLabel ?? "尚未保存")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/web/lib/workbench/useFocusMode.test.ts
Normal file
61
apps/web/lib/workbench/useFocusMode.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { FOCUS_MODE_KEY, useFocusMode } from "./useFocusMode";
|
||||||
|
|
||||||
|
// 本仓库 jsdom 未提供可用的 Storage(setItem/clear 缺失),装一个内存版 localStorage。
|
||||||
|
function installMemoryStorage(): Storage {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
const storage: Storage = {
|
||||||
|
get length() {
|
||||||
|
return map.size;
|
||||||
|
},
|
||||||
|
clear: () => map.clear(),
|
||||||
|
getItem: (key) => (map.has(key) ? (map.get(key) ?? null) : null),
|
||||||
|
key: (index) => Array.from(map.keys())[index] ?? null,
|
||||||
|
removeItem: (key) => map.delete(key),
|
||||||
|
setItem: (key, value) => map.set(key, String(value)),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: storage,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useFocusMode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
installMemoryStorage();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("默认关闭(无存档时 focus=false)", () => {
|
||||||
|
const { result } = renderHook(() => useFocusMode());
|
||||||
|
expect(result.current.focus).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle 翻转并把状态写入 localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useFocusMode());
|
||||||
|
|
||||||
|
act(() => result.current.toggle());
|
||||||
|
|
||||||
|
expect(result.current.focus).toBe(true);
|
||||||
|
expect(window.localStorage.getItem(FOCUS_MODE_KEY)).toBe("1");
|
||||||
|
|
||||||
|
act(() => result.current.toggle());
|
||||||
|
|
||||||
|
expect(result.current.focus).toBe(false);
|
||||||
|
expect(window.localStorage.getItem(FOCUS_MODE_KEY)).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("挂载时从 localStorage 恢复已开启状态", () => {
|
||||||
|
window.localStorage.setItem(FOCUS_MODE_KEY, "1");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFocusMode());
|
||||||
|
|
||||||
|
expect(result.current.focus).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/web/lib/workbench/useFocusMode.ts
Normal file
46
apps/web/lib/workbench/useFocusMode.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// 「专注写作」偏好的持久化键(对齐既有 `ww.` 前缀约定)。
|
||||||
|
export const FOCUS_MODE_KEY = "ww.workbench_focus_mode";
|
||||||
|
|
||||||
|
export interface FocusMode {
|
||||||
|
// true=专注模式:隐藏左侧图标导航 + 目录 TOC,让宽给正文;false=常规四栏。
|
||||||
|
focus: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(focus: boolean): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(FOCUS_MODE_KEY, focus ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
// 隐私模式/配额异常忽略:专注模式是纯 UI 偏好,写盘失败不影响写作功能。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记住「专注写作」开关(P3-4):SSR 首帧恒关(避免 hydration 不一致),
|
||||||
|
// 挂载后再从 localStorage 恢复;toggle 即时写回。仅桌面(≥xl)改变栅格,窄屏本就单列不受影响。
|
||||||
|
export function useFocusMode(): FocusMode {
|
||||||
|
const [focus, setFocus] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
setFocus(window.localStorage.getItem(FOCUS_MODE_KEY) === "1");
|
||||||
|
} catch {
|
||||||
|
// 读盘失败则维持默认关闭。
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setFocus((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { focus, toggle };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user