P1-6 useStyleLearn/useKimiOauth 修 stale-closure 依赖。 P1-7 CommandPalette/Drawer 加 focus trap(新 lib/a11y/focusTrap)。 P1-8 SSE 解析与 server.ts 去 as 强转改类型守卫/运行时校验。 P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理+稳定 key、列表 key 稳定化、 rel=noopener、useMemo 大文本、ConflictCard role=group、useInjection 加锁、 client.test 真断言。 codegen 重生成 schema.d.ts + 消费 DimensionEntry/ReviewConflictView 强类型。
87 lines
2.6 KiB
TypeScript
87 lines
2.6 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import { api } from "@/lib/api/client";
|
||
import { useToast } from "@/components/Toast";
|
||
import {
|
||
AUTOSAVE_DELAY_MS,
|
||
createDebouncedSaver,
|
||
formatSavedAt,
|
||
type DebouncedSaver,
|
||
} from "./autosave";
|
||
|
||
export type SaveStatus = "idle" | "saving" | "saved" | "error";
|
||
|
||
export interface UseAutosave {
|
||
status: SaveStatus;
|
||
savedLabel: string | null;
|
||
onChange: (text: string) => void;
|
||
flush: () => void;
|
||
}
|
||
|
||
// 防抖自动保存草稿:乐观置 saving → PUT 成功置 saved(落「保存于 hh:mm」);
|
||
// 失败回滚状态 + toast,不丢正文(已在编辑器里)。
|
||
// `initialSavedText`:进页种入的已存草稿——非空时初始即「已保存」,修「加载已存草稿却显示尚未保存」的误导。
|
||
export function useAutosave(
|
||
projectId: string,
|
||
chapterNo: number,
|
||
initialSavedText?: string,
|
||
): UseAutosave {
|
||
// 归一为非空基线(undefined → ""),避免后续非空断言/强转。
|
||
const initialDraft = initialSavedText ?? "";
|
||
const hasInitialDraft = initialDraft.length > 0;
|
||
const [status, setStatus] = useState<SaveStatus>(
|
||
hasInitialDraft ? "saved" : "idle",
|
||
);
|
||
const [savedLabel, setSavedLabel] = useState<string | null>(
|
||
hasInitialDraft ? "已保存" : null,
|
||
);
|
||
const toast = useToast();
|
||
// 种入已存草稿为基线:用户未改动时不触发冗余保存(save 早退)。
|
||
const lastSavedRef = useRef<string | null>(
|
||
hasInitialDraft ? initialDraft : null,
|
||
);
|
||
|
||
const save = useCallback(
|
||
async (text: string): Promise<void> => {
|
||
if (text === lastSavedRef.current) return;
|
||
setStatus("saving");
|
||
const { error } = await api.PUT(
|
||
"/projects/{project_id}/chapters/{chapter_no}/draft",
|
||
{
|
||
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||
body: { text },
|
||
},
|
||
);
|
||
if (error) {
|
||
setStatus("error");
|
||
toast("自动保存失败,稍后重试(正文未丢失)", "error");
|
||
return;
|
||
}
|
||
lastSavedRef.current = text;
|
||
setSavedLabel(formatSavedAt(new Date()));
|
||
setStatus("saved");
|
||
},
|
||
[projectId, chapterNo, toast],
|
||
);
|
||
|
||
const saver: DebouncedSaver = useMemo(
|
||
() => createDebouncedSaver(save, AUTOSAVE_DELAY_MS),
|
||
[save],
|
||
);
|
||
|
||
useEffect(() => () => saver.cancel(), [saver]);
|
||
|
||
const onChange = useCallback(
|
||
(text: string) => {
|
||
saver.schedule(text);
|
||
},
|
||
[saver],
|
||
);
|
||
|
||
const flush = useCallback(() => saver.flush(), [saver]);
|
||
|
||
return { status, savedLabel, onChange, flush };
|
||
}
|