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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user