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

@@ -1,8 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { RotateCcw, X } from "lucide-react"; import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { buttonClass } from "@/lib/ui/variants"; import { buttonClass } from "@/lib/ui/variants";
import { import {
@@ -27,7 +30,7 @@ interface ChapterAssistantProps {
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。 // 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。
export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) { export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) {
return ( return (
<aside className="hidden border-l border-line bg-panel py-4 lg:block"> <aside className="hidden border-l border-line bg-panel py-4 xl:block">
<AssistantContent projectId={projectId} chapterNo={chapterNo} /> <AssistantContent projectId={projectId} chapterNo={chapterNo} />
</aside> </aside>
); );
@@ -41,26 +44,31 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
return ( return (
<div className="px-4"> <div className="px-4">
<div className="flex items-center justify-between"> <SectionHeader
<h2 className="text-sm font-semibold text-ink"></h2> title="本章助手"
{injection.saving ? ( action={
<span className="text-[10px] text-ink-soft"></span> injection.saving ? (
) : null} <ThinkingIndicator
</div> label="助手保存中"
className="text-xs text-ink-soft"
/>
) : null
}
/>
<section className="mt-4"> <section className="mt-4">
<div className="flex items-center justify-between"> <SectionHeader
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> title="本章注入(透明)"
action={
</h3> data ? (
{data ? ( <RecentStepper
<RecentStepper value={data.recent_n}
value={data.recent_n} disabled={injection.saving}
disabled={injection.saving} onChange={injection.setRecentN}
onChange={injection.setRecentN} />
/> ) : null
) : null} }
</div> />
{injection.loading ? ( {injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p> <p className="mt-2 text-xs text-ink-soft"></p>
@@ -131,9 +139,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
{excluded.length > 0 ? ( {excluded.length > 0 ? (
<div className="mt-3"> <div className="mt-3">
<h4 className="text-[10px] uppercase tracking-wide text-ink-soft"> <SectionHeader title="已排除(不注入)" />
</h4>
<ul className="mt-1 space-y-1"> <ul className="mt-1 space-y-1">
{excluded.map((ref) => ( {excluded.map((ref) => (
<ExcludedRow <ExcludedRow
@@ -149,12 +155,10 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
</section> </section>
<section className="mt-6"> <section className="mt-6">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft"> <SectionHeader
title="四审"
</h3> description="一致性 / 伏笔 / 文风 / 节奏,在审稿页逐条裁决。"
<p className="mt-2 text-xs text-ink-soft"> />
/ / / 稿
</p>
<Link <Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`} href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })} className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
@@ -179,42 +183,44 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
return ( return (
<li className="rounded border border-line bg-bg p-2"> <li className="rounded border border-line bg-bg p-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="rounded bg-panel px-1.5 py-0.5 text-[10px] text-ink-soft"> <span className="rounded bg-panel px-1.5 py-0.5 text-2xs text-ink-soft">
{kindLabel(entity.kind)} {kindLabel(entity.kind)}
</span> </span>
<span className="text-sm text-ink">{entity.name}</span> <span className="text-sm text-ink">{entity.name}</span>
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<button <Button
type="button" variant="ghost"
size="icon"
disabled={disabled} disabled={disabled}
aria-pressed={pinned} aria-pressed={pinned}
title={pinned ? "取消置顶" : "置顶(强制注入)"} aria-label={
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(强制注入)`
}
onClick={() => onTogglePin(ref)} onClick={() => onTogglePin(ref)}
className={`rounded px-1.5 py-0.5 text-[11px] disabled:opacity-50 ${ className={pinned ? "text-cinnabar" : undefined}
pinned
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink-soft hover:bg-panel"
}`}
> >
📌 {pinned ? (
</button> <Pin className="h-4 w-4" aria-hidden="true" />
<button ) : (
type="button" <PinOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="icon"
disabled={disabled} disabled={disabled}
title="排除(不注入本章)" aria-label={`排除 ${entity.name}(不注入本章)`}
aria-label={`排除 ${entity.name}`}
onClick={() => onExclude(ref)} onClick={() => onExclude(ref)}
className="rounded px-1.5 py-0.5 text-[11px] text-ink-soft hover:bg-panel disabled:opacity-50"
> >
<X className="h-4 w-4" aria-hidden="true" />
</button> </Button>
</div> </div>
</div> </div>
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{(entity.reasons ?? []).map((reason) => ( {(entity.reasons ?? []).map((reason) => (
<span <span
key={reason} key={reason}
className="rounded bg-[var(--color-cinnabar-wash)] px-1.5 py-0.5 text-[10px] text-cinnabar" className="rounded bg-[var(--color-cinnabar-wash)] px-1.5 py-0.5 text-2xs text-cinnabar"
> >
{reasonLabel(reason)} {reasonLabel(reason)}
</span> </span>
@@ -233,17 +239,18 @@ interface ExcludedRowProps {
function ExcludedRow({ refItem, disabled, onRestore }: ExcludedRowProps) { function ExcludedRow({ refItem, disabled, onRestore }: ExcludedRowProps) {
return ( return (
<li className="flex items-center gap-2 rounded border border-dashed border-line px-2 py-1"> <li className="flex items-center gap-2 rounded border border-dashed border-line px-2 py-1">
<span className="text-[10px] text-ink-soft">{kindLabel(refItem.kind)}</span> <span className="text-2xs text-ink-soft">{kindLabel(refItem.kind)}</span>
<span className="text-xs text-ink-soft line-through">{refItem.name}</span> <span className="text-xs text-ink-soft line-through">{refItem.name}</span>
<button <Button
type="button" variant="ghost"
size="icon"
disabled={disabled} disabled={disabled}
title="恢复(回到自动判定)" aria-label={`恢复 ${refItem.name}(回到自动判定)`}
onClick={() => onRestore(refItem)} onClick={() => onRestore(refItem)}
className="ml-auto rounded px-1.5 py-0.5 text-[11px] text-cinnabar hover:bg-[var(--color-cinnabar-wash)] disabled:opacity-50" className="ml-auto text-cinnabar"
> >
<Undo2 className="h-4 w-4" aria-hidden="true" />
</button> </Button>
</li> </li>
); );
} }
@@ -257,27 +264,27 @@ interface RecentStepperProps {
// 「关联近 N 章」步进器:覆盖近况摘要回看章数(钳在 [MIN, MAX])。 // 「关联近 N 章」步进器:覆盖近况摘要回看章数(钳在 [MIN, MAX])。
function RecentStepper({ value, disabled, onChange }: RecentStepperProps) { function RecentStepper({ value, disabled, onChange }: RecentStepperProps) {
return ( return (
<div className="flex items-center gap-1 text-[11px] text-ink-soft"> <div className="flex items-center gap-1 text-2xs text-ink-soft">
<span></span> <span></span>
<button <Button
type="button" variant="ghost"
size="icon"
disabled={disabled || value <= RECENT_N_MIN} disabled={disabled || value <= RECENT_N_MIN}
aria-label="减少关联章数" aria-label="减少关联章数"
onClick={() => onChange(value - 1)} onClick={() => onChange(value - 1)}
className="rounded border border-line px-1.5 leading-tight hover:bg-panel disabled:opacity-40"
> >
<Minus className="h-4 w-4" aria-hidden="true" />
</button> </Button>
<span className="w-4 text-center tabular-nums text-ink">{value}</span> <span className="w-4 text-center tabular-nums text-ink">{value}</span>
<button <Button
type="button" variant="ghost"
size="icon"
disabled={disabled || value >= RECENT_N_MAX} disabled={disabled || value >= RECENT_N_MAX}
aria-label="增加关联章数" aria-label="增加关联章数"
onClick={() => onChange(value + 1)} onClick={() => onChange(value + 1)}
className="rounded border border-line px-1.5 leading-tight hover:bg-panel disabled:opacity-40"
> >
+ <Plus className="h-4 w-4" aria-hidden="true" />
</button> </Button>
<span></span> <span></span>
</div> </div>
); );

View File

@@ -17,7 +17,7 @@ export function ChapterList({
currentChapterNo, currentChapterNo,
}: ChapterListProps) { }: ChapterListProps) {
return ( return (
<aside className="hidden border-r border-line bg-panel py-4 lg:block"> <aside className="hidden border-r border-line bg-panel py-4 xl:block">
<ChapterListContent <ChapterListContent
projectId={projectId} projectId={projectId}
chapters={chapters} chapters={chapters}

View File

@@ -2,6 +2,8 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
interface EditorProps { interface EditorProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
@@ -36,7 +38,11 @@ export function Editor({ value, onChange, streaming }: EditorProps) {
readOnly={streaming} readOnly={streaming}
aria-busy={streaming} aria-busy={streaming}
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。" placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
className="block min-h-[60vh] w-full resize-none overflow-hidden bg-transparent font-serif text-[18px] leading-[1.9] text-ink placeholder:text-ink-soft/50 focus:outline-none" className={cn(
proseBody,
"block min-h-[60vh] w-full resize-none overflow-hidden rounded bg-transparent text-ink placeholder:text-ink-soft/50",
focusRing,
)}
/> />
{streaming ? ( {streaming ? (
<span <span

View File

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