fix(web): 写作台流式/保存 live region + 图标按钮换 lucide 无障碍 + 三栏改 xl 断点 + 手稿字号统一

This commit is contained in:
Yaojia Wang
2026-06-30 08:56:23 +02:00
parent e82aff7067
commit 69030eacc3
4 changed files with 137 additions and 78 deletions

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookOpen,
ChevronDown,
ClipboardCheck,
FileText,
Library,
@@ -15,6 +16,7 @@ import {
import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
@@ -102,9 +104,27 @@ export function Workbench({
autosave.onChange(value);
};
const wordCount = text.length;
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null);
// 流式里程碑播报(仅终结句,非逐 token完成/停止/失败时写入 role=status 区。
const liveMessage = ((): string => {
switch (stream.state.phase) {
case "done":
return `本章生成完成,共 ${wordCount}`;
case "aborted":
return "已停止";
case "error":
return stream.state.error
? friendlyError(stream.state.error.code, stream.state.error.message)
.text
: "本章生成失败,请稍后重试。";
default:
return "";
}
})();
return (
<AppShell
title={`${project.title}`}
@@ -112,7 +132,7 @@ export function Workbench({
projectId={project.id}
activeNav="write"
>
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:h-[calc(100vh-var(--chrome,4rem))] lg:grid-cols-[12rem_1fr_18rem]">
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] xl:grid-cols-[12rem_1fr_18rem]">
<ChapterList
projectId={project.id}
chapters={chapters}
@@ -148,6 +168,7 @@ export function Workbench({
saveStatus={autosave.status}
streaming={stream.isStreaming}
streamError={stream.state.error}
liveMessage={liveMessage}
onWrite={onWrite}
onStop={stream.stop}
/>
@@ -156,7 +177,7 @@ export function Workbench({
<ChapterAssistant projectId={project.id} chapterNo={chapterNo} />
</div>
{/* 移动端:目录 / 本章助手经抽屉触达(桌面已在栅格里,抽屉 lg:hidden。 */}
{/* 窄屏 / 中屏(10241280):目录 / 本章助手经抽屉触达≥xl 已在三栏栅格里直显。 */}
<Drawer
open={mobilePanel === "chapters"}
onClose={closePanel}
@@ -199,7 +220,7 @@ function MobileContextBar({
assistantTriggerRef,
}: MobileContextBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 lg:hidden">
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 xl:hidden">
<Button
ref={chapterTriggerRef}
onClick={onOpenChapters}
@@ -243,12 +264,18 @@ function DirectivePanel({
const panelId = useId();
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return (
<details className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<details className="group border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
aria-controls={panelId}
>
<span className="font-serif text-base text-ink"></span>
<span className="flex items-center gap-2 font-serif text-base text-ink">
<ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</span>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
</span>
@@ -327,6 +354,8 @@ interface ToolbarProps {
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
onWrite: () => void;
onStop: () => void;
}
@@ -339,6 +368,7 @@ function Toolbar({
saveStatus,
streaming,
streamError,
liveMessage,
onWrite,
onStop,
}: ToolbarProps) {
@@ -359,14 +389,26 @@ function Toolbar({
</Button>
)}
{streaming ? (
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
{wordCount.toLocaleString("en-US")}
// 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.toLocaleString("en-US")}
{wordCount}
</span>
)}
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
<Link
href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary", size: "sm" })}
@@ -393,7 +435,11 @@ function Toolbar({
稿
</Link>
</div>
<span className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto">
<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"
? "保存中…"