merge: UX 清晰化修复 — 水合告警/正文滚动/草稿已保存/推理动画/裁决保存提示

- fix: html/body suppressHydrationWarning(浏览器扩展注入,非应用 bug)
- fix: 正文编辑器自适应高度,消除嵌套滚动条、铺满写作区
- fix(Q6): useAutosave 种入已存草稿 → 初始显示「已保存」
- feat(A): ThinkingIndicator 三点波动接入 审稿/验收/回炉 推理等待
- feat(B): 审稿裁决保存模型提示(暂存本地、验收落库)
门禁绿: typecheck/lint/vitest 213/build。实景验证: 已保存/正文滚动/审稿动画。
This commit is contained in:
Yaojia Wang
2026-06-21 18:18:55 +02:00
8 changed files with 84 additions and 12 deletions

View File

@@ -14,9 +14,11 @@ export default function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
// suppressHydrationWarning浏览器扩展如 Dark Reader/翻译插件)常在 html/body
// 注入 class/attr水合前改 DOM → 仅这一层属性的水合告警,非本应用 bug。
return ( return (
<html lang="zh-CN"> <html lang="zh-CN" suppressHydrationWarning>
<body className="font-sans antialiased"> <body className="font-sans antialiased" suppressHydrationWarning>
<ToastProvider> <ToastProvider>
{children} {children}
<CommandPaletteMount /> <CommandPaletteMount />

View File

@@ -0,0 +1,32 @@
interface ThinkingIndicatorProps {
// 状态文案(如「审稿进行中」「验收中」「回炉中」「生成中」)。
label: string;
className?: string;
}
// AI 推理中动画提示:三点波动(朱砂/继承色),让长等待「看得见在工作」。
// motion-safe尊重 prefers-reduced-motion减动偏好下三点静止为「…」仍可读a11y §10
// role=status + aria-live屏读播报状态变化。
export function ThinkingIndicator({
label,
className = "",
}: ThinkingIndicatorProps) {
return (
<span
className={`inline-flex items-center gap-1.5 ${className}`}
role="status"
aria-live="polite"
>
<span className="inline-flex gap-0.5" aria-hidden="true">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse"
style={{ animationDelay: `${i * 200}ms` }}
/>
))}
</span>
<span>{label}</span>
</span>
);
}

View File

