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:
@@ -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 />
|
||||||
|
|||||||
32
apps/web/components/ThinkingIndicator.tsx
Normal file
32
apps/web/components/ThinkingIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.9(UX §2.3 / §6.3)。
|
// 中栏正文编辑器:宋体、720px 居中、行高 1.9(UX §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
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user