feat(frontend): 进来即写——落地页「直接开始写」直达空白编辑器,敲字后延迟落库
Phase 0(写作工作台重构):新增 /write 草稿编辑器入口,只有敲入实质正文并停顿后 才懒建占位「未命名草稿」项目并种入首章草稿(useDeferredDraft,双重幂等闸 + 草稿落库 非致命降级),随即 replace 换入完整工作台;空进空出不落库,杜绝孤儿草稿。 落地页 CTA 改为「直接开始写」为主、「先立项」(立项向导)为辅。
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookOpen, Plus } from "lucide-react";
|
import { BookOpen, PenLine, Plus } from "lucide-react";
|
||||||
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||||
@@ -28,13 +28,22 @@ export default async function DashboardPage() {
|
|||||||
title="我的作品"
|
title="我的作品"
|
||||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||||
actions={
|
actions={
|
||||||
<Link
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
href="/projects/new"
|
<Link
|
||||||
className={buttonClass({ variant: "primary" })}
|
href="/write"
|
||||||
>
|
className={buttonClass({ variant: "primary" })}
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
>
|
||||||
新建作品
|
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
直接开始写
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/projects/new"
|
||||||
|
className={buttonClass({ variant: "secondary" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
先立项
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -44,15 +53,24 @@ export default async function DashboardPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title="还没有作品"
|
title="还没有作品"
|
||||||
description="从一句灵感开始,后续的设定库、大纲、写章和审稿都会围绕这本书展开。"
|
description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。"
|
||||||
action={
|
action={
|
||||||
<Link
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
href="/projects/new"
|
<Link
|
||||||
className={buttonClass({ variant: "primary" })}
|
href="/write"
|
||||||
>
|
className={buttonClass({ variant: "primary" })}
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
>
|
||||||
新建作品
|
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
直接开始写
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/projects/new"
|
||||||
|
className={buttonClass({ variant: "secondary" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
先立项
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
6
apps/web/app/write/page.tsx
Normal file
6
apps/web/app/write/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { DraftComposer } from "@/components/start/DraftComposer";
|
||||||
|
|
||||||
|
// 进来即写入口(无 project 前缀):直达空白编辑器,敲字后懒建项目并换入 /projects/[id]/write。
|
||||||
|
export default function WriteDraftPage() {
|
||||||
|
return <DraftComposer />;
|
||||||
|
}
|
||||||
58
apps/web/components/start/DraftComposer.tsx
Normal file
58
apps/web/components/start/DraftComposer.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
|
import { useDeferredDraft } from "@/lib/start/useDeferredDraft";
|
||||||
|
|
||||||
|
// 敲入正文后停顿多久才懒建项目(落库)——在停顿点落库,快照即所见,避免打断连续输入。
|
||||||
|
const CREATE_DEBOUNCE_MS = 700;
|
||||||
|
|
||||||
|
// 进来即写:空白编辑器直达。敲入实质正文并停顿后懒建占位项目 + 落首章草稿,随即换入完整工作台。
|
||||||
|
// 空进空出不落库(从不敲字 → 从不 POST)。落库逻辑在 useDeferredDraft(已单测),此处只管交互。
|
||||||
|
export function DraftComposer() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { ensureCreated } = useDeferredDraft();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const textRef = useRef("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const redirectingRef = useRef(false);
|
||||||
|
|
||||||
|
const tryCreate = async (): Promise<void> => {
|
||||||
|
if (redirectingRef.current) return;
|
||||||
|
const snapshot = textRef.current;
|
||||||
|
if (snapshot.trim().length === 0) return;
|
||||||
|
const id = await ensureCreated(snapshot);
|
||||||
|
if (id && !redirectingRef.current) {
|
||||||
|
redirectingRef.current = true;
|
||||||
|
router.replace(`/projects/${id}/write`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = (value: string): void => {
|
||||||
|
setText(value);
|
||||||
|
textRef.current = value;
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => void tryCreate(), CREATE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="mx-auto flex min-h-[calc(100vh-var(--chrome,4rem))] max-w-3xl flex-col px-6 py-10 sm:px-8">
|
||||||
|
<p className="mb-4 text-sm text-ink-soft">
|
||||||
|
直接开始写——落笔即为你建立作品,正文自动保存。书名、设定、大纲都可在写作中随时补齐。
|
||||||
|
</p>
|
||||||
|
<TextArea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="min-h-[60vh] flex-1 resize-none text-lg leading-8"
|
||||||
|
placeholder="从这里开始写你的第一章……"
|
||||||
|
autoFocus
|
||||||
|
aria-label="正文"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
apps/web/lib/start/useDeferredDraft.test.ts
Normal file
134
apps/web/lib/start/useDeferredDraft.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
95
apps/web/lib/start/useDeferredDraft.ts
Normal file
95
apps/web/lib/start/useDeferredDraft.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user