Files
writer-work-flow/apps/web/components/workbench/Workbench.tsx
Yaojia Wang 9b893a6c61 feat(ux): M1/M2 移动端可用 — 导航汉堡抽屉 + 写作工具条不溢出
M1 左导航响应式抽屉:抽出 lib/nav/items.ts(条目数据+激活判定,纯逻辑+vitest)
共用于桌面静态侧栏(hidden lg:block)与移动汉堡抽屉。新增通用 Drawer(遮罩/
Esc 关闭/打开聚焦,复用 CommandPalette a11y 模式)+ NavDrawer + NavItems;
LeftNav 改 <lg 隐藏,AppShell 顶栏加汉堡(aria-label 打开导航)。

M2 写作工具条不溢出 + 移动端可达章节/助手:Toolbar 改 flex-wrap,390px 不再
横切「尚未保存」;ChapterList/ChapterAssistant 抽出 *Content,Workbench 经
左/右 Drawer 在 <lg 触达目录/助手。

实景 390px 复验(browse):汉堡显示/静态导航隐藏/抽屉开合聚焦+Esc、工具条
两行不溢出、助手抽屉真注入面板。门禁:lint/typecheck 干净 · vitest 177 · build OK。
2026-06-20 11:26:14 +02:00

248 lines
7.9 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 { useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer";
import type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream";
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor";
interface WorkbenchProps {
project: ProjectResponse;
// RSC 种入的已存草稿正文GET .../draft无草稿404→ ""(空编辑器)。
initialText?: string;
}
// 移动端右栏(目录/助手)经抽屉触达;桌面用三栏栅格。
type MobilePanel = "chapters" | "assistant" | null;
// M1单章工作台。章节列表为占位无章节列表端点默认第 1 章。
// WORKBENCH_CHAPTER_NO 移到 lib/workbench/chapter.ts非客户端模块供 Server Component 安全导入。
export function Workbench({ project, initialText = "" }: WorkbenchProps) {
const chapterNo = WORKBENCH_CHAPTER_NO;
// 用已存草稿作初值;流式开始后由下方 effect 覆盖(流不会被初值反扑——
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
const [text, setText] = useState(initialText);
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
const autosave = useAutosave(project.id, chapterNo);
const stream = useDraftStream();
const lastStreamText = useRef("");
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => {
const streamed = stream.state.text;
if (
(stream.state.phase === "streaming" ||
stream.state.phase === "done" ||
stream.state.phase === "aborted") &&
streamed !== lastStreamText.current
) {
lastStreamText.current = streamed;
setText(streamed);
autosave.onChange(streamed);
}
}, [stream.state.phase, stream.state.text, autosave]);
const onWrite = (): void => {
void stream.start(project.id, chapterNo);
};
const onEditorChange = (value: string): void => {
setText(value);
autosave.onChange(value);
};
const wordCount = text.length;
const closePanel = (): void => setMobilePanel(null);
return (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo}`}
projectId={project.id}
activeNav="write"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList currentChapterNo={chapterNo} />
<section className="flex min-w-0 flex-col bg-bg">
<div className="flex-1 overflow-auto px-6 py-8">
<Editor
value={text}
onChange={onEditorChange}
streaming={stream.isStreaming}
/>
</div>
<Toolbar
projectId={project.id}
chapterNo={chapterNo}
wordCount={wordCount}
savedLabel={autosave.savedLabel}
saveStatus={autosave.status}
streaming={stream.isStreaming}
streamError={stream.state.error}
onWrite={onWrite}
onStop={stream.stop}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
/>
</section>
<ChapterAssistant projectId={project.id} chapterNo={chapterNo} />
</div>
{/* 移动端:目录 / 本章助手经抽屉触达(桌面已在栅格里,抽屉 lg:hidden。 */}
<Drawer
open={mobilePanel === "chapters"}
onClose={closePanel}
side="left"
label="目录"
>
<ChapterListContent currentChapterNo={chapterNo} />
</Drawer>
<Drawer
open={mobilePanel === "assistant"}
onClose={closePanel}
side="right"
label="本章助手"
>
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer>
</AppShell>
);
}
// 写章失败提示:友好文案(不暴露 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>
);
}
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
onWrite: () => void;
onStop: () => void;
onOpenChapters: () => void;
onOpenAssistant: () => void;
}
function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
onWrite,
onStop,
onOpenChapters,
onOpenAssistant,
}: ToolbarProps) {
return (
<div className="border-t border-line bg-panel px-4 py-3 sm:px-6">
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<button
type="button"
onClick={onOpenChapters}
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
>
</button>
{streaming ? (
<button
type="button"
onClick={onStop}
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
>
</button>
) : (
<button
type="button"
onClick={onWrite}
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
>
</button>
)}
{streaming ? (
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
{wordCount.toLocaleString("en-US")}
</span>
) : (
<span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString("en-US")}
</span>
)}
<Link
href={`/projects/${projectId}/outline`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
>
</Link>
<Link
href={`/projects/${projectId}/foreshadow`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
>
</Link>
<button
type="button"
onClick={onOpenAssistant}
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
>
</button>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
>
稿
</Link>
<span className="ml-auto font-mono text-xs text-ink-soft">
{saveStatus === "saving"
? "保存中…"
: saveStatus === "error"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}