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:
Yaojia Wang
2026-07-11 08:04:56 +02:00
parent 67e30a6863
commit 5674158707
9 changed files with 787 additions and 549 deletions

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