0.4.0 convex及界面修改

This commit is contained in:
liaibo
2026-02-01 08:47:40 +08:00
parent d1f055f51a
commit af92c4b149
636 changed files with 7522 additions and 1815 deletions
@@ -1,9 +1,12 @@
"use client";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useConvexAuth, useQuery } from "convex/react";
import type { DocumentTable } from "@/types/online-table";
import { deleteOnlineTable, getDocumentTable, saveOnlineTable } from "@/lib/online-table";
import { Loader2, Table as TableIcon, Maximize2, Trash2, RotateCw } from "lucide-react";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
interface CompactTablePreviewProps {
tableId: string;
@@ -13,16 +16,45 @@ interface CompactTablePreviewProps {
}
const useTableData = (tableId: string) => {
const convexEnabled = isConvexEnabled();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
const userId =
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
? String((currentUser as any)._id)
: "";
const tableFromConvex = useQuery(
api.tables.get,
convexEnabled && userId && tableId ? { userId, tableId } : "skip",
);
const [table, setTable] = useState<DocumentTable | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [version, setVersion] = useState(0);
const refresh = useCallback(() => {
setIsLoading(true);
setVersion((prev) => prev + 1);
}, []);
if (!convexEnabled) {
setIsLoading(true);
setVersion((prev) => prev + 1);
}
}, [convexEnabled]);
useEffect(() => {
if (convexEnabled) {
if (tableFromConvex === undefined) {
setIsLoading(true);
return;
}
if (tableFromConvex === null) {
setTable(null);
setIsLoading(false);
return;
}
setTable({ ...(tableFromConvex as unknown as DocumentTable), title: (tableFromConvex as any)?.title || "未命名表格" });
setIsLoading(false);
return;
}
let canceled = false;
getDocumentTable(tableId)
.then((data) => {
@@ -44,7 +76,7 @@ const useTableData = (tableId: string) => {
return () => {
canceled = true;
};
}, [tableId, version]);
}, [convexEnabled, tableFromConvex, tableId, version]);
return { table, isLoading, refresh };
};
@@ -103,7 +135,7 @@ const CompactTablePreviewInner: React.FC<CompactTablePreviewProps> = ({
}, []);
const handleDeleteTable = useCallback(async () => {
const confirmed = window.confirm("删除表格将同步移除 Supabase 记录,确认继续?");
const confirmed = window.confirm("删除表格将同步移除在线表格记录,确认继续?");
if (!confirmed) return;
try {
await deleteOnlineTable(tableId);
@@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, Table, X, Zap } from "lucide-react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import type { DocumentTable, TableRowData } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
@@ -10,6 +11,8 @@ import {
createDefaultTableSnapshot,
saveOnlineTable,
} from "@/lib/online-table";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { extractRowsForPreview } from "@/components/online-table/utils";
@@ -75,6 +78,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const [saveError, setSaveError] = useState<string | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const persistSnapshotRef = useRef<((reason: "auto" | "close") => Promise<void>) | null>(null);
const lastLocalPersistAtRef = useRef<number>(0);
const lastPointerDownInGridRef = useRef(false);
const savingHintTimerRef = useRef<number | null>(null);
const [showSavingHint, setShowSavingHint] = useState(false);
@@ -82,6 +86,19 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const [renameValue, setRenameValue] = useState("");
const [isSavingTitle, setIsSavingTitle] = useState(false);
const convexEnabled = isConvexEnabled();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
const userId =
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
? String((currentUser as any)._id)
: "";
const tableFromConvex = useQuery(
api.tables.get,
convexEnabled && userId && tableId ? { userId, tableId } : "skip",
);
const updateTable = useMutation(api.tables.update);
const startSavingHint = useCallback(() => {
if (savingHintTimerRef.current !== null) return;
savingHintTimerRef.current = window.setTimeout(() => {
@@ -129,16 +146,36 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
}, []);
useEffect(() => {
fetchTable(tableId);
hasInitializedRef.current = false;
lastTableIdRef.current = tableId;
}, [fetchTable, tableId]);
}, [tableId]);
useEffect(() => {
if (tableData) {
if (convexEnabled) {
if (tableFromConvex === undefined) {
setIsTableLoading(true);
setTableError(null);
return;
}
if (tableFromConvex === null) {
setIsTableLoading(false);
setTableError("无法加载表格数据,请稍后重试。");
setTableData(null);
return;
}
// 避免“本地保存 -> 订阅回推 -> 立刻重建 luckysheet”导致的闪烁/选区丢失
if (Date.now() - lastLocalPersistAtRef.current < 1500) {
setIsTableLoading(false);
return;
}
setTableError(null);
setTableData(tableFromConvex as unknown as DocumentTable);
setIsTableLoading(false);
return;
}
}, [tableData]);
fetchTable(tableId);
}, [convexEnabled, fetchTable, tableFromConvex, tableId]);
useEffect(() => {
if (tableData?.title !== undefined) {
@@ -185,11 +222,22 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : luckysheetSheets,
};
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: tableData.schema,
});
lastLocalPersistAtRef.current = Date.now();
if (convexEnabled && userId) {
await updateTable({
userId,
tableId,
snapshot,
rows,
schema: tableData.schema,
});
} else {
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: tableData.schema,
});
}
setHasPendingChanges(false);
setLastSyncedAt(Date.now());
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
@@ -200,7 +248,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
stopSavingHint();
setIsSaving(false);
}
}, [luckysheetSheets, startSavingHint, stopSavingHint, tableData, tableId]);
}, [convexEnabled, luckysheetSheets, startSavingHint, stopSavingHint, tableData, tableId, updateTable, userId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot("auto");
@@ -443,6 +491,37 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const gridKey = tableData?.grid_key ?? tableId;
const loadUrl = gridKey ? `/api/luckysheet/load?gridKey=${gridKey}` : "";
let canceled = false;
let resizeRafId: number | null = null;
const resizeTimerIds: number[] = [];
let unlockTimerId: number | null = null;
const safeResize = () => {
if (canceled) return;
const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID);
if (!container) return;
const rect = container.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return;
const instance = window.luckysheet as any;
if (!instance || typeof instance.resize !== "function") return;
try {
instance.resize();
} catch {
// 忽略:Luckysheet 内部可能处于 destroy/create 过程中
}
};
const scheduleResize = () => {
if (canceled) return;
resizeRafId = window.requestAnimationFrame(() => {
safeResize();
});
// 多次兜底:避免首次打开时样式/布局尚未完全稳定
resizeTimerIds.push(window.setTimeout(safeResize, 80));
resizeTimerIds.push(window.setTimeout(safeResize, 240));
resizeTimerIds.push(window.setTimeout(safeResize, 800));
};
const options = {
container: LUCKY_SHEET_CONTAINER_ID,
title: tableData.title ?? tableId,
@@ -476,7 +555,10 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
imageUrlHandle: (url: string) => url,
hook: {
workbookCreateAfter: () => {
if (canceled) return;
setIsTableLoading(false);
// 仅在 Luckysheet 完成 DOM 构建后再调用 resize,避免触发其内部空引用(offsetHeight of null
scheduleResize();
},
updated: () => {
if (isApplyingSnapshotRef.current) return;
@@ -515,31 +597,21 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
return;
}
// 首次加载时,资源/样式可能刚完成注入,强制触发一次 resize 让 Luckysheet 重新计算布局(避免工具栏/公式栏不显示)
requestAnimationFrame(() => {
try {
window.dispatchEvent(new Event("resize"));
(window.luckysheet as any)?.resize?.();
} catch {
// 忽略
}
setTimeout(() => {
try {
window.dispatchEvent(new Event("resize"));
(window.luckysheet as any)?.resize?.();
} catch {
// 忽略
}
}, 80);
});
// 等待首帧渲染完成再开放 updated 事件
setTimeout(() => {
unlockTimerId = window.setTimeout(() => {
isApplyingSnapshotRef.current = false;
}, 0);
const containerEl = containerRef.current;
return () => {
canceled = true;
if (resizeRafId !== null) {
cancelAnimationFrame(resizeRafId);
}
resizeTimerIds.forEach((id) => clearTimeout(id));
if (unlockTimerId !== null) {
clearTimeout(unlockTimerId);
}
if (window.luckysheet) {
window.luckysheet?.destroy?.(LUCKY_SHEET_CONTAINER_ID);
}
@@ -570,8 +642,13 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
}
setIsSavingTitle(true);
try {
const updated = await saveOnlineTable(tableId, { title: nextTitle });
const finalTitle = updated.title ?? nextTitle;
let finalTitle = nextTitle;
if (convexEnabled && userId) {
await updateTable({ userId, tableId, title: nextTitle });
} else {
const updated = await saveOnlineTable(tableId, { title: nextTitle });
finalTitle = updated.title ?? nextTitle;
}
setTableData((prev) => (prev ? { ...prev, title: finalTitle } : prev));
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
} catch (error) {
@@ -581,7 +658,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
setIsRenaming(false);
setIsSavingTitle(false);
}
}, [renameValue, tableData, tableId]);
}, [convexEnabled, renameValue, tableData, tableId, updateTable, userId]);
const statusText = saveError
? saveError
@@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, RotateCw } from "lucide-react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import type { DocumentTable } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
@@ -11,6 +12,8 @@ import {
getDocumentTable,
saveOnlineTable,
} from "@/lib/online-table";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
@@ -61,16 +64,24 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
const [saveError, setSaveError] = useState<string | null>(null);
const [showSavingHint, setShowSavingHint] = useState(false);
const savingHintTimerRef = useRef<number | null>(null);
const lastRemoteSyncedAtRef = useRef<string | null>(null);
const lastSnapshotHashRef = useRef<string | null>(null);
const lastLocalPersistAtRef = useRef<number>(0);
const computeSnapshotHash = useCallback((snapshot: unknown) => {
try {
return JSON.stringify(snapshot ?? {});
} catch {
return null;
}
}, []);
const convexEnabled = isConvexEnabled();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
const userId =
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
? String((currentUser as any)._id)
: "";
const shouldFetchTable = Boolean(convexEnabled && userId && tableId);
const tableFromConvex = useQuery(
api.tables.get,
shouldFetchTable ? { userId, tableId } : "skip",
);
const updateTable = useMutation(api.tables.update);
const allowInlineEdit = editable ?? embed;
const startSavingHint = useCallback(() => {
if (savingHintTimerRef.current !== null) return;
@@ -92,13 +103,37 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
setIsLoading(true);
setError(null);
if (convexEnabled) {
// Convex 模式下走 useQuery 实时订阅,这里仅维护与旧逻辑兼容的 loading/error 状态
if (tableFromConvex === undefined) {
// still loading
return () => {
canceled = true;
};
}
if (!canceled) {
if (tableFromConvex === null) {
setTable(null);
setError("无法加载表格数据");
} else {
// 可编辑内嵌视图:避免每次自动保存后立即重建 UI(会闪烁)。
// 我们在本组件触发保存后的短时间内,忽略来自订阅的回写更新。
if (allowInlineEdit && Date.now() - lastLocalPersistAtRef.current < 1500) {
// ignore
} else {
setTable(tableFromConvex as unknown as DocumentTable);
}
}
setIsLoading(false);
}
return () => {
canceled = true;
};
}
getDocumentTable(tableId)
.then((data) => {
if (!canceled) {
lastSnapshotHashRef.current = computeSnapshotHash(data.snapshot);
if ((data as { last_synced_at?: string }).last_synced_at) {
lastRemoteSyncedAtRef.current = (data as { last_synced_at?: string }).last_synced_at ?? null;
}
setTable(data);
}
})
@@ -118,10 +153,9 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
return () => {
canceled = true;
};
}, [computeSnapshotHash, reloadVersion, tableId]);
}, [allowInlineEdit, convexEnabled, reloadVersion, tableFromConvex, tableId]);
const allowInlineEdit = editable ?? embed;
const focusLuckysheetEditor = useCallback(() => {
const applyFocus = () => {
const editor = document.getElementById("luckysheet-rich-text-editor");
@@ -203,12 +237,22 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : table.snapshot?.luckysheet ?? [],
};
lastSnapshotHashRef.current = computeSnapshotHash(snapshot);
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: table.schema,
});
lastLocalPersistAtRef.current = Date.now();
if (convexEnabled && userId) {
await updateTable({
userId,
tableId,
snapshot,
rows,
schema: table.schema,
});
} else {
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: table.schema,
});
}
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
} catch (err) {
console.error("内嵌表格保存失败", err);
@@ -217,7 +261,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
stopSavingHint();
setIsSaving(false);
}
}, [allowInlineEdit, computeSnapshotHash, startSavingHint, stopSavingHint, table, tableId]);
}, [allowInlineEdit, convexEnabled, startSavingHint, stopSavingHint, table, tableId, updateTable, userId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
@@ -232,36 +276,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
useEffect(() => {
if (!tableId) return;
// Supabase 已移除:此处不再做实时订阅
/* Supabase subscription removed
const channel = supabaseBrowser
.channel(`table-${tableId}-live`)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "document_tables", filter: `id=eq.${tableId}` },
(payload) => {
const next = payload.new as DocumentTable | null;
if (!next) return;
const nextSynced = (next as { last_synced_at?: string }).last_synced_at ?? null;
if (nextSynced && lastRemoteSyncedAtRef.current && nextSynced <= lastRemoteSyncedAtRef.current) {
return;
}
lastRemoteSyncedAtRef.current = nextSynced;
const nextHash = computeSnapshotHash(next.snapshot);
const currentHash = lastSnapshotHashRef.current;
if (nextHash && currentHash && nextHash === currentHash) {
return; // 相同快照无需重建,避免闪烁
}
lastSnapshotHashRef.current = nextHash;
setTable(next);
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
*/
// Supabase 已移除:实时订阅由 Convex useQuery 承担(见上方 tableFromConvex
}, [tableId]);
useEffect(() => {