fix(web): 写作台流式/保存 live region + 图标按钮换 lucide 无障碍 + 三栏改 xl 断点 + 手稿字号统一
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)。 */}
|
{/* 窄屏 / 中屏(1024–1280):目录 / 本章助手经抽屉触达;≥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"
|
||||||
? "保存中…"
|
? "保存中…"
|
||||||
|
|||||||
Reference in New Issue
Block a user