ai-chatbot/components/flow-board/drawnix-workspace.tsx
Your Name d88c28356a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add flow board with database drafts
2026-04-11 20:48:46 +08:00

269 lines
7.9 KiB
TypeScript

"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>
);
}