diff --git a/app/(chat)/flow-board/layout.tsx b/app/(chat)/flow-board/layout.tsx new file mode 100644 index 000000000..2e0557122 --- /dev/null +++ b/app/(chat)/flow-board/layout.tsx @@ -0,0 +1,12 @@ +export default function FlowBoardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + + {children} + + ); +} diff --git a/app/(chat)/flow-board/page.tsx b/app/(chat)/flow-board/page.tsx new file mode 100644 index 000000000..42d9f2d2c --- /dev/null +++ b/app/(chat)/flow-board/page.tsx @@ -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 ( + + ); +} diff --git a/app/api/flow-board/draft/route.ts b/app/api/flow-board/draft/route.ts new file mode 100644 index 000000000..19ef9b066 --- /dev/null +++ b/app/api/flow-board/draft/route.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeRecord(value: unknown): Record | 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, + }, + }); +} diff --git a/components/chat-shell.tsx b/components/chat-shell.tsx index 803313f88..5b3c6c845 100644 --- a/components/chat-shell.tsx +++ b/components/chat-shell.tsx @@ -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
{children}
; } - if (isAiCadWorkspace) { + if (isAiCadWorkspace || isFlowBoardWorkspace) { return
{children}
; } diff --git a/components/flow-board/drawnix-workspace.tsx b/components/flow-board/drawnix-workspace.tsx new file mode 100644 index 000000000..4e09f52f1 --- /dev/null +++ b/components/flow-board/drawnix-workspace.tsx @@ -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) + : {}; + + 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({ + children: [], + }); + const [isReady, setIsReady] = useState(false); + const [lastSavedAt, setLastSavedAt] = useState(null); + const snapshotRef = useRef({ children: [] }); + const saveTimerRef = useRef | 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) => { + 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 ( +
+
+ 正在恢复流程白板... +
+
+ ); + } + + return ( +
+
+ + +
+ {displayLogo ? ( + {siteName} + ) : ( +
+ {siteName.slice(0, 1).toUpperCase()} +
+ )} + + 流程白板 + + + {siteName} + + + + + {formatSavedAt(lastSavedAt)} + +
+
+ + } + onThemeChange={handleThemeChange} + onValueChange={handleValueChange} + onViewportChange={handleViewportChange} + theme={snapshot.theme} + tutorial={false} + value={snapshot.children} + viewport={snapshot.viewport} + /> +
+ ); +} diff --git a/components/flow-board/flow-board-client.tsx b/components/flow-board/flow-board-client.tsx new file mode 100644 index 000000000..a0d07b016 --- /dev/null +++ b/components/flow-board/flow-board-client.tsx @@ -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: () => ( +
+
+ + 正在加载流程白板... +
+
+ ), + } +); + +export function FlowBoardClient(props: FlowBoardClientProps) { + return ; +} diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index 391b8aa6d..687f6458e 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -35,6 +35,7 @@ import { User, Users, Wand2, + Workflow, X, Zap, } from "lucide-react"; @@ -1595,6 +1596,19 @@ export function LovartHome({ 视频画布 + + + + + 流程白板 + {siteSettingsData?.productPlanningEnabled !== false ? ( diff --git a/components/top-navbar.tsx b/components/top-navbar.tsx index a1547d6d3..7016ef3d9 100644 --- a/components/top-navbar.tsx +++ b/components/top-navbar.tsx @@ -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({ {getShortLabel("视频画布")} + + {/* 无限画布 */}