@@ -2,6 +2,7 @@
import type { AcceptResponse } from "@/lib/api/types"; import type { AcceptResponse } from "@/lib/api/types";
import { buildAcceptPreview } from "@/lib/review/accept-preview"; import { buildAcceptPreview } from "@/lib/review/accept-preview";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
interface AcceptPanelProps { interface AcceptPanelProps {
conflictCount: number; conflictCount: number;
@@ -101,7 +102,11 @@ export function AcceptPanel({
aria-disabled={blocked || accepting} aria-disabled={blocked || accepting}
className="w-full rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40" className="w-full rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
> >
{accepting ? "验收中…" : "全部处理完 → 验收本章"} {accepting ? (
<ThinkingIndicator label="验收中" className="justify-center" />
) : (
"全部处理完 → 验收本章"
)}
</button> </button>
</div> </div>
); );

View File

@@ -30,6 +30,7 @@ import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse"; import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
import { normalizeStyleDrift } from "@/lib/style/style"; import { normalizeStyleDrift } from "@/lib/style/style";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { AcceptPanel } from "./AcceptPanel"; import { AcceptPanel } from "./AcceptPanel";
import { AnnotatedText } from "./AnnotatedText"; import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard"; import { ConflictCard } from "./ConflictCard";
@@ -318,6 +319,11 @@ export function ReviewReport({
</button> </button>
</div> </div>
{/* B讲清裁决保存模型——「采纳/裁决」仅本地暂存,验收时一并落库 */}
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
稿
</p>
{groups.map((group) => ( {groups.map((group) => (
<div key={group.type}> <div key={group.type}>
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink"> <h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink">
@@ -429,8 +435,8 @@ function SectionStatusLine({
}: SectionStatusLineProps) { }: SectionStatusLineProps) {
if (reviewing) { if (reviewing) {
return ( return (
<p className="mt-1 text-xs text-info" aria-live="polite"> <p className="mt-1 text-xs text-info">
稿 <ThinkingIndicator label="审稿进行中" />
</p> </p>
); );
} }

View File

@@ -3,6 +3,8 @@
import { useRefine } from "@/lib/style/useRefine"; import { useRefine } from "@/lib/style/useRefine";
import { useEffect } from "react"; import { useEffect } from "react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
interface RefineViewProps { interface RefineViewProps {
projectId: string; projectId: string;
chapterNo: number; chapterNo: number;
@@ -54,8 +56,8 @@ export function RefineView({
{segment.trim().length === 0 ? ( {segment.trim().length === 0 ? (
<p className="text-overdue">稿稿</p> <p className="text-overdue">稿稿</p>
) : refiner.status === "refining" ? ( ) : refiner.status === "refining" ? (
<p className="text-info" aria-live="polite"> <p className="text-info">
<ThinkingIndicator label="回炉中" />
</p> </p>
) : refiner.status === "error" ? ( ) : refiner.status === "error" ? (
<p className="text-conflict"></p> <p className="text-conflict"></p>

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { useEffect, useRef } from "react";
interface EditorProps { interface EditorProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
@@ -9,19 +11,32 @@ interface EditorProps {
// 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。 // 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。
// 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css // 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css
export function Editor({ value, onChange, streaming }: EditorProps) { export function Editor({ value, onChange, streaming }: EditorProps) {
const ref = useRef<HTMLTextAreaElement>(null);
// 自适应高度textarea 高度=内容高度overflow-hidden 不内部滚动),由外层写作区
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value]);
return ( return (
<div className="mx-auto max-w-prose"> <div className="mx-auto max-w-prose">
<label htmlFor="chapter-editor" className="sr-only"> <label htmlFor="chapter-editor" className="sr-only">
</label> </label>
<textarea <textarea
ref={ref}
id="chapter-editor" id="chapter-editor"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
readOnly={streaming} readOnly={streaming}
aria-busy={streaming} aria-busy={streaming}
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。" placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
className="min-h-[60vh] w-full resize-none bg-transparent font-serif text-[18px] leading-[1.9] text-ink placeholder:text-ink-soft/50 focus:outline-none" 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"
/> />
{streaming ? ( {streaming ? (
<span <span

View File

@@ -36,7 +36,7 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。 // 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState(""); const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]); const [presetIds, setPresetIds] = useState<string[]>([]);
const autosave = useAutosave(project.id, chapterNo); const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream(); const stream = useDraftStream();
const lastStreamText = useRef(""); const lastStreamText = useRef("");

View File

@@ -22,14 +22,24 @@ export interface UseAutosave {
// 防抖自动保存草稿:乐观置 saving → PUT 成功置 saved落「保存于 hh:mm」 // 防抖自动保存草稿:乐观置 saving → PUT 成功置 saved落「保存于 hh:mm」
// 失败回滚状态 + toast不丢正文已在编辑器里 // 失败回滚状态 + toast不丢正文已在编辑器里
// `initialSavedText`:进页种入的已存草稿——非空时初始即「已保存」,修「加载已存草稿却显示尚未保存」的误导。
export function useAutosave( export function useAutosave(
projectId: string, projectId: string,
chapterNo: number, chapterNo: number,
initialSavedText?: string,
): UseAutosave { ): UseAutosave {
const [status, setStatus] = useState<SaveStatus>("idle"); const hasInitialDraft = (initialSavedText ?? "").length > 0;
const [savedLabel, setSavedLabel] = useState<string | null>(null); const [status, setStatus] = useState<SaveStatus>(
hasInitialDraft ? "saved" : "idle",
);
const [savedLabel, setSavedLabel] = useState<string | null>(
hasInitialDraft ? "已保存" : null,
);
const toast = useToast(); const toast = useToast();
const lastSavedRef = useRef<string | null>(null); // 种入已存草稿为基线用户未改动时不触发冗余保存save 早退)。
const lastSavedRef = useRef<string | null>(
hasInitialDraft ? (initialSavedText as string) : null,
);
const save = useCallback( const save = useCallback(
async (text: string): Promise<void> => { async (text: string): Promise<void> => {