feat: Phase 0 — monorepo 骨架 + 全表迁移 + FastAPI/Next 骨架 + CI
- uv(workspace) + pnpm monorepo;docker-compose(pg+api+web) - SQLAlchemy 16 MVP 表 + Alembic 初版迁移(无漂移,users stub) - FastAPI 骨架:统一错误信封(带 request_id) + structlog + /jobs/:id + OpenAPI - Next.js 骨架:纸感主题 token + OpenAPI→TS 客户端代码生成(gen:api) - CI(ruff/mypy/pytest + pg service + alembic 漂移校验) - 四份设计规格(PRODUCT/UX/ARCHITECTURE/DEV_PLAN) + CLAUDE.md
This commit is contained in:
1
apps/web/.eslintrc.json
Normal file
1
apps/web/.eslintrc.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "extends": "next/core-web-vitals" }
|
||||
4
apps/web/.gitignore
vendored
Normal file
4
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules
|
||||
/.next
|
||||
/lib/api/openapi.json
|
||||
next-env.d.ts
|
||||
15
apps/web/Dockerfile
Normal file
15
apps/web/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-slim AS build
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:22-slim AS run
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
50
apps/web/app/globals.css
Normal file
50
apps/web/app/globals.css
Normal file
@@ -0,0 +1,50 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 纸感设计 token(UX_SPEC §2.1) */
|
||||
:root {
|
||||
--color-bg: #f5f1e8;
|
||||
--color-panel: #fbf8f1;
|
||||
--color-ink: #2b2620;
|
||||
--color-ink-soft: #6b6356;
|
||||
--color-line: #e5ddcd;
|
||||
--color-cinnabar: #a23b2e;
|
||||
--color-cinnabar-wash: #a23b2e14;
|
||||
--color-conflict: #b5543a;
|
||||
--color-overdue: #c8893a;
|
||||
--color-pass: #5a6b4f;
|
||||
--color-info: #4a5a6b;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motion(UX §10)。 */
|
||||
.typewriter-cursor {
|
||||
animation: typewriter-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes typewriter-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 冲突就地标注锚点:朱砂波浪下划线(UX §8.3)。 */
|
||||
.conflict-anchor {
|
||||
text-decoration: underline wavy var(--color-conflict);
|
||||
text-underline-offset: 3px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typewriter-cursor {
|
||||
animation: none;
|
||||
}
|
||||
.conflict-anchor {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
23
apps/web/app/layout.tsx
Normal file
23
apps/web/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "网文创作工作流",
|
||||
description: "把写小说当软件工程:架构→生成→质检→单一真相源",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="font-sans antialiased">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
46
apps/web/components/AppShell.tsx
Normal file
46
apps/web/components/AppShell.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { LeftNav } from "./LeftNav";
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
// 顶栏右侧的上下文信息(如作品名 / 进度)。
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
// 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。
|
||||
export function AppShell({ children, title, subtitle }: AppShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-serif text-xl text-cinnabar"
|
||||
aria-label="返回作品库"
|
||||
>
|
||||
墨痕
|
||||
</Link>
|
||||
{title ? (
|
||||
<span className="font-serif text-lg text-ink">{title}</span>
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
<span className="font-mono text-xs text-ink-soft">{subtitle}</span>
|
||||
) : null}
|
||||
<nav className="ml-auto flex items-center gap-4 text-sm">
|
||||
<Link
|
||||
href="/settings/providers"
|
||||
className="text-ink-soft hover:text-cinnabar"
|
||||
>
|
||||
设置
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<div className="flex">
|
||||
<LeftNav />
|
||||
<main className="min-h-[calc(100vh-4rem)] flex-1 bg-bg">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
apps/web/components/LeftNav.tsx
Normal file
66
apps/web/components/LeftNav.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
// M1 仅作品库 + 设置可达;其余为后续里程碑占位(禁用)。
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "作品库", enabled: true },
|
||||
{ href: "/settings/providers", label: "设置", enabled: true },
|
||||
{ href: "#outline", label: "大纲", enabled: false },
|
||||
{ href: "#codex", label: "设定库", enabled: false },
|
||||
{ href: "#foreshadow", label: "伏笔", enabled: false },
|
||||
{ href: "#review", label: "审稿", enabled: false },
|
||||
{ href: "#style", label: "文风", enabled: false },
|
||||
];
|
||||
|
||||
// 左导航:当前项朱砂竖条 + 浅晕底(UX §5.1)。
|
||||
export function LeftNav() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav
|
||||
className="w-44 shrink-0 border-r border-line bg-panel py-4"
|
||||
aria-label="主导航"
|
||||
>
|
||||
<ul className="flex flex-col gap-1 px-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.enabled && pathname === item.href;
|
||||
if (!item.enabled) {
|
||||
return (
|
||||
<li key={item.label}>
|
||||
<span
|
||||
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
|
||||
aria-disabled="true"
|
||||
title="后续里程碑开放"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
|
||||
active
|
||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
73
apps/web/components/Toast.tsx
Normal file
73
apps/web/components/Toast.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type ToastKind = "info" | "error" | "success";
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
type ShowToast = (message: string, kind?: ToastKind) => void;
|
||||
|
||||
const ToastContext = createContext<ShowToast | null>(null);
|
||||
|
||||
const TOAST_TTL_MS = 4000;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
|
||||
const show = useCallback<ShowToast>((message, kind = "info") => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems((prev) => [...prev, { id, message, kind }]);
|
||||
setTimeout(() => {
|
||||
setItems((prev) => prev.filter((t) => t.id !== id));
|
||||
}, TOAST_TTL_MS);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => show, [show]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto rounded border px-4 py-2 text-sm shadow-paper ${
|
||||
t.kind === "error"
|
||||
? "border-conflict bg-panel text-conflict"
|
||||
: t.kind === "success"
|
||||
? "border-pass bg-panel text-pass"
|
||||
: "border-line bg-panel text-ink"
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ShowToast {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
// 容错:未挂 Provider 时退化为 no-op(不应在生产路径发生)。
|
||||
return () => undefined;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
8
apps/web/lib/api/client.test.ts
Normal file
8
apps/web/lib/api/client.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("api base url", () => {
|
||||
it("falls back to localhost when env unset", () => {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
expect(base).toMatch(/^https?:\/\//);
|
||||
});
|
||||
});
|
||||
8
apps/web/lib/api/client.ts
Normal file
8
apps/web/lib/api/client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import createClient from "openapi-fetch";
|
||||
|
||||
import type { paths } from "./schema";
|
||||
import { API_BASE_PUBLIC } from "./config";
|
||||
|
||||
// 类型安全的后端客户端(schema.d.ts 由 `pnpm gen:api` 从后端 OpenAPI 生成)。
|
||||
// 浏览器侧使用 NEXT_PUBLIC_API_BASE;服务端读取请走 lib/api/server.ts。
|
||||
export const api = createClient<paths>({ baseUrl: API_BASE_PUBLIC });
|
||||
8
apps/web/lib/api/config.ts
Normal file
8
apps/web/lib/api/config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// 后端基址:服务端组件用 INTERNAL(容器内 service 名),客户端用 PUBLIC(浏览器可达)。
|
||||
// 两者皆可缺省回退到本地开发地址。
|
||||
export const API_BASE_PUBLIC =
|
||||
process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
|
||||
export function serverApiBase(): string {
|
||||
return process.env.API_BASE_INTERNAL ?? API_BASE_PUBLIC;
|
||||
}
|
||||
987
apps/web/lib/api/schema.d.ts
vendored
Normal file
987
apps/web/lib/api/schema.d.ts
vendored
Normal file
@@ -0,0 +1,987 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Root */
|
||||
get: operations["root__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Health */
|
||||
get: operations["health_health_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/jobs/{job_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Job */
|
||||
get: operations["get_job_jobs__job_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Projects */
|
||||
get: operations["list_projects_projects_get"];
|
||||
put?: never;
|
||||
/** Create Project */
|
||||
post: operations["create_project_projects_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Project */
|
||||
get: operations["get_project_projects__project_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chapters/{chapter_no}/draft": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Save Draft
|
||||
* @description 自动保存:幂等 upsert 草稿(同章节覆盖同一行,版次不爆炸)。
|
||||
*/
|
||||
put: operations["save_draft_projects__project_id__chapters__chapter_no__draft_put"];
|
||||
/**
|
||||
* Stream Draft
|
||||
* @description 流式写章草稿:组装记忆 → 网关流 → 归一为 SSE 事件 → text/event-stream。
|
||||
*/
|
||||
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chapters/{chapter_no}/review": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Review Chapter
|
||||
* @description 续审(SSE):组审稿上下文 → 跑审稿子图 → 归一为 section/conflict/done 事件。
|
||||
*
|
||||
* 提交边界:网关 ledger + collect 经 review_repo.record 均只 flush;端点在**流耗尽后**
|
||||
* `await session.commit()`(镜像 draft 端点,否则记账/留痕静默丢失)。
|
||||
*/
|
||||
post: operations["review_chapter_projects__project_id__chapters__chapter_no__review_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chapters/{chapter_no}/reviews": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Reviews
|
||||
* @description 审稿历史(新→旧):供前端审稿页加载既往审稿留痕 + 裁决。
|
||||
*/
|
||||
get: operations["list_reviews_projects__project_id__chapters__chapter_no__reviews_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chapters/{chapter_no}/accept": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Accept Chapter
|
||||
* @description 验收事务 + 冲突 gate(§5.5):gate(事务前)→ 终稿提炼 digest(事务外,R2)→
|
||||
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
|
||||
*/
|
||||
post: operations["accept_chapter_projects__project_id__chapters__chapter_no__accept_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Providers */
|
||||
get: operations["list_providers_settings_providers_get"];
|
||||
/** Upsert Providers */
|
||||
put: operations["upsert_providers_settings_providers_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings/providers/test": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Test Connection */
|
||||
post: operations["test_connection_settings_providers_test_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/**
|
||||
* AcceptRequest
|
||||
* @description POST /projects/:id/chapters/:no/accept:裁决清单 + 可能改过的终稿。
|
||||
*/
|
||||
AcceptRequest: {
|
||||
/**
|
||||
* Final Text
|
||||
* @description 作者裁决/改稿后的最终验收文本
|
||||
*/
|
||||
final_text: string;
|
||||
/**
|
||||
* Decisions
|
||||
* @description 对最近审稿每个冲突的裁决(每冲突必有其一,R5)
|
||||
*/
|
||||
decisions?: components["schemas"]["ConflictDecision"][];
|
||||
};
|
||||
/**
|
||||
* AcceptResponse
|
||||
* @description 验收「本次将更新」清单(ARCH §7.2 写回结果)。
|
||||
*/
|
||||
AcceptResponse: {
|
||||
/**
|
||||
* Project Id
|
||||
* Format: uuid
|
||||
*/
|
||||
project_id: string;
|
||||
/** Chapter No */
|
||||
chapter_no: number;
|
||||
/**
|
||||
* Accepted Version
|
||||
* @description 晋升到的 accepted 版次(max+1)
|
||||
*/
|
||||
accepted_version: number;
|
||||
/**
|
||||
* Digest Added
|
||||
* @description 是否新增了一行 chapter_digests
|
||||
*/
|
||||
digest_added: boolean;
|
||||
/**
|
||||
* Decisions Recorded
|
||||
* @description 本次写回的裁决条数
|
||||
*/
|
||||
decisions_recorded: number;
|
||||
/**
|
||||
* Review Id
|
||||
* @description 写回裁决的审稿留痕行 id
|
||||
*/
|
||||
review_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* CapabilitiesView
|
||||
* @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。
|
||||
*/
|
||||
CapabilitiesView: {
|
||||
/**
|
||||
* Structured Output
|
||||
* @default false
|
||||
*/
|
||||
structured_output: boolean;
|
||||
/**
|
||||
* Prefix Cache
|
||||
* @default false
|
||||
*/
|
||||
prefix_cache: boolean;
|
||||
/**
|
||||
* Thinking
|
||||
* @default false
|
||||
*/
|
||||
thinking: boolean;
|
||||
};
|
||||
/**
|
||||
* ConflictDecision
|
||||
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
|
||||
*/
|
||||
ConflictDecision: {
|
||||
/**
|
||||
* Conflict Index
|
||||
* @description 冲突在最近审稿 conflicts 列表中的下标
|
||||
*/
|
||||
conflict_index: number;
|
||||
/**
|
||||
* Verdict
|
||||
* @description 采纳改法 / 忽略 / 手改
|
||||
* @enum {string}
|
||||
*/
|
||||
verdict: "accept" | "ignore" | "manual";
|
||||
/**
|
||||
* Note
|
||||
* @description 可选裁决备注(如手改说明)
|
||||
*/
|
||||
note?: string | null;
|
||||
};
|
||||
/**
|
||||
* DraftResponse
|
||||
* @description 草稿保存结果(脱敏:只回元信息 + 长度,不回灌正文以外的衍生)。
|
||||
*/
|
||||
DraftResponse: {
|
||||
/**
|
||||
* Project Id
|
||||
* Format: uuid
|
||||
*/
|
||||
project_id: string;
|
||||
/** Chapter No */
|
||||
chapter_no: number;
|
||||
/** Volume */
|
||||
volume: number;
|
||||
/** Status */
|
||||
status: string;
|
||||
/** Version */
|
||||
version: number;
|
||||
/** Length */
|
||||
length: number;
|
||||
};
|
||||
/**
|
||||
* DraftSaveRequest
|
||||
* @description PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。
|
||||
*/
|
||||
DraftSaveRequest: {
|
||||
/** Text */
|
||||
text: string;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/**
|
||||
* ProjectCreateRequest
|
||||
* @description POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。
|
||||
*/
|
||||
ProjectCreateRequest: {
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Genre */
|
||||
genre?: string | null;
|
||||
/** Logline */
|
||||
logline?: string | null;
|
||||
/** Premise */
|
||||
premise?: string | null;
|
||||
/** Theme */
|
||||
theme?: string | null;
|
||||
/** Selling Points */
|
||||
selling_points?: unknown[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
};
|
||||
/**
|
||||
* ProjectListResponse
|
||||
* @description GET /projects:项目列表。
|
||||
*/
|
||||
ProjectListResponse: {
|
||||
/** Projects */
|
||||
projects?: components["schemas"]["ProjectResponse"][];
|
||||
};
|
||||
/**
|
||||
* ProjectResponse
|
||||
* @description 项目视图(创建/列表/详情共用)。
|
||||
*/
|
||||
ProjectResponse: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Genre */
|
||||
genre?: string | null;
|
||||
/** Logline */
|
||||
logline?: string | null;
|
||||
/** Premise */
|
||||
premise?: string | null;
|
||||
/** Theme */
|
||||
theme?: string | null;
|
||||
/** Selling Points */
|
||||
selling_points?: unknown[];
|
||||
/** Structure */
|
||||
structure?: string | null;
|
||||
};
|
||||
/**
|
||||
* ProviderCredentialInput
|
||||
* @description 单条提供商凭据写入。
|
||||
*/
|
||||
ProviderCredentialInput: {
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Api Key */
|
||||
api_key: string;
|
||||
};
|
||||
/**
|
||||
* ProviderView
|
||||
* @description 已配置提供商(掩码视图)。
|
||||
*/
|
||||
ProviderView: {
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Masked Key */
|
||||
masked_key: string;
|
||||
};
|
||||
/**
|
||||
* ProvidersResponse
|
||||
* @description GET/PUT 响应:已配置提供商(掩码)+ 当前档位路由。
|
||||
*/
|
||||
ProvidersResponse: {
|
||||
/** Providers */
|
||||
providers?: components["schemas"]["ProviderView"][];
|
||||
/** Tier Routing */
|
||||
tier_routing?: components["schemas"]["TierRoutingView"][];
|
||||
};
|
||||
/**
|
||||
* ProvidersUpsertRequest
|
||||
* @description PUT 请求:可同时 upsert 若干凭据与档位路由(幂等)。
|
||||
*/
|
||||
ProvidersUpsertRequest: {
|
||||
/** Credentials */
|
||||
credentials?: components["schemas"]["ProviderCredentialInput"][];
|
||||
/** Tier Routing */
|
||||
tier_routing?: components["schemas"]["TierRoutingInput"][];
|
||||
};
|
||||
/**
|
||||
* ReviewHistoryItem
|
||||
* @description 单条审稿留痕(GET .../reviews 历史项;snake_case)。
|
||||
*/
|
||||
ReviewHistoryItem: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Project Id
|
||||
* Format: uuid
|
||||
*/
|
||||
project_id: string;
|
||||
/** Chapter No */
|
||||
chapter_no: number;
|
||||
/** Chapter Version */
|
||||
chapter_version?: number | null;
|
||||
/** Conflicts */
|
||||
conflicts?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
/** Foreshadow Sug */
|
||||
foreshadow_sug?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
/** Style */
|
||||
style?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Pace */
|
||||
pace?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Health Score */
|
||||
health_score?: number | null;
|
||||
/** Decisions */
|
||||
decisions?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
/**
|
||||
* ReviewHistoryResponse
|
||||
* @description GET /projects/:id/chapters/:no/reviews:审稿历史(新→旧)。
|
||||
*/
|
||||
ReviewHistoryResponse: {
|
||||
/** Reviews */
|
||||
reviews?: components["schemas"]["ReviewHistoryItem"][];
|
||||
};
|
||||
/**
|
||||
* ReviewRequest
|
||||
* @description POST /projects/:id/chapters/:no/review:可选携带待审草稿正文。
|
||||
*
|
||||
* 不传 `draft` 时端点回退到已保存的草稿(chapter_repo)。
|
||||
*/
|
||||
ReviewRequest: {
|
||||
/** Draft */
|
||||
draft?: string | null;
|
||||
};
|
||||
/**
|
||||
* TestConnectionRequest
|
||||
* @description POST /test:最小探测请求。
|
||||
*/
|
||||
TestConnectionRequest: {
|
||||
/** Provider */
|
||||
provider: string;
|
||||
};
|
||||
/**
|
||||
* TestConnectionResponse
|
||||
* @description POST /test 响应:连通性 + 能力矩阵。
|
||||
*/
|
||||
TestConnectionResponse: {
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Ok */
|
||||
ok: boolean;
|
||||
capabilities: components["schemas"]["CapabilitiesView"];
|
||||
};
|
||||
/**
|
||||
* TierRoutingInput
|
||||
* @description 单条档位路由写入。
|
||||
*/
|
||||
TierRoutingInput: {
|
||||
/** Tier */
|
||||
tier: string;
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Model */
|
||||
model: string;
|
||||
/** Fallback */
|
||||
fallback?: string[];
|
||||
};
|
||||
/**
|
||||
* TierRoutingView
|
||||
* @description 档位 → provider:model 路由(含回退链)。
|
||||
*/
|
||||
TierRoutingView: {
|
||||
/** Tier */
|
||||
tier: string;
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Model */
|
||||
model: string;
|
||||
/** Fallback */
|
||||
fallback?: string[];
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
loc: (string | number)[];
|
||||
/** Message */
|
||||
msg: string;
|
||||
/** Error Type */
|
||||
type: string;
|
||||
/** Input */
|
||||
input?: unknown;
|
||||
/** Context */
|
||||
ctx?: Record<string, never>;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
root__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
health_health_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_job_jobs__job_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
job_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_projects_projects_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_project_projects_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectCreateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_project_projects__project_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
save_draft_projects__project_id__chapters__chapter_no__draft_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
chapter_no: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DraftSaveRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DraftResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
stream_draft_projects__project_id__chapters__chapter_no__draft_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
chapter_no: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
review_chapter_projects__project_id__chapters__chapter_no__review_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
chapter_no: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReviewRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_reviews_projects__project_id__chapters__chapter_no__reviews_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
chapter_no: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ReviewHistoryResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
accept_chapter_projects__project_id__chapters__chapter_no__accept_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
chapter_no: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AcceptRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AcceptResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_providers_settings_providers_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProvidersResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
upsert_providers_settings_providers_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProvidersUpsertRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProvidersResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
test_connection_settings_providers_test_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TestConnectionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TestConnectionResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
37
apps/web/lib/api/server.ts
Normal file
37
apps/web/lib/api/server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { serverApiBase } from "./config";
|
||||
import type {
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
ProvidersResponse,
|
||||
ReviewHistoryResponse,
|
||||
} from "./types";
|
||||
|
||||
// 服务端只读取数据(Server Components)。失败时抛出,由页面边界处理。
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求失败 ${res.status}: ${path}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchProjects(): Promise<ProjectListResponse> {
|
||||
return getJson<ProjectListResponse>("/projects");
|
||||
}
|
||||
|
||||
export async function fetchProject(projectId: string): Promise<ProjectResponse> {
|
||||
return getJson<ProjectResponse>(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function fetchProviders(): Promise<ProvidersResponse> {
|
||||
return getJson<ProvidersResponse>("/settings/providers");
|
||||
}
|
||||
|
||||
export async function fetchReviews(
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
): Promise<ReviewHistoryResponse> {
|
||||
return getJson<ReviewHistoryResponse>(
|
||||
`/projects/${projectId}/chapters/${chapterNo}/reviews`,
|
||||
);
|
||||
}
|
||||
27
apps/web/lib/api/types.ts
Normal file
27
apps/web/lib/api/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { components } from "./schema";
|
||||
|
||||
// 复用后端 OpenAPI 生成的 schema 类型,绝不手写共享类型(CLAUDE.md 命名契约)。
|
||||
export type ProjectResponse = components["schemas"]["ProjectResponse"];
|
||||
export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"];
|
||||
export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
|
||||
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
|
||||
export type DraftResponse = components["schemas"]["DraftResponse"];
|
||||
export type ProvidersResponse = components["schemas"]["ProvidersResponse"];
|
||||
export type ProviderView = components["schemas"]["ProviderView"];
|
||||
export type TierRoutingView = components["schemas"]["TierRoutingView"];
|
||||
export type ProvidersUpsertRequest =
|
||||
components["schemas"]["ProvidersUpsertRequest"];
|
||||
export type TestConnectionRequest =
|
||||
components["schemas"]["TestConnectionRequest"];
|
||||
export type TestConnectionResponse =
|
||||
components["schemas"]["TestConnectionResponse"];
|
||||
export type CapabilitiesView = components["schemas"]["CapabilitiesView"];
|
||||
|
||||
// M2 审/裁/验收(C3 扩)。
|
||||
export type ReviewRequest = components["schemas"]["ReviewRequest"];
|
||||
export type ReviewHistoryItem = components["schemas"]["ReviewHistoryItem"];
|
||||
export type ReviewHistoryResponse =
|
||||
components["schemas"]["ReviewHistoryResponse"];
|
||||
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
||||
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
||||
5
apps/web/next.config.mjs
Normal file
5
apps/web/next.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
export default nextConfig;
|
||||
33
apps/web/package.json
Normal file
33
apps/web/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"gen:api": "node scripts/gen-api.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.1.3",
|
||||
"openapi-fetch": "^0.13.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.0.0",
|
||||
"@types/react-dom": "19.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"openapi-typescript": "^7.5.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
4906
apps/web/pnpm-lock.yaml
generated
Normal file
4906
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
apps/web/pnpm-workspace.yaml
Normal file
11
apps/web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
packages:
|
||||
- .
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
sharp: set this to true or false
|
||||
unrs-resolver: set this to true or false
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
verifyDepsBeforeRun: false
|
||||
3
apps/web/postcss.config.mjs
Normal file
3
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
26
apps/web/scripts/gen-api.mjs
Normal file
26
apps/web/scripts/gen-api.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, "../../..");
|
||||
const webRoot = resolve(import.meta.dirname, "..");
|
||||
const outDir = resolve(webRoot, "lib/api");
|
||||
const jsonPath = resolve(outDir, "openapi.json");
|
||||
const dtsPath = resolve(outDir, "schema.d.ts");
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// 1) 后端 OpenAPI(uv 运行,离线,CI 无需起服务)
|
||||
const uv = process.env.UV_BIN ?? "uv";
|
||||
const spec = execFileSync(uv, ["run", "python", "-m", "ww_api.export_openapi"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
writeFileSync(jsonPath, spec);
|
||||
|
||||
// 2) OpenAPI → TS 类型(本地 bin,避免 pnpm exec 的工作区检查)
|
||||
const bin = resolve(webRoot, "node_modules/.bin/openapi-typescript");
|
||||
execFileSync(bin, [jsonPath, "-o", dtsPath], { cwd: webRoot, stdio: "inherit" });
|
||||
|
||||
console.log("gen:api done ->", dtsPath);
|
||||
32
apps/web/tailwind.config.ts
Normal file
32
apps/web/tailwind.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
// 纸感·文学温暖主题(UX_SPEC §2)。颜色经 CSS 变量落地,便于后续夜读模式切换。
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: "var(--color-bg)",
|
||||
panel: "var(--color-panel)",
|
||||
ink: "var(--color-ink)",
|
||||
"ink-soft": "var(--color-ink-soft)",
|
||||
line: "var(--color-line)",
|
||||
cinnabar: "var(--color-cinnabar)",
|
||||
conflict: "var(--color-conflict)",
|
||||
overdue: "var(--color-overdue)",
|
||||
pass: "var(--color-pass)",
|
||||
info: "var(--color-info)",
|
||||
},
|
||||
fontFamily: {
|
||||
serif: ['"Noto Serif SC"', '"Songti SC"', "serif"],
|
||||
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
|
||||
mono: ['"JetBrains Mono"', "ui-monospace"],
|
||||
},
|
||||
borderRadius: { DEFAULT: "6px" },
|
||||
boxShadow: { paper: "0 1px 3px #2B26200F" },
|
||||
maxWidth: { prose: "720px" },
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
21
apps/web/tsconfig.json
Normal file
21
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
14
apps/web/vitest.config.ts
Normal file
14
apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// 单测:纯逻辑(SSE reducer / 自动保存防抖 / 向导流程),node 环境,无需 DOM。
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { "@": resolve(__dirname, ".") },
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["lib/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user