diff --git a/design/file-tree-vscode-checklist.md b/design/file-tree-vscode-checklist.md index ca77c552..39c6bbae 100644 --- a/design/file-tree-vscode-checklist.md +++ b/design/file-tree-vscode-checklist.md @@ -133,6 +133,7 @@ - [x] 复制 doc:`/api/documents/copy-tree` 已验证返回 200(页面端 `fetch`) - [ ] 复制 asset:生成新的存储对象(不是引用),新文件可独立下载(需要至少 1 个真实附件用例) - [x] 拖拽移动 doc:已通过页面拖拽触发 `/api/documents/move` 并验证 200 +- [x] 从系统拖拽文件到文件树:落点为目标页面(doc 行 / index 行 / asset 行),触发 `/api/media/upload` 上传并作为“真实文件”附件挂到该页面下 - [ ] Alt+拖拽复制:逻辑已接入(`event.altKey`),需要人工补测一次(MCP 暂无法“按住 Alt 拖拽”) --- diff --git a/wolai-frontend/src/app/api/documents/copy-tree/route.ts b/wolai-frontend/src/app/api/documents/copy-tree/route.ts index e8c7f144..49bfbaf6 100644 --- a/wolai-frontend/src/app/api/documents/copy-tree/route.ts +++ b/wolai-frontend/src/app/api/documents/copy-tree/route.ts @@ -59,13 +59,23 @@ async function ensureDocumentScaffold(id: string, title: string | null) { } async function copyMindmapIfExists(sourceId: string, targetId: string) { - const src = path.join(documentsBaseDir, sourceId, "mindmap.json"); + const srcDir = path.join(documentsBaseDir, sourceId); const destDir = path.join(documentsBaseDir, targetId); - const dest = path.join(destDir, "mindmap.json"); try { - const buf = await fs.readFile(src); + const entries = await fs.readdir(srcDir); + const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\\.json$/i.test(name)); + if (mindmapFiles.length === 0) return; await fs.mkdir(destDir, { recursive: true }); - await fs.writeFile(dest, buf); + await Promise.all( + mindmapFiles.map(async (name) => { + try { + const buf = await fs.readFile(path.join(srcDir, name)); + await fs.writeFile(path.join(destDir, name), buf); + } catch { + // ignore + } + }), + ); } catch { // 源不存在则忽略 } @@ -264,7 +274,8 @@ export async function POST(request: Request) { } else { siblingQuery.is("parent_id", null); } - const { count: siblingCount = 0 } = await siblingQuery; + const { count: rawSiblingCount } = await siblingQuery; + const siblingCount = rawSiblingCount ?? 0; const nextSortByParent = new Map([[targetParentId, siblingCount]]); const insertedDocs: Array<{ oldId: string; newId: string }> = []; diff --git a/wolai-frontend/src/app/api/documents/create-child/route.ts b/wolai-frontend/src/app/api/documents/create-child/route.ts index cd72c30a..9070ccc1 100644 --- a/wolai-frontend/src/app/api/documents/create-child/route.ts +++ b/wolai-frontend/src/app/api/documents/create-child/route.ts @@ -60,7 +60,8 @@ export async function POST(request: Request) { siblingQuery.is("parent_id", null); } - const { count: siblingCount = 0, error: countError } = await siblingQuery; + const { count: rawSiblingCount, error: countError } = await siblingQuery; + const siblingCount = rawSiblingCount ?? 0; if (countError) { return NextResponse.json({ error: countError.message }, { status: 500 }); diff --git a/wolai-frontend/src/app/api/documents/create/route.ts b/wolai-frontend/src/app/api/documents/create/route.ts index b9fd876d..3462d762 100644 --- a/wolai-frontend/src/app/api/documents/create/route.ts +++ b/wolai-frontend/src/app/api/documents/create/route.ts @@ -86,7 +86,8 @@ async function handleCreateRequest(request: Request) { siblingQuery.is("parent_id", null); } - const { count: siblingCount = 0, error: countError } = await siblingQuery; + const { count: rawSiblingCount, error: countError } = await siblingQuery; + const siblingCount = rawSiblingCount ?? 0; if (countError) { return NextResponse.json({ error: countError.message }, { status: 500 }); diff --git a/wolai-frontend/src/app/api/documents/duplicate/route.ts b/wolai-frontend/src/app/api/documents/duplicate/route.ts index 046d72d4..40657a43 100644 --- a/wolai-frontend/src/app/api/documents/duplicate/route.ts +++ b/wolai-frontend/src/app/api/documents/duplicate/route.ts @@ -19,13 +19,23 @@ async function ensureDocumentScaffold(id: string, title: string | null) { } async function copyMindmapIfExists(sourceId: string, targetId: string) { - const src = path.join(documentsBaseDir, sourceId, "mindmap.json"); - const destDir = path.join(documentsBaseDir, targetId); - const dest = path.join(destDir, "mindmap.json"); try { - const buf = await fs.readFile(src); + const srcDir = path.join(documentsBaseDir, sourceId); + const destDir = path.join(documentsBaseDir, targetId); + const entries = await fs.readdir(srcDir); + const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\\.json$/i.test(name)); + if (mindmapFiles.length === 0) return; await fs.mkdir(destDir, { recursive: true }); - await fs.writeFile(dest, buf); + await Promise.all( + mindmapFiles.map(async (name) => { + try { + const buf = await fs.readFile(path.join(srcDir, name)); + await fs.writeFile(path.join(destDir, name), buf); + } catch { + // ignore + } + }), + ); } catch { // 如果源不存在则忽略 } @@ -68,7 +78,8 @@ export async function POST(request: Request) { siblingQuery.is("parent_id", null); } - const { count: siblingCount = 0, error: countError } = await siblingQuery; + const { count: rawSiblingCount, error: countError } = await siblingQuery; + const siblingCount = rawSiblingCount ?? 0; if (countError) { return NextResponse.json({ error: countError.message }, { status: 500 }); } diff --git a/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts new file mode 100644 index 00000000..685f7fda --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap/[docId]/[mindmapId]/route.ts @@ -0,0 +1,158 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { promises as fs } from "fs"; +import path from "path"; + +const defaultMindmapData = { + data: { text: "中心主题" }, + children: [], +}; + +const documentsBaseDir = path.join(process.cwd(), "public", "documents"); + +async function ensureDir(dir: string) { + await fs.mkdir(dir, { recursive: true }); +} + +async function removeFileSafe(file: string) { + try { + await fs.rm(file, { force: true }); + } catch { + // ignore + } +} + +async function ensureIndexFile(folder: string, title = "无标题") { + const indexFile = path.join(folder, "index.md"); + try { + await fs.access(indexFile); + } catch { + await fs.writeFile(indexFile, `# ${title}\n`, "utf8"); + } +} + +function resolveMindmapFileName(mindmapId: string) { + if (mindmapId === "legacy" || mindmapId.startsWith("legacy-")) { + return "mindmap.json"; + } + return `mindmap-${mindmapId}.json`; +} + +async function tryReadJson(file: string) { + try { + const content = await fs.readFile(file, "utf8"); + return JSON.parse(content); + } catch { + return null; + } +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ docId: string; mindmapId: string }> }, +) { + const { docId, mindmapId } = await params; + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const folder = path.join(documentsBaseDir, docId); + const file = path.join(folder, resolveMindmapFileName(mindmapId)); + const legacyFile = path.join(folder, "mindmap.json"); + + const localData = (await tryReadJson(file)) ?? (await tryReadJson(legacyFile)); + if (localData) { + return NextResponse.json({ data: localData, source: "local" }); + } + + // 兼容旧版:没有本地文件时,回退到 documents.mindmap_data(仅能表示单个旧导图) + const { data, error } = await supabase + .from("documents") + .select("mindmap_data") + .eq("id", docId) + .eq("user_id", session.user.id) + .maybeSingle(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + const payload = data?.mindmap_data ?? defaultMindmapData; + return NextResponse.json({ data: payload, source: "supabase" }); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ docId: string; mindmapId: string }> }, +) { + const { docId, mindmapId } = await params; + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + // 校验页面归属(避免任意写入) + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id,title") + .eq("id", docId) + .eq("user_id", session.user.id) + .maybeSingle(); + if (docError) { + return NextResponse.json({ error: docError.message }, { status: 400 }); + } + if (!doc) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + const { data } = await request.json().catch(() => ({ data: null })); + const folder = path.join(documentsBaseDir, docId); + const file = path.join(folder, resolveMindmapFileName(mindmapId)); + try { + await ensureDir(folder); + await ensureIndexFile(folder, doc.title ?? "无标题"); + await fs.writeFile(file, JSON.stringify(data ?? defaultMindmapData, null, 2), "utf8"); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } + + return NextResponse.json({ ok: true }); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ docId: string; mindmapId: string }> }, +) { + const { docId, mindmapId } = await params; + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + // 校验页面归属 + const { data: doc, error: docError } = await supabase + .from("documents") + .select("id") + .eq("id", docId) + .eq("user_id", session.user.id) + .maybeSingle(); + if (docError) { + return NextResponse.json({ error: docError.message }, { status: 400 }); + } + if (!doc) { + return NextResponse.json({ error: "页面不存在" }, { status: 404 }); + } + + const folder = path.join(documentsBaseDir, docId); + const file = path.join(folder, resolveMindmapFileName(mindmapId)); + await removeFileSafe(file); + return NextResponse.json({ ok: true }); +} diff --git a/wolai-frontend/src/app/api/mindmap/[id]/route.ts b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts similarity index 93% rename from wolai-frontend/src/app/api/mindmap/[id]/route.ts rename to wolai-frontend/src/app/api/mindmap/[docId]/route.ts index 2f8063a1..a3c40a56 100644 --- a/wolai-frontend/src/app/api/mindmap/[id]/route.ts +++ b/wolai-frontend/src/app/api/mindmap/[docId]/route.ts @@ -36,9 +36,9 @@ async function ensureIndexFile(folder: string, title = "无标题") { export async function GET( _req: Request, - { params }: { params: Promise<{ id: string }> }, + { params }: { params: Promise<{ docId: string }> }, ) { - const { id } = await params; + const { docId: id } = await params; const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -85,9 +85,9 @@ export async function GET( export async function POST( request: Request, - { params }: { params: Promise<{ id: string }> }, + { params }: { params: Promise<{ docId: string }> }, ) { - const { id } = await params; + const { docId: id } = await params; const supabase = await createSupabaseRouteClient(); const { data: { session }, @@ -126,9 +126,9 @@ export async function POST( export async function DELETE( _req: Request, - { params }: { params: Promise<{ id: string }> }, + { params }: { params: Promise<{ docId: string }> }, ) { - const { id } = await params; + const { docId: id } = await params; const supabase = await createSupabaseRouteClient(); const { data: { session }, diff --git a/wolai-frontend/src/app/api/sidebar/route.ts b/wolai-frontend/src/app/api/sidebar/route.ts index 89ba87ac..a65e4edf 100644 --- a/wolai-frontend/src/app/api/sidebar/route.ts +++ b/wolai-frontend/src/app/api/sidebar/route.ts @@ -3,7 +3,8 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server"; import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces"; import { fetchSidebarDataset } from "@/lib/sidebar-tree"; import type { SidebarInitialData } from "@/components/sidebar/types"; -import { detectLocalMindmapDocs } from "@/lib/mindmap-files"; +import { detectLocalMindmapFiles, detectLocalMindmapDocs } from "@/lib/mindmap-files"; +import type { MediaAsset } from "@/types/media"; export const dynamic = "force-dynamic"; @@ -29,13 +30,36 @@ export async function GET(request: Request) { } try { - const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId); - const localMindmaps = await detectLocalMindmapDocs( - dataset.documents.map((d) => d.id), - ); - const mindmapDocs = Array.from( - new Set([...(dataset.mindmapDocs ?? []), ...localMindmaps]), - ); + const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId); + const docIds = dataset.documents.map((d) => d.id); + const localMindmapFiles = await detectLocalMindmapFiles(docIds); + const mindmapDocs = Array.from(new Set([...(dataset.mindmapDocs ?? []), ...(await detectLocalMindmapDocs(docIds))])); + const docById = new Map(dataset.documents.map((d) => [d.id, d])); + const mindmapAssets: MediaAsset[] = localMindmapFiles.map((item) => { + const doc = docById.get(item.documentId); + const workspaceId = doc?.workspace_id ?? targetWorkspaceId; + const fileUrlBase = item.source === "legacy" ? `/mindmaps/${item.documentId}` : `/documents/${item.documentId}`; + return { + id: item.mindmapId, + workspace_id: workspaceId, + document_id: item.documentId, + asset_type: "mindmap", + file_url: `${fileUrlBase}/${item.fileName}`, + thumbnail_url: null, + bucket: null, + storage_path: null, + file_name: item.fileName, + file_size: null, + mime_type: "application/json", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: "", + updated_at: "", + }; + }); const payload: SidebarInitialData = { activeWorkspaceId: targetWorkspaceId, @@ -43,6 +67,7 @@ export async function GET(request: Request) { documents: dataset.documents, trashedDocuments: dataset.trashedDocuments, mindmapDocs, + mindmapAssets, mediaAssets: dataset.mediaAssets ?? [], }; diff --git a/wolai-frontend/src/app/dev/mindmap/page.tsx b/wolai-frontend/src/app/dev/mindmap/page.tsx index ae988a1c..724b98eb 100644 --- a/wolai-frontend/src/app/dev/mindmap/page.tsx +++ b/wolai-frontend/src/app/dev/mindmap/page.tsx @@ -1,21 +1,19 @@ "use client"; -import { MindmapBlockView } from "@/components/editor/blocks/MindmapBlock"; -import type { BlockNoteEditor, Block } from "@blocknote/core"; +import { MindmapBlockView, defaultMindmapData } from "@/components/editor/blocks/MindmapBlock"; +import type { BlockNoteEditor } from "@blocknote/core"; import type { CustomBlockSchema } from "@/components/editor/schema"; -const stubBlock: Block = { +const stubBlock = { id: "dev-mindmap", type: "mindmap", props: { - data: { - data: { text: "中心主题" }, - children: [], - }, + docId: "dev", + data: defaultMindmapData, }, content: [], children: [], -}; +} as any; const editorStub = { updateBlock: () => { diff --git a/wolai-frontend/src/components/editor/blocknote-editor.tsx b/wolai-frontend/src/components/editor/blocknote-editor.tsx index 04b51122..6ac13fa9 100644 --- a/wolai-frontend/src/components/editor/blocknote-editor.tsx +++ b/wolai-frontend/src/components/editor/blocknote-editor.tsx @@ -145,6 +145,7 @@ const syncProgressMeters = (editorInstance: ReturnType { const block = findBlockById(blocks, progressId); if (!block) return; + if (block.type !== "progressMeter") return; const weightedDone = stat.done + stat.doing * 0.5; const percent = stat.total === 0 ? 0 : Math.min(100, Math.round((weightedDone / stat.total) * 100)); const summary = stat.total === 0 ? "暂无条目" : `${stat.done}/${stat.total} 完成`; @@ -237,17 +238,20 @@ export function BlockNoteEditor({ const debouncedSave = useDebouncedCallback(saveContent, 800); const previousAssetsRef = useRef>(new Set()); - const hadMindmapRef = useRef(false); - const clearMindmapAutosaveCache = useCallback((targetDocumentId: string) => { + const previousMindmapBlockIdsRef = useRef>(new Set()); + const clearMindmapAutosaveCache = useCallback((targetDocumentId: string, mindmapId?: string) => { if (typeof window === "undefined") return; try { const prefix = "wolai-mindmap-autosave-"; const targetPrefix = `${prefix}${targetDocumentId}`; + const directKey = mindmapId ? `${targetPrefix}:${mindmapId}` : targetPrefix; const keys: string[] = []; for (let i = 0; i < window.localStorage.length; i += 1) { const k = window.localStorage.key(i); if (!k) continue; - if (k === targetPrefix || k.startsWith(targetPrefix)) { + if (mindmapId) { + if (k === directKey) keys.push(k); + } else if (k === targetPrefix || k.startsWith(`${targetPrefix}:`)) { keys.push(k); } } @@ -256,19 +260,20 @@ export function BlockNoteEditor({ // ignore } }, []); - const markMindmapDeleting = useCallback((targetDocumentId: string) => { + const markMindmapDeleting = useCallback((targetDocumentId: string, mindmapId?: string) => { if (typeof window === "undefined") return; try { const w = window as unknown as { - __wolaiMindmapDeletingDocIds?: Set; + __wolaiMindmapDeletingKeys?: Set; }; - if (!w.__wolaiMindmapDeletingDocIds) { - w.__wolaiMindmapDeletingDocIds = new Set(); + if (!w.__wolaiMindmapDeletingKeys) { + w.__wolaiMindmapDeletingKeys = new Set(); } - w.__wolaiMindmapDeletingDocIds.add(targetDocumentId); + const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId; + w.__wolaiMindmapDeletingKeys.add(key); window.setTimeout(() => { try { - w.__wolaiMindmapDeletingDocIds?.delete(targetDocumentId); + w.__wolaiMindmapDeletingKeys?.delete(key); } catch { // ignore } @@ -280,7 +285,7 @@ export function BlockNoteEditor({ const collectAssets = useCallback((blocks: Block[]) => { const assetIds = new Set(); - let hasMindmap = false; + const mindmapBlockIds = new Set(); const walk = (target: Block[]) => { target.forEach((b) => { if (b.type === "media") { @@ -288,7 +293,7 @@ export function BlockNoteEditor({ if (id) assetIds.add(id); } if (b.type === "mindmap") { - hasMindmap = true; + mindmapBlockIds.add(b.id); } if (Array.isArray(b.children) && b.children.length > 0) { walk(b.children as Block[]); @@ -296,7 +301,7 @@ export function BlockNoteEditor({ }); }; walk(blocks); - return { assetIds, hasMindmap }; + return { assetIds, mindmapBlockIds }; }, []); const deleteAssets = useCallback( @@ -319,17 +324,28 @@ export function BlockNoteEditor({ [documentId], ); - const deleteMindmap = useCallback(async () => { - const resp = await fetch(`/api/mindmap/${documentId}`, { method: "DELETE" }); - if (!resp.ok) { - console.error("删除思维导图失败", await resp.text()); - return; - } - // 重要:删除思维导图文件后也要清理本地 autosave,否则用户再次插入导图会从旧缓存恢复,表现为“删除不干净/重复出现” - markMindmapDeleting(documentId); - clearMindmapAutosaveCache(documentId); - emitAssetsChanged(documentId); - }, [clearMindmapAutosaveCache, documentId, markMindmapDeleting]); + const deleteMindmapAssets = useCallback( + async (mindmapIds: string[]) => { + if (mindmapIds.length === 0) return; + await Promise.all( + mindmapIds.map(async (mindmapId) => { + try { + const resp = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, { method: "DELETE" }); + if (!resp.ok) { + console.error("删除思维导图失败", mindmapId, await resp.text().catch(() => "")); + } + } catch (error) { + console.error("删除思维导图失败", mindmapId, error); + } finally { + markMindmapDeleting(documentId, mindmapId); + clearMindmapAutosaveCache(documentId, mindmapId); + } + }), + ); + emitAssetsChanged(documentId, undefined, undefined, false, mindmapIds); + }, + [clearMindmapAutosaveCache, documentId, markMindmapDeleting], + ); // 监听侧边栏删除事件,主动移除编辑区遗留块 useEffect(() => { @@ -338,24 +354,42 @@ export function BlockNoteEditor({ docId?: string; assetIds?: string[]; mindmapDeleted?: boolean; + mindmapAssetIds?: string[]; }; if (!detail || detail.docId !== documentId) return; const assetIds = detail.assetIds ?? []; const mindmapDeleted = Boolean(detail.mindmapDeleted); - if (assetIds.length === 0 && !mindmapDeleted) return; - if (mindmapDeleted) { + const mindmapAssetIds = Array.isArray(detail.mindmapAssetIds) + ? (detail.mindmapAssetIds.filter((id) => typeof id === "string") as string[]) + : []; + if (assetIds.length === 0 && !mindmapDeleted && mindmapAssetIds.length === 0) return; + if (mindmapDeleted || mindmapAssetIds.length > 0) { // 标记“删除中”,避免 MindmapBlock 卸载清理里把 autosave 写回导致“复活” - markMindmapDeleting(documentId); + if (mindmapAssetIds.length > 0) { + mindmapAssetIds.forEach((id) => markMindmapDeleting(documentId, id)); + } else { + markMindmapDeleting(documentId); + } // 双保险:等卸载完成后再清一次 autosave,避免删除后再次插入出现旧内容(重复) - window.setTimeout(() => clearMindmapAutosaveCache(documentId), 0); + window.setTimeout(() => { + if (mindmapAssetIds.length > 0) { + mindmapAssetIds.forEach((id) => clearMindmapAutosaveCache(documentId, id)); + } else { + clearMindmapAutosaveCache(documentId); + } + }, 0); } const blocks = editor?.topLevelBlocks as Block[] | undefined; if (!blocks || blocks.length === 0 || !editor) return; const toRemove: string[] = []; const walk = (target: Block[]) => { target.forEach((b) => { - if (mindmapDeleted && b.type === "mindmap") { - toRemove.push(b.id); + if (b.type === "mindmap") { + if (mindmapDeleted) { + toRemove.push(b.id); + } else if (mindmapAssetIds.length > 0 && mindmapAssetIds.includes(b.id)) { + toRemove.push(b.id); + } } if (assetIds.length > 0 && b.type === "media") { const id = (b.props as { assetId?: string })?.assetId; @@ -370,7 +404,11 @@ export function BlockNoteEditor({ }; walk(blocks); if (toRemove.length > 0) { - editor.removeBlocks(toRemove); + try { + editor.removeBlocks(toRemove); + } catch { + // ignore:可能已被其它链路先行删除(例如块菜单/工具栏触发的 removeBlocks) + } } }; window.addEventListener(ASSETS_CHANGED_EVENT, handler); @@ -397,28 +435,30 @@ export function BlockNoteEditor({ onSnapshot?.({ blocks: blocks as Json, stats }); // 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏 - const { assetIds, hasMindmap } = collectAssets(typedBlocks); + const { assetIds, mindmapBlockIds } = collectAssets(typedBlocks); const prevAssets = previousAssetsRef.current; const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id)); if (removedAssets.length > 0) { void deleteAssets(removedAssets); } previousAssetsRef.current = assetIds; - if (hadMindmapRef.current && !hasMindmap) { - void deleteMindmap(); + const prevMindmaps = previousMindmapBlockIdsRef.current; + const removedMindmaps = [...prevMindmaps].filter((id) => !mindmapBlockIds.has(id)); + if (removedMindmaps.length > 0) { + void deleteMindmapAssets(removedMindmaps); } - hadMindmapRef.current = hasMindmap; + previousMindmapBlockIdsRef.current = mindmapBlockIds; }; runSync(); - const unsubscribe = editor.onEditorContentChange(runSync); + const unsubscribe = editor.onEditorContentChange(runSync) as unknown as + | undefined + | (() => void); return () => { disposed = true; - if (typeof unsubscribe === "function") { - unsubscribe(); - } + unsubscribe?.(); }; - }, [collectAssets, debouncedSave, deleteAssets, deleteMindmap, editor, onSnapshot, onStatsChange]); + }, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, editor, onSnapshot, onStatsChange]); const jumpToHeading = useCallback((headingId: string) => { const target = document.querySelector(`[data-id="${headingId}"]`); @@ -453,15 +493,15 @@ const trimTrailingCharacter = ( if (!editorInstance) { return; } - const content = Array.isArray(block.content) ? [...block.content] : []; + const content = (Array.isArray(block.content) ? [...block.content] : []) as any[]; for (let index = content.length - 1; index >= 0; index -= 1) { - const node = content[index] as { text?: string }; + const node = content[index] as any; if (typeof node?.text === "string" && node.text.endsWith(char)) { const nextText = node.text.slice(0, -1); if (nextText.length === 0) { content.splice(index, 1); } else { - content[index] = { ...node, text: nextText }; + content[index] = { ...(node as any), text: nextText } as any; } editorInstance.updateBlock(block, { content }); break; @@ -482,7 +522,7 @@ const computeDocumentStats = (blocks: Block[]): DocumentStats const accumulate = (targetBlocks: Block[]) => { targetBlocks.forEach((block) => { if (Array.isArray(block.content)) { - block.content.forEach((node: { text?: string }) => { + (block.content as any[]).forEach((node: any) => { if (typeof node.text === "string") { const text = node.text; characterCount += text.length; @@ -537,7 +577,7 @@ const computeDocumentStats = (blocks: Block[]): DocumentStats assetId: asset.id, assetType: asset.asset_type ?? "image", fileName: asset.file_name ?? "", - fileSize: asset.file_size ?? null, + fileSize: asset.file_size ?? undefined, mimeType: asset.mime_type ?? "", ocrStatus: asset.ocr_status ?? "idle", documentId, @@ -598,7 +638,7 @@ const computeDocumentStats = (blocks: Block[]): DocumentStats href: buildDocumentPath(target.id), content: text, }, - { type: "text", text: " " }, + " ", ]); return { blockId }; }, @@ -729,6 +769,7 @@ const computeDocumentStats = (blocks: Block[]): DocumentStats ; + block: Block & { props: any }; editor: BlockNoteEditor; }; @@ -75,7 +75,7 @@ const formatFileSize = (size?: number | null) => { return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`; }; -const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => { +const MediaBlockContent = ({ block, editor }: any) => { const { openPicker } = useImagePicker(); const [busy, setBusy] = useState(false); const fileUrl = block.props.fileUrl as string; diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx index 52bf75de..62f253df 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -334,16 +334,17 @@ const MindmapBlockView = ({ : ""), [block.props.docId], ); + const mindmapId = block.id; useEffect(() => { hasLocalEditsRef.current = false; applyingRemoteRef.current = false; }, [docId]); - const autosaveKey = useMemo( - () => `${STORAGE_PREFIX}${docId || block.id}`, - [block.id, docId], - ); + const autosaveKey = useMemo(() => { + if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`; + return `${STORAGE_PREFIX}${mindmapId}`; + }, [docId, mindmapId]); const initialDataRef = useRef(null); if (initialDataRef.current === null) { const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null; @@ -364,7 +365,8 @@ const MindmapBlockView = ({ if (!docId) return; (async () => { try { - const resp = await fetch(`/api/mindmap/${docId}`); + // 多导图:按 docId + mindmapId 拉取 + const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`); if (!resp.ok) return; const payload = await resp.json().catch(() => null); const data = payload?.data; @@ -390,7 +392,7 @@ const MindmapBlockView = ({ return () => { cancelled = true; }; - }, [docId, mindmap]); + }, [docId, mindmap, mindmapId]); // 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域 useEffect(() => { @@ -617,26 +619,27 @@ const MindmapBlockView = ({ editor.updateBlock(block, { props: { ...block.props, data: safe } }); if (docId) { // 同步到本地文件 + Supabase(弱依赖) - fetch(`/api/mindmap/${docId}`, { + fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: safe }), }) .then((resp) => { if (resp.ok) { + const fileName = `mindmap-${mindmapId}.json`; emitAssetsChanged(docId, { - id: `mindmap-${docId}`, + id: mindmapId, document_id: docId, asset_type: "mindmap", - file_name: "mindmap.json", - file_url: `/documents/${docId}`, + file_name: fileName, + file_url: `/documents/${docId}/${fileName}`, }); } }) .catch((err) => console.warn("思维导图同步失败", err)); } }, - [autosaveKey, block, docId, editor], + [autosaveKey, block, docId, editor, mindmapId], ); const debouncedPersist = useDebouncedCallback((data: unknown) => { @@ -652,7 +655,7 @@ const MindmapBlockView = ({ ); (async () => { try { - const resp = await fetch(`/api/mindmap/${docId}`, { + const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data }), @@ -668,16 +671,17 @@ const MindmapBlockView = ({ console.warn("初次创建思维导图文件失败", err); } finally { // 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新 + const fileName = `mindmap-${mindmapId}.json`; emitAssetsChanged(docId, { - id: `mindmap-${docId}`, + id: mindmapId, document_id: docId, asset_type: "mindmap", - file_name: "mindmap.json", - file_url: `/documents/${docId}`, + file_name: fileName, + file_url: `/documents/${docId}/${fileName}`, }); } })(); - }, [docId, mindmap, initialDataRef]); + }, [docId, mindmap, mindmapId, initialDataRef]); // 初始选中根节点,后续不强制抢焦点,允许用户自由选择 useEffect(() => { @@ -691,17 +695,18 @@ const MindmapBlockView = ({ } }, [mindmap, activeNodes.length]); - // mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap.json + // mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap-.json useEffect(() => { if (!docId || !mindmap) return; + const fileName = `mindmap-${mindmapId}.json`; emitAssetsChanged(docId, { - id: `mindmap-${docId}`, + id: mindmapId, document_id: docId, asset_type: "mindmap", - file_name: "mindmap.json", - file_url: `/documents/${docId}`, + file_name: fileName, + file_url: `/documents/${docId}/${fileName}`, }); - }, [docId, mindmap]); + }, [docId, mindmap, mindmapId]); useEffect(() => { let destroyed = false; @@ -982,6 +987,11 @@ const MindmapBlockView = ({ if (typeof window !== "undefined") { // 便于开发阶段在控制台直接调试实例 window.__mindmapInstance = instance; + const w = window as unknown as { + __mindmapInstancesById?: Record; + }; + if (!w.__mindmapInstancesById) w.__mindmapInstancesById = {}; + w.__mindmapInstancesById[mindmapId] = instance; } setMindmap(instance); @@ -1087,9 +1097,13 @@ const MindmapBlockView = ({ if (!docId || typeof window === "undefined") return false; try { const w = window as unknown as { - __wolaiMindmapDeletingDocIds?: Set; + __wolaiMindmapDeletingKeys?: Set; }; - return Boolean(w.__wolaiMindmapDeletingDocIds?.has(docId)); + const key = `${docId}:${mindmapId}`; + return Boolean( + w.__wolaiMindmapDeletingKeys?.has(docId) || + w.__wolaiMindmapDeletingKeys?.has(key), + ); } catch { return false; } @@ -1111,7 +1125,7 @@ const MindmapBlockView = ({ // ignore } if (docId) { - fetch(`/api/mindmap/${docId}`, { + fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: safe }), @@ -1129,6 +1143,14 @@ const MindmapBlockView = ({ if (window.__mindmapInstance === createdInstance) { window.__mindmapInstance = null; } + try { + const w = window as unknown as { __mindmapInstancesById?: Record }; + if (w.__mindmapInstancesById && w.__mindmapInstancesById[mindmapId] === createdInstance) { + delete w.__mindmapInstancesById[mindmapId]; + } + } catch { + // ignore + } } setMindmap(null); mindmapRef.current = null; @@ -1628,7 +1650,7 @@ const MindmapBlockView = ({ const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。"); if (!confirmed) return; deletingRef.current = true; - const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" }); + const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "删除思维导图失败"); @@ -1640,9 +1662,14 @@ const MindmapBlockView = ({ } catch { // ignore } - emitAssetsChanged(docId, undefined, undefined, true); - editor.removeBlocks([block.id]); - }, [autosaveKey, block.id, docId, editor]); + emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]); + // 同时会触发全局删除监听(ASSETS_CHANGED_EVENT)进行块移除,这里做 try/catch 避免重复删除报错 + try { + editor.removeBlocks([block.id]); + } catch { + // ignore + } + }, [autosaveKey, block.id, docId, editor, mindmapId]); const toolbarProps = { canBack, @@ -1928,6 +1955,7 @@ const MindmapBlockView = ({ ref={containerRef} className="h-full w-full" data-testid="mindmap-canvas" + data-mindmap-id={mindmapId} contentEditable={false} /> @@ -2011,6 +2039,7 @@ const MindmapBlockView = ({ ref={containerRef} className="h-full w-full" data-testid="mindmap-canvas" + data-mindmap-id={mindmapId} contentEditable={false} /> diff --git a/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx b/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx index 54348d4a..1d21009b 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx @@ -24,22 +24,15 @@ import { } from "./mindmapOptions"; import iconConfig from "./mindmapIconConfig"; import imageConfig from "./mindmapImageConfig"; -import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig"; +import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig"; +import type { MindMapNode } from "./mindmapTypes"; // 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入 -// @ts-expect-error 第三方库缺少类型定义 const loadIconModules = async () => { const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js"); const { mergerIconList } = await import("simple-mind-map/src/utils/index.js"); return { nodeIconList, mergerIconList }; }; -type MindMapNode = { - getStyle: (prop: string, checkRoot?: boolean) => any; - setStyle: (prop: string, value: any) => void; - setIcon: (icons: string[]) => void; - getData: (key: string) => any; -}; - type SidebarProps = { mindmap: any; activeNodes: MindMapNode[]; @@ -118,12 +111,12 @@ const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => { } }, [activeNodes]); - const updateStyle = (prop: string, value: any) => { - setStyle((prev) => ({ ...prev, [prop]: value })); - activeNodes.forEach((node) => { - node.setStyle(prop, value); - }); - }; + const updateStyle = (prop: string, value: any) => { + setStyle((prev) => ({ ...prev, [prop]: value })); + activeNodes.forEach((node) => { + node.setStyle?.(prop, value); + }); + }; if (activeNodes.length === 0) { return ( @@ -446,26 +439,27 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => { const addIcon = (type: string, name: string) => { const key = `${type}_${name}`; activeNodes.forEach((node) => { - const icons = node.getData("icon") || []; - const newIcons = icons.filter((i: string) => !i.startsWith(`${type}_`)); + const rawIcons = node.getData("icon"); + const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : []; + const newIcons = icons.filter((i) => !i.startsWith(`${type}_`)); newIcons.push(key); - node.setIcon(newIcons); + node.setIcon?.(newIcons); }); }; const removeIcon = (type: string) => { activeNodes.forEach((node) => { - const icons = node.getData("icon") || []; - const newIcons = icons.filter((i: string) => !i.startsWith(`${type}_`)); - node.setIcon(newIcons); + const rawIcons = node.getData("icon"); + const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : []; + const newIcons = icons.filter((i) => !i.startsWith(`${type}_`)); + node.setIcon?.(newIcons); }); }; const setSticker = (img: { url: string; width?: number; height?: number }) => { activeNodes.forEach((node) => { // simple-mind-map 支持 setImage 接收对象,包含 url/width/height - // @ts-expect-error 第三方库无类型 - node.setImage({ + node.setImage?.({ url: img.url, width: img.width || 100, height: img.height || 100, @@ -476,8 +470,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => { const clearSticker = () => { activeNodes.forEach((node) => { // 传入 null 以清除贴纸 - // @ts-expect-error 第三方库无类型 - node.setImage(null); + node.setImage?.(null); }); }; @@ -511,7 +504,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
- {group.list.map((item) => ( + {group.list.map((item: any) => (