fix+feat(ux): 已存草稿显示已保存(Q6) + AI 推理动画指示器(A)
- Q6 bug: useAutosave 加 initialSavedText,进页种入已存草稿时初始即「已保存」,修「加载已存草稿却显示尚未保存」的误导。 - A: 新 ThinkingIndicator(三点波动,motion-safe 尊重 reduced-motion)接入审稿进行中/验收中/回炉中三处静态文案,长等待看得见在工作。
This commit is contained in:
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";
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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