Files
writer-work-flow/apps/web/components/workbench/Workbench.tsx
Yaojia Wang b18310bcb7 feat(ux): F3 流式进度感 + F4 友好错误文案(纯前端)
F4:新 lib/errors/messages.ts —— friendlyError(code, fallback) 把错误码映射为
中文文案 + 可选「去设置提供商」动作;覆盖后端 ErrorCode + 前端 NETWORK/
STREAM_FAILED;未知码回退原 message。写作页与审稿页错误块改用之,不再暴露
raw error code。+4 vitest。

F3:写作页工具条字数计数器流式时显示「生成中… N 字」+ 朱砂脉冲
(motion-safe:animate-pulse,尊重 prefers-reduced-motion),停后回「字数 N」。
纯 text.length,无后端改动。

前端门禁绿:lint/typecheck/vitest 171/build。无契约变更。
2026-06-20 11:02:27 +02:00

204 lines
6.3 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 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 } from "./ChapterList";
import { ChapterAssistant } from "./ChapterAssistant";
import { Editor } from "./Editor";
interface WorkbenchProps {
project: ProjectResponse;
// RSC 种入的已存草稿正文GET .../draft无草稿404→ ""(空编辑器)。
initialText?: string;
}
// 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 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;
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}
/>
</section>
<ChapterAssistant projectId={project.id} chapterNo={chapterNo} />
</div>
</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;
}
function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
onWrite,
onStop,
}: ToolbarProps) {
return (
<div className="border-t border-line bg-panel px-6 py-3">
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="flex items-center gap-4">
{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>
<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>
);
}