diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 48d8def..441d137 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,5 +1,5 @@
import Link from "next/link";
-import { BookOpen, Plus } from "lucide-react";
+import { BookOpen, PenLine, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice";
@@ -28,13 +28,22 @@ export default async function DashboardPage() {
title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。"
actions={
-
-
- 新建作品
-
+
+
+
+ 直接开始写
+
+
+
+ 先立项
+
+
}
/>
@@ -44,15 +53,24 @@ export default async function DashboardPage() {
-
- 新建作品
-
+
+
+
+ 直接开始写
+
+
+
+ 先立项
+
+
}
/>
) : (
diff --git a/apps/web/app/write/page.tsx b/apps/web/app/write/page.tsx
new file mode 100644
index 0000000..90949bd
--- /dev/null
+++ b/apps/web/app/write/page.tsx
@@ -0,0 +1,6 @@
+import { DraftComposer } from "@/components/start/DraftComposer";
+
+// 进来即写入口(无 project 前缀):直达空白编辑器,敲字后懒建项目并换入 /projects/[id]/write。
+export default function WriteDraftPage() {
+ return ;
+}
diff --git a/apps/web/components/start/DraftComposer.tsx b/apps/web/components/start/DraftComposer.tsx
new file mode 100644
index 0000000..a621fd9
--- /dev/null
+++ b/apps/web/components/start/DraftComposer.tsx
@@ -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 | null>(null);
+ const redirectingRef = useRef(false);
+
+ const tryCreate = async (): Promise => {
+ 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 (
+
+
+
+ 直接开始写——落笔即为你建立作品,正文自动保存。书名、设定、大纲都可在写作中随时补齐。
+
+
+
+ );
+}
diff --git a/apps/web/lib/start/useDeferredDraft.test.ts b/apps/web/lib/start/useDeferredDraft.test.ts
new file mode 100644
index 0000000..c1fe2d4
--- /dev/null
+++ b/apps/web/lib/start/useDeferredDraft.test.ts
@@ -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");
+ });
+});
diff --git a/apps/web/lib/start/useDeferredDraft.ts b/apps/web/lib/start/useDeferredDraft.ts
new file mode 100644
index 0000000..6c55262
--- /dev/null
+++ b/apps/web/lib/start/useDeferredDraft.ts
@@ -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;
+}
+
+export function useDeferredDraft(): UseDeferredDraft {
+ const [status, setStatus] = useState("idle");
+ const toast = useToast();
+ // 已建项目 id 与在途 Promise:双重幂等闸,防抖多次触发只落一次库。
+ const createdIdRef = useRef(null);
+ const inflightRef = useRef | null>(null);
+
+ const ensureCreated = useCallback(
+ (text: string): Promise => {
+ 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;
+
+// 懒建项目 + 落首章草稿(草稿失败为非致命:项目已建,正文仍在前端,进编辑器后自动重存)。
+async function createDraftProject(
+ text: string,
+ toast: Toast,
+ setStatus: (s: DeferredStatus) => void,
+): Promise {
+ 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 {
+ 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");
+ }
+}