269 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|