- 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)
210 lines
7.0 KiB
TypeScript
210 lines
7.0 KiB
TypeScript
"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>
|
||
);
|
||
}
|