diff --git a/luckysheet-crdt-master/CRDT_Improvements_Documentation.md b/luckysheet-crdt-master/CRDT_Improvements_Documentation.md new file mode 100644 index 00000000..9f631f70 --- /dev/null +++ b/luckysheet-crdt-master/CRDT_Improvements_Documentation.md @@ -0,0 +1,64 @@ +# Luckysheet CRDT 相较于 Luckysheet Master 的改进分析 + +本文档比较了 `luckysheet-crdt-master` 项目相对于官方 `Luckysheet-master` 项目的主要改进和增强,并列出了相关的代码位置或被修改的模块,方便进行代码优化选取。 + +## 一、 核心功能增强:实时协同编辑 (CRDT) + +`luckysheet-crdt-master` 最重要的改进是引入了 CRDT (Conflict-free Replicated Data Type) 机制,实现了实时协作编辑功能。 + +| 改进点 | 描述 | 关键代码位置/模块 | +| :--- | :--- | :--- | +| **CRDT 核心逻辑** | 实现数据结构和操作同步逻辑,确保多用户实时编辑时的数据一致性。 | **位于未公开的 Luckysheet 源码修改部分。** 原始 Luckysheet 源码中关于数据操作和状态管理的核心文件被重写以支持 CRDT。 | +| **WebSocket 协同连接** | 客户端初始化时,通过 WebSocket 连接后端协同服务。 | `luckysheet-crdt-master/src/main.ts` (约 56 行): 构造 `options.updateUrl`。
`luckysheet-crdt-master/src/config/index.ts`: 定义 `WS_SERVER_URL`。 | +| **协同模式降级** | 当协同服务连接失败时,项目能够优雅地降级到普通模式,避免页面空白。 | `luckysheet-crdt-master/src/main.ts` (约 45-61 行): `try...catch` 块处理 API 请求失败,并使用 `defaultSheetData` 初始化。 | +| **连接关闭处理** | 确保在浏览器刷新或退出时,客户端主动关闭 WebSocket 连接,释放资源。 | `luckysheet-crdt-master/src/main.ts` (约 20 行): `window.onbeforeunload = () => luckysheet && luckysheet.closeWebSocket();` | +| **后端服务集成** | 提供了基于 Node.js/Sequelize 的后端服务 (`server/` 目录),用于数据持久化和协同消息转发。 | `luckysheet-crdt-master/server/` 目录下的文件结构。 | + +## 二、 工程化与技术栈升级 + +| 改进点 | 描述 | 关键代码位置/模块 | +| :--- | :--- | :--- | +| **TypeScript 迁移** | 项目主要开发语言迁移到 TypeScript,提供了类型提示,提高了代码质量和可维护性。 | 项目中大量使用 `.ts` 文件,如 `src/main.ts`, `src/config/index.ts`。 | +| **现代化构建工具** | 采用 Vite 进行前端构建和开发,取代了 Luckysheet 原始的 Gulp/Webpack 配置,提高了开发效率。 | `luckysheet-crdt-master/vite.config.ts`。 | + +## 三、 功能和配置增强 + +### 1. 插件和依赖优化 + +| 改进点 | 描述 | 关键代码位置/模块 | +| :--- | :--- | :--- | +| **插件依赖加载优化** | 解决了原始 Luckysheet 中插件依赖可能因网络问题加载失败的问题。新的插件注册支持 `dependScripts` 和 `dependLinks`,允许指定本地或在线的依赖路径。 | **位于未公开的 Luckysheet 源码修改部分。** 涉及 Luckysheet 插件加载机制的核心文件。 | +| **文件导入/导出插件化** | 将文件导入和导出功能封装为插件 (`fileImport`, `fileExport`),并支持协同。 | 插件相关代码 (未提供源码);配置项在 `luckysheet-crdt-master/src/main.ts` 中通过 `registerPlugins()` 注册。 | + +### 2. UI 和交互增强 + +| 改进点 | 描述 | 关键代码位置/模块 | +| :--- | :--- | :--- | +| **页面 UI 重构** | 进行了页面 UI 重构,提升了用户体验。 | 涉及 Luckysheet 源码中 UI 渲染和样式的修改部分。 | +| **新增边框类型** | 新增了左斜线和右斜线边框类型。 | 涉及 Luckysheet 源码中边框绘制逻辑的修改。 | +| **图表协同增强** | 引入 `vchart` 进行图表渲染,动画更流畅,并实现了图表数据的协同联动。 | 涉及 Luckysheet 源码中图表模块的修改。 | +| **自定义菜单** | 允许通过配置项自定义菜单栏按钮和行为。 | 涉及 Luckysheet 菜单渲染模块的修改;配置项在 `luckysheet-crdt-master/src/main.ts` 中初始化:`menuHandler: { customs: [...] }`。 | +| **自定义请求头** | 允许用户通过 `requestHeaders` 配置项添加自定义请求头(如 token),以实现用户身份权限校验。 | **位于未公开的 Luckysheet 源码修改部分。** 原始 Luckysheet 中处理数据加载的模块 (如原 `src/core.js` 或 `src/controllers/server.js`) 被修改,在 Ajax 请求中添加 `beforeSend` 钩子。 | +| **自定义快捷键** | 允许通过 `customShortcutKeys` 配置项添加自定义快捷键。 | 涉及 Luckysheet 快捷键处理模块的修改。 | +| **单元格图片功能** | 新增对单元格图片的支持,包括浮动图片和单元格图片的相互转换。 | 涉及 Luckysheet 源码中图片处理和渲染逻辑的修改。 | + +### 3. 打印功能增强 + +| 改进点 | 描述 | 关键代码位置/模块 | +| :--- | :--- | :--- | +| **新增打印 API** | 提供了 `luckysheet.print(type, neetToPreview)` API。 | 涉及 Luckysheet 源码中 API 注册和打印模块的修改。 | +| **打印模糊优化** | 新增 `printDevicePixelRatio` 配置项,用于控制打印清晰度。 | 涉及 Luckysheet 源码中打印配置和渲染逻辑的修改。 | +| **打印功能完善** | 支持打印预览、取消网格线、打印当前页/选区/指定页码、打印图片/图表等。 | 涉及 Luckysheet 源码中打印模块的修改。 | + +## 四、 源码修复和优化 (Commit 记录) + +`luckysheet-crdt-master` 还包含了对原始 Luckysheet 源码的一些 BUG 修复和协同优化,部分重要的提交如下: + +| 改进点 | 描述 | 关键代码位置 (Commit Hash) | +| :--- | :--- | :--- | +| **协同提示框修复** | 修复多人协同提示框显示异常。 | `af3c5837f8bec8a8cf4d261cbc8c9416d19902e1` | +| **光标协同修复** | 修复同用户 ID 刷新后光标无法实现协同。 | `5212b82c90595ff324c86db56e5ec25b88912d38` | +| **公式链协同** | 修复公式链相关协同消息传递。 | `c121bcd389b4f8ecef00e3570cda9aea27e7333d` | +| **批注导入** | 批注导入实现、完善源码对批注的识别。 | `72e52419ce0168c352b0ed78e182832426b7bdda` | +| **删除列撤销协同** | 修复删除列后撤销协同不更新的 BUG。 | `232103c62df81e7cec3abd2b19e986d1ffad73d5` | +| **富文本复制粘贴** | 修复 `inlineStr` 富文本数据复制粘贴异常 BUG。 | `33274ef5e1a7462b4c4670bbd700d1f1dcba53fa` | diff --git a/wolai-frontend/src/app/api/tables/[tableId]/route.ts b/wolai-frontend/src/app/api/tables/[tableId]/route.ts index 38b690da..7b225bcc 100644 --- a/wolai-frontend/src/app/api/tables/[tableId]/route.ts +++ b/wolai-frontend/src/app/api/tables/[tableId]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table"; interface RouteContext { params: { @@ -7,19 +8,43 @@ interface RouteContext { }; } -export async function GET(request: Request, context: RouteContext) { +type UpdateTableRequest = { + title?: string; + snapshot?: DocumentTableSnapshot | null; + rows?: TableRowData[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + viewPreferences?: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schema?: Record; +}; + +const extractTableId = async (context: RouteContext) => { + // Next.js 16 / Turbopack 下 params 是 Promise,需要等待。 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (await (context as any).params).tableId as string; +}; + +const getAuthUser = async () => { const supabase = await createSupabaseRouteClient(); - - const { data: { user } } = await supabase.auth.getUser(); + const { data, error } = await supabase.auth.getUser(); + if (error) { + console.error("Auth getUser error:", error); + } + if (!data?.user) { + return { supabase, user: null as const }; + } + return { supabase, user: data.user }; +}; + +export async function GET(request: Request, context: RouteContext) { + const { supabase, user } = await getAuthUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // 强制 await params,以解决 Next.js 16/Turbopack 错误。 - // 必须忽略类型检查,因为 Next.js 16/Turbopack 在运行时将 params 视为 Promise。 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tableId = (await (context as any).params).tableId; + const tableId = await extractTableId(context); if (!tableId) { return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); @@ -49,3 +74,160 @@ export async function GET(request: Request, context: RouteContext) { return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } } + +export async function PATCH(request: Request, context: RouteContext) { + const { supabase, user } = await getAuthUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const tableId = await extractTableId(context); + if (!tableId) { + return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); + } + + let body: UpdateTableRequest | null = null; + try { + body = await request.json() as UpdateTableRequest; + } catch (error) { + console.error("Invalid payload:", error); + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + try { + const { data: tableMeta, error: metaError } = await supabase + .from("document_tables") + .select("id, workspace_id, document_id") + .eq("id", tableId) + .single(); + + if (metaError || !tableMeta) { + console.error("Table not found or load error:", metaError); + return NextResponse.json({ error: "Table not found" }, { status: 404 }); + } + + const updatePayload: Record = { + updated_by: user.id, + last_synced_at: new Date().toISOString(), + }; + + if (body?.title !== undefined) { + updatePayload.title = body.title; + } + if (body?.schema) { + updatePayload.schema = body.schema; + } + if (body?.snapshot !== undefined) { + updatePayload.snapshot = body.snapshot ?? {}; + } + if (body?.viewPreferences) { + updatePayload.view_preferences = body.viewPreferences; + } + + const { error: updateError } = await supabase + .from("document_tables") + .update(updatePayload) + .eq("id", tableId); + + if (updateError) { + console.error("Failed to update table:", updateError); + return NextResponse.json({ error: "Failed to update table", details: updateError.message }, { status: 500 }); + } + + const nextRows = body?.rows ?? body?.snapshot?.rows ?? []; + if (Array.isArray(nextRows)) { + const { error: deleteRowsError } = await supabase + .from("document_table_rows") + .delete() + .eq("table_id", tableId); + + if (deleteRowsError) { + console.error("Failed to clear old rows:", deleteRowsError); + return NextResponse.json({ error: "Failed to update rows", details: deleteRowsError.message }, { status: 500 }); + } + + const sanitizedRows = nextRows + .filter((row) => row && typeof row === "object" && Object.keys(row).length > 0); + + if (sanitizedRows.length > 0) { + const rowsToInsert = sanitizedRows.map((row, index) => ({ + table_id: tableId, + workspace_id: tableMeta.workspace_id, + document_id: tableMeta.document_id, + row_index: index, + row_data: row, + row_hash: null, + is_deleted: false, + updated_by: user.id, + })); + + const { error: insertError } = await supabase + .from("document_table_rows") + .insert(rowsToInsert); + + if (insertError) { + console.error("Failed to insert rows:", insertError); + return NextResponse.json({ error: "Failed to insert rows", details: insertError.message }, { status: 500 }); + } + } + } + + const { data: latest, error: fetchError } = await supabase + .from("document_tables") + .select("*") + .eq("id", tableId) + .single(); + + if (fetchError || !latest) { + console.error("Failed to fetch updated table:", fetchError); + return NextResponse.json({ error: "Failed to load updated table", details: fetchError?.message }, { status: 500 }); + } + + return NextResponse.json(latest, { status: 200 }); + } catch (error) { + console.error("API error:", error); + const message = error instanceof Error ? error.message : "Internal Server Error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const { supabase, user } = await getAuthUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const tableId = await extractTableId(context); + if (!tableId) { + return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); + } + + try { + const { error: deleteRowsError } = await supabase + .from("document_table_rows") + .delete() + .eq("table_id", tableId); + + if (deleteRowsError) { + console.error("Failed to delete table rows:", deleteRowsError); + return NextResponse.json({ error: "Failed to delete table rows" }, { status: 500 }); + } + + const { error: deleteTableError } = await supabase + .from("document_tables") + .delete() + .eq("id", tableId); + + if (deleteTableError) { + console.error("Failed to delete table:", deleteTableError); + return NextResponse.json({ error: "Failed to delete table" }, { status: 500 }); + } + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + console.error("API error:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/wolai-frontend/src/app/api/tables/create/route.ts b/wolai-frontend/src/app/api/tables/create/route.ts index d3cda808..2c309728 100644 --- a/wolai-frontend/src/app/api/tables/create/route.ts +++ b/wolai-frontend/src/app/api/tables/create/route.ts @@ -48,6 +48,8 @@ export async function POST(request: Request) { document_id: documentId, title: title, schema: schema, + view_preferences: {}, + is_archived: false, created_by: user.id, updated_by: user.id, snapshot: snapshot ?? {}, diff --git a/wolai-frontend/src/components/editor/blocks/OnlineTableBlock.tsx b/wolai-frontend/src/components/editor/blocks/OnlineTableBlock.tsx index 5f0d0537..69243dc0 100644 --- a/wolai-frontend/src/components/editor/blocks/OnlineTableBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/OnlineTableBlock.tsx @@ -4,7 +4,7 @@ import { BlockNoteEditor, Block } from "@blocknote/core"; import { createReactBlockSpec } from "@blocknote/react"; import { OnlineTableBlockProps } from "@/types/online-table"; import { Table } from "lucide-react"; -import React from "react"; +import React, { useCallback } from "react"; import type { CustomBlockSchema } from "../schema"; import CompactTablePreview from "@/components/online-table/CompactTablePreview"; import { useEditorBridgeStore } from "@/store/editor-bridge"; @@ -29,9 +29,13 @@ const OnlineTableBlockComponent = ({ } }; + const handleDelete = useCallback(() => { + editor.removeBlocks([block.id]); + }, [block.id, editor]); + return (
- +
); }; diff --git a/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx b/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx index 45a56b09..737740b5 100644 --- a/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx +++ b/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx @@ -14,6 +14,7 @@ import { } from "@blocknote/react"; import { useRouter } from "next/navigation"; import type { CustomBlockSchema } from "../schema"; +import { deleteOnlineTable } from "@/lib/online-table"; type InlineNode = { text?: unknown }; type TableMenuBlock = Parameters< @@ -72,6 +73,16 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => void removePageReference(); return; } + if (block.type === "onlineTable") { + const tableId = block.props.tableId as string | undefined; + if (tableId) { + void deleteOnlineTable(tableId) + .catch((error) => console.error("删除在线表格失败", error)); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } })); + } + } + } editor.removeBlocks([block.id]); }, [block, editor, removePageReference]); diff --git a/wolai-frontend/src/components/online-table/CompactTablePreview.tsx b/wolai-frontend/src/components/online-table/CompactTablePreview.tsx index f2a97fc6..ad3420d9 100644 --- a/wolai-frontend/src/components/online-table/CompactTablePreview.tsx +++ b/wolai-frontend/src/components/online-table/CompactTablePreview.tsx @@ -1,67 +1,172 @@ "use client"; import React, { useEffect, useState, useMemo, useCallback } from "react"; -import { DocumentTable, TableColumn } from "@/types/online-table"; -import { DEFAULT_TABLE_COLUMNS, DEFAULT_TABLE_ROWS, getDocumentTable } from "@/lib/online-table"; -import { Loader2, Table, Maximize2, Plus, Minus } from "lucide-react"; +import { DocumentTable, TableColumn, TableRowData } from "@/types/online-table"; +import { + DEFAULT_TABLE_COLUMNS, + DEFAULT_TABLE_ROWS, + deleteOnlineTable, + getDocumentTable, +} from "@/lib/online-table"; +import { Loader2, Table, Maximize2, Trash2 } from "lucide-react"; interface CompactTablePreviewProps { tableId: string; onFullScreen: () => void; + onDelete?: () => void; } -// 模拟获取表格行数据(阶段二只关注元数据和预览结构) -// 实际应用中,这里会调用 getDocumentTable 并可能获取前N行数据 const useTableData = (tableId: string) => { const [table, setTable] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [version, setVersion] = useState(0); + + const refresh = useCallback(() => setVersion((prev) => prev + 1), []); useEffect(() => { + let aborted = false; setIsLoading(true); getDocumentTable(tableId) .then((data) => { + if (aborted) return; setTable({ ...data, title: data.title || "未命名表格" }); }) .catch((err) => { console.error("Failed to load table:", err); + if (aborted) return; setTable(null); }) - .finally(() => setIsLoading(false)); - }, [tableId]); + .finally(() => { + if (!aborted) { + setIsLoading(false); + } + }); + return () => { + aborted = true; + }; + }, [tableId, version]); - return { table, isLoading }; + return { table, isLoading, refresh }; }; -// 渲染单个单元格内容的辅助函数 -const renderCellContent = (column: TableColumn, value: any) => { - if (value === undefined || value === null || value === "") { - return ""; - } - if (column.type === 'select') { - const option = column.options?.find(opt => opt.value === value); - if (option) { - return ( - - {value} - - ); - } - } - return String(value); -} +const pickCellDisplayValue = (cell: any) => { + if (!cell) return undefined; + if (cell.m !== undefined && cell.m !== null) return cell.m; + if (cell.v?.m !== undefined && cell.v?.m !== null) return cell.v.m; + if (cell.v?.v !== undefined && cell.v?.v !== null) return cell.v.v; + if (cell.v !== undefined && cell.v !== null && typeof cell.v !== "object") return cell.v; + if (cell.w !== undefined && cell.w !== null) return cell.w; + return undefined; +}; -const CompactTablePreview: React.FC = ({ tableId, onFullScreen }) => { - const { table, isLoading } = useTableData(tableId); - const [hoveredRowIndex, setHoveredRowIndex] = useState(null); - const [editingCell, setEditingCell] = useState<{ rowIndex: number, colId: string } | null>(null); +const deriveRowsFromLuckysheet = (luckysheetData: any, columns: TableColumn[]): TableRowData[] => { + const sheets = Array.isArray(luckysheetData) ? luckysheetData : []; + const sheet = sheets[0]; + if (!sheet) return []; + + const columnIds = columns.length > 0 + ? columns.map((col) => col.id) + : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, index) => `col${index + 1}`); + + const rows: TableRowData[] = []; + const dataGrid = Array.isArray(sheet.data) ? sheet.data : []; + + dataGrid.forEach((rowData: any[], rowIndex: number) => { + if (!Array.isArray(rowData)) return; + const row: TableRowData = {}; + let hasValue = false; + columnIds.forEach((colId, colIndex) => { + const value = pickCellDisplayValue(rowData[colIndex]); + if (value !== undefined && value !== null && value !== "") { + row[colId] = value; + hasValue = true; + } + }); + if (hasValue) rows.push(row); + }); + + if (rows.length === 0 && Array.isArray(sheet.celldata)) { + const map = new Map(); + sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => { + const value = pickCellDisplayValue(cell?.v ?? cell); + if (value === undefined || value === null || value === "") return; + const existing = map.get(cell.r) ?? {}; + existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value; + map.set(cell.r, existing); + }); + Array.from(map.entries()) + .sort(([a], [b]) => a - b) + .forEach(([, row]) => rows.push(row)); + } + + return rows; +}; + +const renderCellContent = (column: TableColumn, value: any) => { + if (value === undefined || value === null || value === "") { + return ""; + } + if (column.type === "select") { + const option = column.options?.find((opt) => opt.value === value); + if (option) { + return ( + + {value} + + ); + } + } + return String(value); +}; + +const CompactTablePreview: React.FC = ({ tableId, onFullScreen, onDelete }) => { + const { table, isLoading, refresh } = useTableData(tableId); const [visibleRows, setVisibleRows] = useState(5); const [visibleCols, setVisibleCols] = useState(5); - const snapshotRows = table?.snapshot?.rows ?? []; - const schemaColumns = table?.schema.columns ?? []; - const hasData = snapshotRows.length > 0 && schemaColumns.length > 0; + const schemaColumns = useMemo( + () => Array.isArray(table?.schema?.columns) ? table!.schema.columns : [], + [table], + ); - const totalRowsAvailable = hasData ? snapshotRows.length : DEFAULT_TABLE_ROWS; + const rows: TableRowData[] = useMemo(() => { + if (!table) return []; + if (Array.isArray(table.snapshot?.rows) && table.snapshot.rows.length > 0) { + return table.snapshot.rows as TableRowData[]; + } + if ((table.snapshot as { luckysheet?: unknown })?.luckysheet) { + return deriveRowsFromLuckysheet( + (table.snapshot as { luckysheet?: unknown }).luckysheet, + schemaColumns, + ); + } + return []; + }, [schemaColumns, table]); + + useEffect(() => { + const handleSaved = (event: Event) => { + const detail = (event as CustomEvent<{ tableId?: string }>).detail; + if (detail?.tableId === tableId) { + refresh(); + } + }; + const handleDeleted = (event: Event) => { + const detail = (event as CustomEvent<{ tableId?: string }>).detail; + if (detail?.tableId === tableId) { + refresh(); + } + }; + window.addEventListener("online-table-saved", handleSaved as EventListener); + window.addEventListener("online-table-deleted", handleDeleted as EventListener); + return () => { + window.removeEventListener("online-table-saved", handleSaved as EventListener); + window.removeEventListener("online-table-deleted", handleDeleted as EventListener); + }; + }, [refresh, tableId]); + + const hasData = rows.length > 0 && schemaColumns.length > 0; + + const totalRowsAvailable = hasData ? rows.length : DEFAULT_TABLE_ROWS; const totalColsAvailable = hasData ? schemaColumns.length : DEFAULT_TABLE_COLUMNS; useEffect(() => { @@ -80,7 +185,7 @@ const CompactTablePreview: React.FC = ({ tableId, onFu }, [totalRowsAvailable, totalColsAvailable, visibleRows, visibleCols]); const columns = useMemo(() => { - if (hasData) { + if (schemaColumns.length > 0) { return schemaColumns.slice(0, visibleCols); } const placeholderCount = Math.max(visibleCols, 5); @@ -90,15 +195,15 @@ const CompactTablePreview: React.FC = ({ tableId, onFu type: "text" as const, width: 100, })); - }, [hasData, schemaColumns, visibleCols]); + }, [schemaColumns, visibleCols]); const rowsToDisplay = useMemo(() => { - if (hasData) { - return snapshotRows.slice(0, visibleRows); + if (rows.length > 0) { + return rows.slice(0, visibleRows); } const placeholderCount = Math.max(visibleRows, 5); return Array.from({ length: placeholderCount }, (_, index) => ({ id: `placeholder_row_${index}` })); - }, [hasData, snapshotRows, visibleRows]); + }, [rows, visibleRows]); const headerCells = useMemo(() => { const cells = [ @@ -122,6 +227,19 @@ const CompactTablePreview: React.FC = ({ tableId, onFu event.stopPropagation(); }, []); + const handleDeleteTable = useCallback(async () => { + const confirmed = window.confirm("删除表格将同步移除 Supabase 记录,确认继续?"); + if (!confirmed) return; + try { + await deleteOnlineTable(tableId); + window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } })); + onDelete?.(); + } catch (error) { + console.error("删除表格失败", error); + window.alert("删除失败,请稍后重试"); + } + }, [onDelete, tableId]); + if (isLoading) { return (
@@ -139,12 +257,6 @@ const CompactTablePreview: React.FC = ({ tableId, onFu ); } - // 模拟行操作:插入/删除 - // 实际应用中,这里需要调用后端或协同服务 - const handleRowOperation = (rowIndex: number, type: 'insert' | 'delete') => { - console.log(`${type} row at index ${rowIndex} in table ${tableId}`); - } - return (
= ({ tableId, onFu onMouseUp={suppressEditorEvents} onMouseMove={suppressEditorEvents} > - {/* 表格标题 */}

{table.title}

- {/* 全屏按钮 (hover toolbar的一部分) */} - +
+ 只读预览,双击进入全屏编辑 + + +
- {/* 紧凑表格视图 */}
- {/* 表头 */} {headerCells} - {/* 表体 */} - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {rowsToDisplay.map((row: any, index: number) => { - const rowId = row.id || index; + {rowsToDisplay.map((row, index) => { + const rowId = (row as { id?: string }).id || index; return ( - setHoveredRowIndex(index)} - onMouseLeave={() => setHoveredRowIndex(null)} + - {/* 行操作按钮 */} - - {columns.map((col) => { - const isEditing = editingCell?.rowIndex === index && editingCell?.colId === col.id; - const cellValue = row[col.id]; - - const handleCellClick = () => { - // 仅允许轻编辑文本和数字类型 - if (col.type === 'text' || col.type === 'number') { - setEditingCell({ rowIndex: index, colId: col.id }); - } else { - // TODO: 复杂类型(Select, Date等)引导进入全屏 - onFullScreen(); - } - } - - const handleBlur = (e: React.FocusEvent) => { - // TODO: 实际更新 Supabase 数据 - console.log(`Cell ${col.id} at row ${index} updated to: ${e.target.value}`); - setEditingCell(null); - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === 'Escape') { - // 提交或取消编辑 - e.currentTarget.blur(); - } - } - - return ( - - ); - })} + + ))} ); })} - {/* 提示更多行 */} - {table.snapshot?.rows && rowsToDisplay.length < table.snapshot.rows.length && ( - - - + {rows.length > rowsToDisplay.length && ( + + + )}
- {hoveredRowIndex === index && ( -
- - -
- )} -
- {isEditing ? ( - - ) : ( - renderCellContent(col, cellValue) - )} - + {columns.map((col) => ( + + {renderCellContent(col, (row as Record)[col.id])} +
- ... 更多 { table.snapshot.rows.length - rowsToDisplay.length } 行数据,双击进入全屏查看 -
+ ... 更多 {rows.length - rowsToDisplay.length} 行数据,双击进入全屏查看 +
diff --git a/wolai-frontend/src/components/online-table/FullScreenTableEditor.tsx b/wolai-frontend/src/components/online-table/FullScreenTableEditor.tsx index 82c1c4e5..9845ba20 100644 --- a/wolai-frontend/src/components/online-table/FullScreenTableEditor.tsx +++ b/wolai-frontend/src/components/online-table/FullScreenTableEditor.tsx @@ -1,9 +1,16 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; -import { Table, X, Zap } from "lucide-react"; -import type { DocumentTable } from "@/types/online-table"; -import { DEFAULT_TABLE_COLUMNS, DEFAULT_TABLE_ROWS, DEFAULT_TABLE_SCHEMA, createDefaultTableSnapshot } from "@/lib/online-table"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Loader2, Table, X, Zap } from "lucide-react"; +import type { DocumentTable, TableRowData } from "@/types/online-table"; +import { + DEFAULT_TABLE_COLUMNS, + DEFAULT_TABLE_ROWS, + DEFAULT_TABLE_SCHEMA, + createDefaultTableSnapshot, + saveOnlineTable, +} from "@/lib/online-table"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; interface FullScreenTableEditorProps { tableId: string; @@ -27,14 +34,72 @@ const LUCKY_SHEET_RESOURCES = { ], }; +const extractRowsForPreview = (luckysheetData: any, columns: Array<{ id: string }>): TableRowData[] => { + const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null; + if (!sheet) return []; + const columnIds = columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`); + const rows: TableRowData[] = []; + const grid = Array.isArray(sheet.data) ? sheet.data : []; + + const pickValue = (cell: any) => { + if (!cell) return undefined; + if (cell.m !== undefined && cell.m !== null) return cell.m; + if (cell.v?.m !== undefined && cell.v?.m !== null) return cell.v.m; + if (cell.v?.v !== undefined && cell.v?.v !== null) return cell.v.v; + if (cell.v !== undefined && cell.v !== null && typeof cell.v !== "object") return cell.v; + if (cell.w !== undefined && cell.w !== null) return cell.w; + return undefined; + }; + + grid.forEach((row: any[], rowIndex: number) => { + if (!Array.isArray(row)) return; + const rowObj: TableRowData = {}; + let hasValue = false; + columnIds.forEach((colId, colIndex) => { + const value = pickValue(row[colIndex]); + if (value !== undefined && value !== null && value !== "") { + rowObj[colId] = value; + hasValue = true; + } + }); + if (hasValue) { + rows.push(rowObj); + } + }); + + if (rows.length === 0 && Array.isArray(sheet.celldata)) { + const map = new Map(); + sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => { + const value = pickValue(cell?.v ?? cell); + if (value === undefined || value === null || value === "") return; + const existing = map.get(cell.r) ?? {}; + existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value; + map.set(cell.r, existing); + }); + Array.from(map.entries()) + .sort(([a], [b]) => a - b) + .forEach(([, row]) => rows.push(row)); + } + + return rows; +}; + const FullScreenTableEditor: React.FC = ({ tableId, onClose }) => { const containerRef = useRef(null); + const isApplyingSnapshotRef = useRef(false); + const hasInitializedRef = useRef(false); + const lastTableIdRef = useRef(null); const [isLoaded, setIsLoaded] = useState(false); const [tableData, setTableData] = useState(null); const [isTableLoading, setIsTableLoading] = useState(true); const [tableError, setTableError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [hasPendingChanges, setHasPendingChanges] = useState(false); + const [saveError, setSaveError] = useState(null); + const [lastSyncedAt, setLastSyncedAt] = useState(null); + const persistSnapshotRef = useRef<((reason: "auto" | "close") => Promise) | null>(null); - const fetchTable = (id: string) => { + const fetchTable = useCallback((id: string) => { setIsTableLoading(true); setTableError(null); setTableData(null); @@ -54,11 +119,13 @@ const FullScreenTableEditor: React.FC = ({ tableId, setTableData(null); }) .finally(() => setIsTableLoading(false)); - }; + }, []); useEffect(() => { fetchTable(tableId); - }, [tableId]); + hasInitializedRef.current = false; + lastTableIdRef.current = tableId; + }, [fetchTable, tableId]); // 动态加载 Luckysheet 资源 useEffect(() => { @@ -71,7 +138,7 @@ const FullScreenTableEditor: React.FC = ({ tableId, if (document.querySelector(`${tag}[href="${url}"]`) || document.querySelector(`${tag}[src="${url}"]`)) { return true; } - + if (tag === "link") { const link = document.createElement("link"); link.rel = "stylesheet"; @@ -89,10 +156,8 @@ const FullScreenTableEditor: React.FC = ({ tableId, return false; }; - // 加载所有 CSS - LUCKY_SHEET_RESOURCES.css.forEach(url => loadResource("link", url)); + LUCKY_SHEET_RESOURCES.css.forEach((url) => loadResource("link", url)); - // 串行加载 JS,确保顺序 const loadJsSequentially = async () => { for (const url of LUCKY_SHEET_RESOURCES.js) { await loadResource("script", url); @@ -101,16 +166,64 @@ const FullScreenTableEditor: React.FC = ({ tableId, }; loadJsSequentially(); - }, []); - const getLuckysheetSheets = () => { + const luckysheetSheets = useMemo(() => { if (tableData?.snapshot?.luckysheet && Array.isArray(tableData.snapshot.luckysheet) && tableData.snapshot.luckysheet.length > 0) { return tableData.snapshot.luckysheet; } const snapshot = createDefaultTableSnapshot(tableData?.schema ?? DEFAULT_TABLE_SCHEMA); return snapshot.luckysheet ?? []; - }; + }, [tableData]); + + const persistSnapshot = useCallback(async (reason: "auto" | "close") => { + if (!tableData || !window.luckysheet || typeof window.luckysheet.getluckysheetfile !== "function") { + return; + } + setIsSaving(true); + setSaveError(null); + try { + const luckysheetData = window.luckysheet.getluckysheetfile?.() ?? luckysheetSheets; + const rows = extractRowsForPreview( + luckysheetData, + (tableData.schema?.columns ?? []).map((item) => ({ id: item.id })), + ).filter((row) => row && typeof row === "object" && Object.keys(row).length > 0); + const snapshot = { + ...(tableData.snapshot ?? {}), + rows, + luckysheet: Array.isArray(luckysheetData) ? luckysheetData : luckysheetSheets, + }; + const updated = await saveOnlineTable(tableId, { + snapshot, + rows, + schema: tableData.schema, + }); + setTableData(updated); + setHasPendingChanges(false); + setLastSyncedAt(Date.now()); + window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } })); + } catch (error) { + console.error("保存 Luckysheet 数据失败", error); + setSaveError(reason === "close" ? "关闭前保存失败,请重试" : "自动保存失败"); + } finally { + setIsSaving(false); + } + }, [tableData, tableId]); + + const debouncedPersist = useDebouncedCallback(() => { + void persistSnapshot("auto"); + }, 1200); + + useEffect(() => { + persistSnapshotRef.current = persistSnapshot; + }, [persistSnapshot]); + + useEffect(() => { + return () => { + debouncedPersist.cancel(); + void persistSnapshotRef.current?.("close"); + }; + }, [debouncedPersist]); // Luckysheet 初始化和清理 useEffect(() => { @@ -118,6 +231,13 @@ const FullScreenTableEditor: React.FC = ({ tableId, return; } + if (hasInitializedRef.current && lastTableIdRef.current === tableId) { + return; + } + + hasInitializedRef.current = true; + isApplyingSnapshotRef.current = true; + if (containerRef.current.children.length > 0) { window.luckysheet.destroy(LUCKY_SHEET_CONTAINER_ID); containerRef.current.innerHTML = ""; @@ -134,22 +254,48 @@ const FullScreenTableEditor: React.FC = ({ tableId, allowEdit: true, row: DEFAULT_TABLE_ROWS, column: DEFAULT_TABLE_COLUMNS, - data: getLuckysheetSheets(), + data: luckysheetSheets, + hook: { + updated: () => { + if (isApplyingSnapshotRef.current) return; + setHasPendingChanges(true); + debouncedPersist(); + }, + }, }; window.luckysheet.create(options); + // 等待首帧渲染完成再开放 updated 事件 + setTimeout(() => { + isApplyingSnapshotRef.current = false; + }, 0); + return () => { if (window.luckysheet) { window.luckysheet.destroy(LUCKY_SHEET_CONTAINER_ID); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableId, isLoaded, tableData]); + }, [debouncedPersist, luckysheetSheets, isLoaded, tableData, tableId]); + + const handleClose = async () => { + await persistSnapshot("close"); + onClose(); + }; const showLoadingOverlay = !isLoaded || isTableLoading; const loadingMessage = !isLoaded ? "正在加载 Luckysheet 资源..." : "正在加载表格数据..."; + const statusText = saveError + ? saveError + : isSaving + ? "同步中..." + : hasPendingChanges + ? "有未保存变更" + : lastSyncedAt + ? `已同步 ${new Date(lastSyncedAt).toLocaleTimeString()}` + : "准备就绪"; + return (
{/* 顶部工具栏 */} @@ -157,34 +303,34 @@ const FullScreenTableEditor: React.FC = ({ tableId,

- 在线表格编辑 - {(tableData?.title ?? tableId).slice(0, 16)} + 在线表格编辑 - {(tableData?.title ?? tableId).slice(0, 24)}

协同模式 (单用户模式) - +
+ {statusText} + +
{/* Luckysheet 容器 */} - {/* 确保容器在加载完成后可见,并使用 ref 绑定 */}
- {/* Luckysheet 将在这个容器中初始化 */} -
+ style={{ display: isLoaded && !isTableLoading && !tableError ? "block" : "none" }} + /> {showLoadingOverlay && (
-

{loadingMessage}

+

{loadingMessage}

)} diff --git a/wolai-frontend/src/lib/online-table.ts b/wolai-frontend/src/lib/online-table.ts index d654f62b..6ebd579c 100644 --- a/wolai-frontend/src/lib/online-table.ts +++ b/wolai-frontend/src/lib/online-table.ts @@ -1,13 +1,9 @@ -import { DocumentTable, DocumentTableSnapshot, TableSchema } from "@/types/online-table"; +import { DocumentTable, DocumentTableSnapshot, TableRowData, TableSchema } from "@/types/online-table"; -// 默认表格结构:三列,文本类型,冻结首行 +// 默认表格结构:全部为空列,交由用户/全屏自行定义 export const DEFAULT_TABLE_SCHEMA: TableSchema = { - columns: [ - { id: "col1", name: "名称", type: "text", width: 200 }, - { id: "col2", name: "状态", type: "select", width: 150, options: [{ value: "Todo", color: "red" }, { value: "Done", color: "green" }] }, - { id: "col3", name: "创建日期", type: "date", width: 150 }, - ], - frozenRowCount: 1, + columns: [], + frozenRowCount: 0, frozenColCount: 0, }; @@ -28,11 +24,11 @@ export const createDefaultTableSnapshot = (schema: TableSchema): DocumentTableSn defaultRowHeight: 19, defaultColWidth: 73, celldata: [], - config: {}, - frozen: { - row: String(schema.frozenRowCount ?? 0), - column: String(schema.frozenColCount ?? 0), + config: { + columnlen: {}, + rowlen: {}, }, + frozen: {}, scrollLeft: 0, scrollTop: 0, zoomRatio: 1, @@ -89,3 +85,45 @@ export async function getDocumentTable(tableId: string): Promise return response.json() as Promise; } + +/** + * 将表格的 snapshot/行数据回写 Supabase,保持内联与全屏视图一致。 + */ +export async function saveOnlineTable( + tableId: string, + payload: { + title?: string; + schema?: TableSchema; + snapshot?: DocumentTableSnapshot | null; + rows?: TableRowData[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + viewPreferences?: Record; + }, +): Promise { + const response = await fetch(`/api/tables/${tableId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const message = await response.text().catch(() => ""); + throw new Error(message || "Failed to save online table."); + } + + return response.json() as Promise; +} + +/** + * 删除表格及其行数据。 + */ +export async function deleteOnlineTable(tableId: string): Promise { + const response = await fetch(`/api/tables/${tableId}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error("Failed to delete online table."); + } +} diff --git a/wolai-frontend/src/store/editor-bridge.ts b/wolai-frontend/src/store/editor-bridge.ts index 6ae5a823..36cf31d5 100644 --- a/wolai-frontend/src/store/editor-bridge.ts +++ b/wolai-frontend/src/store/editor-bridge.ts @@ -12,6 +12,7 @@ export interface EditorReferenceBridge { insertInlineReference: (target: ReferenceTarget, alias?: string) => EditorReferenceBridgeResult; insertEmbedReference: (target: ReferenceTarget) => EditorReferenceBridgeResult; replaceWithSnapshot: (blocks: Json) => void; + openTableFullScreen?: (tableId: string) => void; } interface EditorBridgeState { diff --git a/wolai-frontend/src/types/online-table.ts b/wolai-frontend/src/types/online-table.ts index 71839eb2..72d5a261 100644 --- a/wolai-frontend/src/types/online-table.ts +++ b/wolai-frontend/src/types/online-table.ts @@ -55,6 +55,8 @@ declare global { // eslint-disable-next-line @typescript-eslint/no-explicit-any create: (options: any) => void; destroy: (id: string) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getluckysheetfile: () => any; // 实际功能会通过动态加载的脚本注入 // 最小化声明以通过 TypeScript 检查 };