fix+feat(ux): 已存草稿显示已保存(Q6) + AI 推理动画指示器(A)

- Q6 bug: useAutosave 加 initialSavedText,进页种入已存草稿时初始即「已保存」,修「加载已存草稿却显示尚未保存」的误导。
- A: 新 ThinkingIndicator(三点波动,motion-safe 尊重 reduced-motion)接入审稿进行中/验收中/回炉中三处静态文案,长等待看得见在工作。
This commit is contained in:
Yaojia Wang
2026-06-21 18:05:55 +02:00
parent 10c0d4bead
commit c51623836f
6 changed files with 59 additions and 9 deletions

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";
@@ -429,8 +430,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

@@ -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> => {