Files
mnote/wolai-frontend/src/components/online-table/HeadlessTableViewer.tsx
T

218 lines
6.9 KiB
TypeScript
Raw Normal View History

2025-11-29 05:16:23 +08:00
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, RotateCw } from "lucide-react";
import type { DocumentTable } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
DEFAULT_TABLE_ROWS,
DEFAULT_TABLE_SCHEMA,
createDefaultTableSnapshot,
getDocumentTable,
saveOnlineTable,
} from "@/lib/online-table";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
interface HeadlessTableViewerProps {
tableId: string;
embed?: boolean;
}
const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false }) => {
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
const containerRef = useRef<HTMLDivElement>(null);
const isLuckysheetReady = useLuckysheetLoader();
const [table, setTable] = useState<DocumentTable | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadVersion, setReloadVersion] = useState(0);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
let canceled = false;
setIsLoading(true);
setError(null);
getDocumentTable(tableId)
.then((data) => {
if (!canceled) {
setTable(data);
}
})
.catch((err) => {
console.error("加载表格失败", err);
if (!canceled) {
setTable(null);
setError("无法加载表格数据");
}
})
.finally(() => {
if (!canceled) {
setIsLoading(false);
}
});
return () => {
canceled = true;
};
}, [tableId, reloadVersion]);
const persistSnapshot = useCallback(async () => {
if (
!embed ||
!table ||
!window.luckysheet ||
typeof window.luckysheet.getluckysheetfile !== "function"
) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
const luckysheetData = window.luckysheet.getluckysheetfile?.() ?? [];
const rows = extractRowsForPreview(
luckysheetData,
(table.schema?.columns ?? []).map((item) => ({ id: item.id })),
).filter((row) => row && typeof row === "object" && Object.keys(row).length > 0);
const snapshot = {
...(table.snapshot ?? {}),
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : table.snapshot?.luckysheet ?? [],
};
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: table.schema,
});
setTable((prev) => (prev ? { ...prev, snapshot, rows } : prev));
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
} catch (err) {
console.error("内嵌表格保存失败", err);
setSaveError("自动保存失败");
} finally {
setIsSaving(false);
}
}, [embed, table, tableId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
}, 1200);
useEffect(() => {
return () => {
debouncedPersist.cancel();
};
}, [debouncedPersist]);
useEffect(() => {
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
return;
}
if (typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
containerRef.current.innerHTML = "";
const sheets =
(table.snapshot?.luckysheet && Array.isArray(table.snapshot.luckysheet) && table.snapshot.luckysheet.length > 0)
? table.snapshot.luckysheet
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
window.luckysheet.create({
container: containerId,
title: table.title ?? tableId,
lang: "zh",
showinfobar: false,
showtoolbar: false,
showsheetbar: sheets.length > 1,
showstatisticBar: false,
allowEdit: embed,
allowUpdate: false,
enableAddBackTop: false,
enableAddRow: false,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
data: sheets,
pointEdit: embed,
pointEditZoom: window.devicePixelRatio ?? 1,
pointEditUpdate: embed
? () => {
debouncedPersist();
}
: undefined,
hook: embed
? {
updated: () => {
debouncedPersist();
},
}
: undefined,
});
return () => {
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
};
}, [containerId, isLuckysheetReady, table, tableId]);
const overlayVisible = isLoading || !isLuckysheetReady;
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const embedContainerStyle = embed ? { minHeight: "100vh", height: "100vh" } : undefined;
return (
<div
className={embed ? "w-full bg-transparent" : "min-h-screen w-full bg-white"}
style={embedContainerStyle}
>
<div
className={embed ? "relative w-full" : "relative h-[calc(100vh-64px)] w-full"}
style={embedContainerStyle}
>
<div
id={containerId}
ref={containerRef}
className="h-full w-full"
style={{ display: isLuckysheetReady && !!table && !error ? "block" : "none" }}
/>
{overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/90">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
<span className="text-sm text-gray-500">{overlayText}</span>
</div>
)}
{error && !overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/95 text-red-500">
<span className="text-sm">{error}</span>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-red-300 px-3 py-1 text-sm"
onClick={() => setReloadVersion((value) => value + 1)}
>
<RotateCw className="h-4 w-4" />
重试
</button>
</div>
)}
{embed && (isSaving || saveError) && (
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
{isSaving && <span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">同步中...</span>}
{saveError && <span className="mt-1 rounded-md bg-white/80 px-2 py-0.5 text-red-500 shadow">{saveError}</span>}
</div>
)}
</div>
</div>
);
};
export default HeadlessTableViewer;