feat: add flow board with database drafts
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-11 20:48:46 +08:00
parent 36f7eea13b
commit d88c28356a
15 changed files with 1315 additions and 1 deletions

View File

@ -0,0 +1,12 @@
export default function FlowBoardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<link href="/vendor/drawnix.css" rel="stylesheet" />
{children}
</>
);
}

View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
import { getSiteSettings } from "@/lib/db/queries";
export const metadata: Metadata = {
title: "流程白板",
};
export default async function FlowBoardPage() {
const siteSettings = await getSiteSettings();
return (
<FlowBoardClient
siteLogo={siteSettings.siteLogo || null}
siteLogoDark={siteSettings.siteLogoDark || null}
siteName={siteSettings.siteName || "AI Chatbot"}
/>
);
}

View File

@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
getFlowBoardDraftByUserId,
upsertFlowBoardDraftByUserId,
} from "@/lib/db/queries";
import type { FlowBoardSnapshotData } from "@/lib/db/schema";
export const runtime = "nodejs";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
function normalizeFlowBoardSnapshot(value: unknown): FlowBoardSnapshotData {
const raw = isRecord(value) ? value : {};
const updatedAt =
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
? raw.updatedAt
: new Date().toISOString();
return {
children: Array.isArray(raw.children)
? raw.children.filter(isRecord)
: [],
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
updatedAt,
};
}
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const draft = await getFlowBoardDraftByUserId({
userId: session.user.id,
});
return NextResponse.json({
success: true,
data: {
snapshot: draft?.snapshot ?? { children: [] },
updatedAt: draft?.updatedAt?.toISOString() ?? null,
},
});
}
export async function PUT(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json().catch(() => ({}))) as {
snapshot?: unknown;
};
const snapshot = normalizeFlowBoardSnapshot(body.snapshot ?? body);
const savedDraft = await upsertFlowBoardDraftByUserId({
userId: session.user.id,
snapshot,
});
if (!savedDraft) {
return NextResponse.json(
{ success: false, error: "保存流程白板失败" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
data: {
snapshot: savedDraft.snapshot,
updatedAt: savedDraft.updatedAt?.toISOString() ?? snapshot.updatedAt,
},
});
}

View File

