feat(frontend): 进来即写——落地页「直接开始写」直达空白编辑器,敲字后延迟落库

Phase 0(写作工作台重构):新增 /write 草稿编辑器入口,只有敲入实质正文并停顿后
才懒建占位「未命名草稿」项目并种入首章草稿(useDeferredDraft,双重幂等闸 + 草稿落库
非致命降级),随即 replace 换入完整工作台;空进空出不落库,杜绝孤儿草稿。
落地页 CTA 改为「直接开始写」为主、「先立项」(立项向导)为辅。
This commit is contained in:
Yaojia Wang
2026-07-07 18:18:18 +02:00
parent e703d0a65c
commit 8389572cbb
5 changed files with 327 additions and 16 deletions

View File

@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DRAFT_FIRST_CHAPTER,
DRAFT_PLACEHOLDER_TITLE,
useDeferredDraft,
} from "./useDeferredDraft";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const post = vi.fn();
const put = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: {
POST: (...a: unknown[]) => post(...a),
PUT: (...a: unknown[]) => put(...a),
},
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
describe("useDeferredDraft", () => {
beforeEach(() => {
post.mockReset();
put.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("初始为 idle", () => {
const { result } = renderHook(() => useDeferredDraft());
expect(result.current.status).toBe("idle");
});
it("首次落库:懒建占位项目 + 用已敲入正文种首章草稿,返回新项目 id", async () => {
post.mockResolvedValue({ data: { id: "p1" }, error: null });
put.mockResolvedValue({ data: {}, error: null });
const { result } = renderHook(() => useDeferredDraft());
let id: string | null = null;
await act(async () => {
id = await result.current.ensureCreated("第一段正文");
});
expect(id).toBe("p1");
expect(result.current.status).toBe("created");
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/projects", {
body: { title: DRAFT_PLACEHOLDER_TITLE },
});
expect(put).toHaveBeenCalledWith(
"/projects/{project_id}/chapters/{chapter_no}/draft",
{
params: { path: { project_id: "p1", chapter_no: DRAFT_FIRST_CHAPTER } },
body: { text: "第一段正文" },
},
);
expect(toast).not.toHaveBeenCalled();
});
it("幂等:已建后再次调用复用同一 id绝不重复 POST", async () => {
post.mockResolvedValue({ data: { id: "p1" }, error: null });
put.mockResolvedValue({ data: {}, error: null });
const { result } = renderHook(() => useDeferredDraft());
await act(async () => {
await result.current.ensureCreated("正文一");
});
let second: string | null = null;
await act(async () => {
second = await result.current.ensureCreated("正文二");
});
expect(second).toBe("p1");
expect(post).toHaveBeenCalledTimes(1);
});
it("正文为空白:仍懒建项目但跳过草稿落库", async () => {
post.mockResolvedValue({ data: { id: "p1" }, error: null });
const { result } = renderHook(() => useDeferredDraft());
await act(async () => {
await result.current.ensureCreated(" ");
});
expect(post).toHaveBeenCalledTimes(1);
expect(put).not.toHaveBeenCalled();
});
it("创建失败status=error、弹错误 toast、返回 null、不落草稿", async () => {
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
const { result } = renderHook(() => useDeferredDraft());
let id: string | null = "sentinel";
await act(async () => {
id = await result.current.ensureCreated("正文");
});
expect(id).toBeNull();
expect(result.current.status).toBe("error");
expect(put).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("草稿落库失败为非致命:项目已建仍返回 id提示但不阻断", async () => {
post.mockResolvedValue({ data: { id: "p1" }, error: null });
put.mockResolvedValue({ data: null, error: { detail: "写库失败" } });
const { result } = renderHook(() => useDeferredDraft());
let id: string | null = null;
await act(async () => {
id = await result.current.ensureCreated("正文");
});
expect(id).toBe("p1");
expect(result.current.status).toBe("created");
expect(toast).toHaveBeenCalledWith(expect.any(String), "info");
});
it("请求抛异常status=error、弹网络异常 toast、返回 null", async () => {
post.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useDeferredDraft());
let id: string | null = "sentinel";
await act(async () => {
id = await result.current.ensureCreated("正文");
});
expect(id).toBeNull();
expect(result.current.status).toBe("error");
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
});

View File

@@ -0,0 +1,95 @@
"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");
}
}