Phase 0(写作工作台重构):新增 /write 草稿编辑器入口,只有敲入实质正文并停顿后 才懒建占位「未命名草稿」项目并种入首章草稿(useDeferredDraft,双重幂等闸 + 草稿落库 非致命降级),随即 replace 换入完整工作台;空进空出不落库,杜绝孤儿草稿。 落地页 CTA 改为「直接开始写」为主、「先立项」(立项向导)为辅。
96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useRef, useState } from "react";
|
||
|
||
import { api } from "@/lib/api/client";
|
||
import { useToast } from "@/components/Toast";
|
||
|
||
// 进来即写(延迟落库):落地页「直接开始写」直达空白编辑器,**只有敲入实质正文后**
|
||
// 才懒建占位项目并把已敲入正文种进首章草稿。空进空出不落库,杜绝孤儿「未命名草稿」。
|
||
export const DRAFT_PLACEHOLDER_TITLE = "未命名草稿";
|
||
export const DRAFT_FIRST_CHAPTER = 1;
|
||
|
||
export type DeferredStatus = "idle" | "creating" | "created" | "error";
|
||
|
||
export interface UseDeferredDraft {
|
||
status: DeferredStatus;
|
||
// 首次有实质正文时调用:懒建占位项目 + 落首章草稿,返回新项目 id(调用方据此跳转编辑器)。
|
||
// 幂等:已建/在建时复用同一结果,绝不重复 POST。失败返回 null(不跳转)。
|
||
ensureCreated: (text: string) => Promise<string | null>;
|
||
}
|
||
|
||
export function useDeferredDraft(): UseDeferredDraft {
|
||
const [status, setStatus] = useState<DeferredStatus>("idle");
|
||
const toast = useToast();
|
||
// 已建项目 id 与在途 Promise:双重幂等闸,防抖多次触发只落一次库。
|
||
const createdIdRef = useRef<string | null>(null);
|
||
const inflightRef = useRef<Promise<string | null> | null>(null);
|
||
|
||
const ensureCreated = useCallback(
|
||
(text: string): Promise<string | null> => {
|
||
if (createdIdRef.current) return Promise.resolve(createdIdRef.current);
|
||
if (inflightRef.current) return inflightRef.current;
|
||
|
||
const run = createDraftProject(text, toast, setStatus).then((id) => {
|
||
inflightRef.current = null;
|
||
if (id) createdIdRef.current = id;
|
||
return id;
|
||
});
|
||
inflightRef.current = run;
|
||
return run;
|
||
},
|
||
[toast],
|
||
);
|
||
|
||
return { status, ensureCreated };
|
||
}
|
||
|
||
type Toast = ReturnType<typeof useToast>;
|
||
|
||
// 懒建项目 + 落首章草稿(草稿失败为非致命:项目已建,正文仍在前端,进编辑器后自动重存)。
|
||
async function createDraftProject(
|
||
text: string,
|
||
toast: Toast,
|
||
setStatus: (s: DeferredStatus) => void,
|
||
): Promise<string | null> {
|
||
setStatus("creating");
|
||
try {
|
||
const { data, error } = await api.POST("/projects", {
|
||
body: { title: DRAFT_PLACEHOLDER_TITLE },
|
||
});
|
||
if (error || !data) {
|
||
setStatus("error");
|
||
toast("创建草稿失败,请重试", "error");
|
||
return null;
|
||
}
|
||
await seedFirstChapter(data.id, text, toast);
|
||
setStatus("created");
|
||
return data.id;
|
||
} catch {
|
||
setStatus("error");
|
||
toast("创建草稿异常,请检查网络", "error");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 把已敲入正文种进首章草稿;空白正文跳过。落库失败仅提示、不抛(项目已建,不阻断进入编辑器)。
|
||
async function seedFirstChapter(
|
||
projectId: string,
|
||
text: string,
|
||
toast: Toast,
|
||
): Promise<void> {
|
||
if (text.trim().length === 0) return;
|
||
const { error } = await api.PUT(
|
||
"/projects/{project_id}/chapters/{chapter_no}/draft",
|
||
{
|
||
params: {
|
||
path: { project_id: projectId, chapter_no: DRAFT_FIRST_CHAPTER },
|
||
},
|
||
body: { text },
|
||
},
|
||
);
|
||
if (error) {
|
||
toast("首段草稿保存失败,进入编辑器后会自动重试", "info");
|
||
}
|
||
}
|