@ -69,12 +69,13 @@ export function ChatShell({
const pathname = usePathname();
const isLovartHome = pathname === "/" && homeVariant === "lovart";
const isAiCadWorkspace = pathname === "/ai-cad";
const isFlowBoardWorkspace = pathname === "/flow-board";
if (isLovartHome) {
return <div className="h-svh w-full overflow-y-auto">{children}</div>;
}
if (isAiCadWorkspace) {
if (isAiCadWorkspace || isFlowBoardWorkspace) {
return <div className="h-svh w-full overflow-hidden">{children}</div>;
}

View File

@ -0,0 +1,268 @@
"use client";
import { Drawnix, type DrawnixProps } from "@drawnix/drawnix";
import type { BoardChangeData } from "@plait-board/react-board";
import type {
PlaitElement,
PlaitTheme,
ThemeColorMode,
Viewport,
} from "@plait/core";
import { ChevronLeft, Save } from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
type FlowBoardWorkspaceProps = {
siteName: string;
siteLogo: string | null;
siteLogoDark: string | null;
};
type FlowBoardSnapshot = {
children: PlaitElement[];
viewport?: Viewport;
theme?: PlaitTheme;
updatedAt?: string;
};
const FLOW_BOARD_DRAFT_API = "/api/flow-board/draft";
function normalizeSnapshot(value: unknown): FlowBoardSnapshot {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Partial<FlowBoardSnapshot>)
: {};
return {
children: Array.isArray(raw.children) ? raw.children : [],
viewport: raw.viewport,
theme: raw.theme,
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined,
};
}
function formatSavedAt(value: Date | null) {
if (!value) return "数据库自动保存";
return `已保存 ${value.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}`;
}
export function DrawnixWorkspace({
siteName,
siteLogo,
siteLogoDark,
}: FlowBoardWorkspaceProps) {
const { resolvedTheme } = useTheme();
const [snapshot, setSnapshot] = useState<FlowBoardSnapshot>({
children: [],
});
const [isReady, setIsReady] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
const snapshotRef = useRef<FlowBoardSnapshot>({ children: [] });
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const displayLogo = useMemo(() => {
if (resolvedTheme === "dark" && siteLogoDark) {
return siteLogoDark;
}
return siteLogo || siteLogoDark;
}, [resolvedTheme, siteLogo, siteLogoDark]);
useEffect(() => {
let cancelled = false;
const loadDraft = async () => {
try {
const response = await fetch(FLOW_BOARD_DRAFT_API, {
cache: "no-store",
credentials: "include",
});
if (!response.ok) {
throw new Error(`Failed to load flow board draft: ${response.status}`);
}
const result = (await response.json()) as {
data?: { snapshot?: unknown; updatedAt?: string | null };
};
if (!cancelled) {
const nextSnapshot = normalizeSnapshot(result.data?.snapshot);
snapshotRef.current = nextSnapshot;
setSnapshot(nextSnapshot);
setLastSavedAt(
result.data?.updatedAt
? new Date(result.data.updatedAt)
: nextSnapshot.updatedAt
? new Date(nextSnapshot.updatedAt)
: null
);
}
} catch (error) {
console.error("[FlowBoard] Failed to load draft:", error);
toast.error("流程白板草稿加载失败");
} finally {
if (!cancelled) {
setIsReady(true);
}
}
};
void loadDraft();
return () => {
cancelled = true;
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, []);
const scheduleSave = useCallback((nextSnapshot: FlowBoardSnapshot) => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(async () => {
try {
const response = await fetch(FLOW_BOARD_DRAFT_API, {
body: JSON.stringify({ snapshot: nextSnapshot }),
credentials: "include",
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
if (!response.ok) {
throw new Error(`Failed to save flow board draft: ${response.status}`);
}
const result = (await response.json()) as {
data?: { updatedAt?: string | null };
};
setLastSavedAt(
result.data?.updatedAt
? new Date(result.data.updatedAt)
: new Date(nextSnapshot.updatedAt || Date.now())
);
} catch (error) {
console.error("[FlowBoard] Failed to save draft:", error);
toast.error("流程白板草稿保存到数据库失败");
}
}, 350);
}, []);
const updateSnapshot = useCallback(
(partialSnapshot: Partial<FlowBoardSnapshot>) => {
const nextSnapshot = normalizeSnapshot({
...snapshotRef.current,
...partialSnapshot,
updatedAt: new Date().toISOString(),
});
snapshotRef.current = nextSnapshot;
setSnapshot(nextSnapshot);
scheduleSave(nextSnapshot);
},
[scheduleSave]
);
const handleDrawnixChange = useCallback(
(value: BoardChangeData) => {
const shouldPersist = value.operations.some(
(operation) => operation.type !== "set_selection"
);
if (!shouldPersist) return;
updateSnapshot({
children: value.children,
theme: value.theme,
viewport: value.viewport,
});
},
[updateSnapshot]
);
const handleValueChange = useCallback(
(children: PlaitElement[]) => {
updateSnapshot({ children });
},
[updateSnapshot]
);
const handleViewportChange = useCallback(
(viewport: Viewport) => {
updateSnapshot({ viewport });
},
[updateSnapshot]
);
const handleThemeChange = useCallback(
(themeColorMode: ThemeColorMode) => {
updateSnapshot({ theme: { themeColorMode } });
},
[updateSnapshot]
);
if (!isReady) {
return (
<div className="flex h-svh w-full items-center justify-center bg-[#f7f8fb] text-[#697386]">
<div className="rounded-2xl border border-black/10 bg-white px-5 py-3 font-medium text-sm shadow-sm">
正在恢复流程白板...
</div>
</div>
);
}
return (
<main className="relative h-svh w-full overflow-hidden bg-[#f7f8fb]">
<div className="pointer-events-none absolute top-1 left-3 z-20 flex h-7 items-center gap-1.5">
<button
className="pointer-events-auto inline-flex h-7 items-center gap-1 rounded-full border border-black/10 bg-white px-2 font-medium text-[#4f566b] text-xs shadow-sm hover:bg-white"
onClick={() => {
window.location.href = "/";
}}
type="button"
>
<ChevronLeft className="h-3.5 w-3.5" />
返回
</button>
<div className="pointer-events-auto flex h-7 items-center gap-2 rounded-full border border-black/10 bg-white px-2 shadow-sm">
{displayLogo ? (
<img
alt={siteName}
className="h-5 w-5 rounded-full object-cover"
src={displayLogo}
/>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-[#111827] font-semibold text-[10px] text-white">
{siteName.slice(0, 1).toUpperCase()}
</div>
)}
<span className="font-semibold text-[#111827] text-xs">
流程白板
</span>
<span className="max-w-[140px] truncate text-[#7a8297] text-[10px]">
{siteName}
</span>
<span className="mx-0.5 h-3 w-px bg-black/10" />
<Save className="h-3 w-3 text-[#7a8297]" />
<span className="text-[#7a8297] text-[10px]">
{formatSavedAt(lastSavedAt)}
</span>
</div>
</div>
<Drawnix
className="h-full w-full"
onChange={handleDrawnixChange as NonNullable<DrawnixProps["onChange"]>}
onThemeChange={handleThemeChange}
onValueChange={handleValueChange}
onViewportChange={handleViewportChange}
theme={snapshot.theme}
tutorial={false}
value={snapshot.children}
viewport={snapshot.viewport}
/>
</main>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
type FlowBoardClientProps = {
siteName: string;
siteLogo: string | null;
siteLogoDark: string | null;
};
const DrawnixWorkspace = dynamic(
() =>
import("@/components/flow-board/drawnix-workspace").then(
(mod) => mod.DrawnixWorkspace
),
{
ssr: false,
loading: () => (
<div className="flex h-svh w-full items-center justify-center bg-[#f7f8fb] text-[#697386]">
<div className="flex items-center gap-3 rounded-2xl border border-black/10 bg-white px-5 py-3 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="font-medium text-sm">正在加载流程白板...</span>
</div>
</div>
),
}
);
export function FlowBoardClient(props: FlowBoardClientProps) {
return <DrawnixWorkspace {...props} />;
}

View File

@ -35,6 +35,7 @@ import {
User,
Users,
Wand2,
Workflow,
X,
Zap,
} from "lucide-react";
@ -1595,6 +1596,19 @@ export function LovartHome({
</TooltipTrigger>
<TooltipContent side="right">视频画布</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => router.push("/flow-board")}
title="流程白板"
>
<Workflow className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">流程白板</TooltipContent>
</Tooltip>
{siteSettingsData?.productPlanningEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>

View File

@ -18,6 +18,7 @@ import {
Sparkles,
Trophy,
Wand2,
Workflow,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
@ -615,6 +616,18 @@ export function TopNavbar({
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
</button>
<button
className={navItemBaseClass}
onClick={() => {
window.location.href = "/flow-board";
}}
title="流程白板"
type="button"
>
<Workflow className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("流程白板")}</span>
</button>
{/* 无限画布 */}
<button
className={navItemBaseClass}

View File

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS "FlowBoardDraft" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
"snapshot" jsonb NOT NULL DEFAULT '{"children":[]}'::jsonb,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now(),
CONSTRAINT "FlowBoardDraft_userId_unique" UNIQUE("userId")
);
CREATE INDEX IF NOT EXISTS "FlowBoardDraft_user_updated_idx"
ON "FlowBoardDraft"("userId", "updatedAt");

View File

@ -386,6 +386,13 @@
"when": 1775782800000,
"tag": "0068_add_agent_psd_render_tasks",
"breakpoints": true
},
{
"idx": 55,
"version": "7",
"when": 1775869200000,
"tag": "0069_add_flow_board_drafts",
"breakpoints": true
}
]
}

View File

@ -46,6 +46,9 @@ import {
vote,
aiCadWorkspace,
type AiCadWorkspace,
flowBoardDraft,
type FlowBoardDraft,
type FlowBoardSnapshotData,
aiProvider,
type AiProvider,
aiModel,
@ -894,6 +897,59 @@ export async function deleteAiCadWorkspaceById(params: {
}
}
export async function getFlowBoardDraftByUserId({
userId,
}: {
userId: string;
}): Promise<FlowBoardDraft | null> {
try {
const [result] = await db
.select()
.from(flowBoardDraft)
.where(eq(flowBoardDraft.userId, userId))
.limit(1);
return result ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get flow board draft"
);
}
}
export async function upsertFlowBoardDraftByUserId(params: {
userId: string;
snapshot: FlowBoardSnapshotData;
}): Promise<FlowBoardDraft | null> {
try {
const now = new Date();
const [result] = await db
.insert(flowBoardDraft)
.values({
userId: params.userId,
snapshot: params.snapshot,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: flowBoardDraft.userId,
set: {
snapshot: params.snapshot,
updatedAt: now,
},
})
.returning();
return result ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to save flow board draft"
);
}
}
export async function upsertConversationWorkingState(params: {
userId: string;
chatId: string;

View File

@ -953,6 +953,29 @@ export const aiCadWorkspace = pgTable("AiCadWorkspace", {
export type AiCadWorkspace = InferSelectModel<typeof aiCadWorkspace>;
export type FlowBoardSnapshotData = {
children: Record<string, unknown>[];
viewport?: Record<string, unknown> | null;
theme?: Record<string, unknown> | null;
updatedAt?: string | null;
};
export const flowBoardDraft = pgTable("FlowBoardDraft", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" })
.unique(),
snapshot: jsonb("snapshot")
.$type<FlowBoardSnapshotData>()
.notNull()
.default({ children: [] }),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type FlowBoardDraft = InferSelectModel<typeof flowBoardDraft>;
// 每轮消息的目标资产/图片定位审计表
export const messageTargetResolution = pgTable("MessageTargetResolution", {
id: uuid("id").primaryKey().notNull().defaultRandom(),

View File

@ -32,12 +32,21 @@
"@codemirror/state": "^6.5.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.35.3",
"@drawnix/drawnix": "0.4.0-2",
"@fal-ai/serverless-client": "^0.15.0",
"@icons-pack/react-simple-icons": "^13.7.0",
"@langchain/core": "^1.1.19",
"@langchain/langgraph": "^1.1.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0",
"@plait-board/react-board": "0.4.0-2",
"@plait-board/react-text": "0.4.0-2",
"@plait/common": "^0.92.1",
"@plait/core": "^0.92.1",
"@plait/draw": "^0.92.1",
"@plait/layouts": "^0.92.1",
"@plait/mind": "^0.92.1",
"@plait/text-plugins": "^0.92.1",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.15",
@ -75,6 +84,7 @@
"framer-motion": "^11.18.2",
"geist": "^1.3.1",
"immer": "^11.1.3",
"is-hotkey": "^0.2.0",
"jszip": "^3.10.1",
"katex": "^0.16.25",
"lucide-react": "^0.446.0",
@ -107,6 +117,10 @@
"server-only": "^0.0.1",
"sharp": "^0.34.5",
"shiki": "^3.12.2",
"slate": "^0.124.0",
"slate-dom": "^0.124.0",
"slate-history": "^0.115.0",
"slate-react": "^0.124.0",
"sonner": "^1.5.0",
"streamdown": "^1.3.0",
"swr": "^2.2.5",

750
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4
public/vendor/drawnix.css vendored Normal file

File diff suppressed because one or more lines are too long