Files
writer-work-flow/apps/web/components/workbench/WorkbenchToolbar.tsx
Yaojia Wang 5674158707 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)
2026-07-11 08:04:56 +02:00

210 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}