0.1.07 文件树拖拽与多思维导图
This commit is contained in:
@@ -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 拖拽”)
|
||||
|
||||
---
|
||||
|
||||
@@ -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<string | null, number>([[targetParentId, siblingCount]]);
|
||||
|
||||
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
+6
-6
@@ -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 },
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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<CustomBlockSchema, "mindmap"> = {
|
||||
const stubBlock = {
|
||||
id: "dev-mindmap",
|
||||
type: "mindmap",
|
||||
props: {
|
||||
data: {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
},
|
||||
docId: "dev",
|
||||
data: defaultMindmapData,
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
};
|
||||
} as any;
|
||||
|
||||
const editorStub = {
|
||||
updateBlock: () => {
|
||||
|
||||
@@ -145,6 +145,7 @@ const syncProgressMeters = (editorInstance: ReturnType<typeof useCreateBlockNote
|
||||
progressStats.forEach((stat, progressId) => {
|
||||
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<Set<string>>(new Set());
|
||||
const hadMindmapRef = useRef(false);
|
||||
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string) => {
|
||||
const previousMindmapBlockIdsRef = useRef<Set<string>>(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<string>;
|
||||
__wolaiMindmapDeletingKeys?: Set<string>;
|
||||
};
|
||||
if (!w.__wolaiMindmapDeletingDocIds) {
|
||||
w.__wolaiMindmapDeletingDocIds = new Set<string>();
|
||||
if (!w.__wolaiMindmapDeletingKeys) {
|
||||
w.__wolaiMindmapDeletingKeys = new Set<string>();
|
||||
}
|
||||
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<CustomBlockSchema>[]) => {
|
||||
const assetIds = new Set<string>();
|
||||
let hasMindmap = false;
|
||||
const mindmapBlockIds = new Set<string>();
|
||||
const walk = (target: Block<CustomBlockSchema>[]) => {
|
||||
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<CustomBlockSchema>[]);
|
||||
@@ -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<CustomBlockSchema>[] | undefined;
|
||||
if (!blocks || blocks.length === 0 || !editor) return;
|
||||
const toRemove: string[] = [];
|
||||
const walk = (target: Block<CustomBlockSchema>[]) => {
|
||||
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<HTMLElement>(`[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<CustomBlockSchema>[]): DocumentStats
|
||||
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
|
||||
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<CustomBlockSchema>[]): 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<CustomBlockSchema>[]): DocumentStats
|
||||
href: buildDocumentPath(target.id),
|
||||
content: text,
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
" ",
|
||||
]);
|
||||
return { blockId };
|
||||
},
|
||||
@@ -729,6 +769,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme="light"
|
||||
slashMenu={false}
|
||||
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
||||
editable={!pageOptions.protectEditing}
|
||||
className={blocknoteClass}
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { CustomBlockSchema } from "../schema";
|
||||
type MediaAlign = "left" | "center" | "right";
|
||||
|
||||
type MediaBlockRenderProps = {
|
||||
block: Block<CustomBlockSchema>;
|
||||
block: Block<CustomBlockSchema> & { props: any };
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<unknown>(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-<id>.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<string, MindMapInstance>;
|
||||
};
|
||||
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<string>;
|
||||
__wolaiMindmapDeletingKeys?: Set<string>;
|
||||
};
|
||||
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<string, MindMapInstance> };
|
||||
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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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[] }) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.list.map((item) => (
|
||||
{group.list.map((item: any) => (
|
||||
<button
|
||||
key={`${group.type}-${item.name}`}
|
||||
onClick={() => addIcon(group.type, item.name)}
|
||||
@@ -521,7 +514,6 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
typeof item.icon === "string" && item.icon.trim().startsWith("<svg") ? (
|
||||
<span
|
||||
className="inline-flex h-6 w-6 items-center justify-center overflow-hidden"
|
||||
// @ts-expect-error: dangerouslySetInnerHTML 用于复用官方 SVG 片段
|
||||
dangerouslySetInnerHTML={{ __html: item.icon }}
|
||||
/>
|
||||
) : (
|
||||
@@ -599,16 +591,13 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
|
||||
if (!node || !r) return;
|
||||
// 仅通过已有方法触发激活,避免直接改 renderer 属性
|
||||
if (typeof r.clearActiveNodeList === "function") {
|
||||
// @ts-expect-error 第三方库缺少类型
|
||||
r.clearActiveNodeList();
|
||||
}
|
||||
if (typeof r.addNodeToActiveList === "function") {
|
||||
// @ts-expect-error 第三方库缺少类型
|
||||
r.addNodeToActiveList(node, true);
|
||||
} else {
|
||||
// 兜底:仍保留最小副作用写入
|
||||
try {
|
||||
// @ts-expect-error 第三方库 renderer 缺类型
|
||||
r?.setActiveNode?.(node);
|
||||
} catch {
|
||||
// 最后兜底:不再直接改引用,避免 lint 报错
|
||||
@@ -1215,7 +1204,6 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
try {
|
||||
const r = mindmap?.renderer;
|
||||
if (r && Array.isArray((r as any).renderCallbackList)) {
|
||||
// @ts-expect-error 第三方库内部字段
|
||||
r.renderCallbackList = (r.renderCallbackList as any[]).filter(
|
||||
(fn) => typeof fn === "function",
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ type ToolbarProps = {
|
||||
onExportMd: () => void;
|
||||
onExportTxt: () => void;
|
||||
onExportXmind: () => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
const ToolbarButton = ({
|
||||
|
||||
@@ -42,10 +42,7 @@ const handleMapping: Record<
|
||||
const OnlineTableBlockComponent = ({
|
||||
block,
|
||||
editor,
|
||||
}: {
|
||||
block: Block<CustomBlockSchema, "onlineTable">;
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
}) => {
|
||||
}: any) => {
|
||||
const { tableId } = block.props;
|
||||
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
|
||||
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
|
||||
|
||||
@@ -177,7 +177,9 @@ export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData>
|
||||
const entry = zip.file(zipPath) ?? zip.file(`/${zipPath}`);
|
||||
if (!entry) return null;
|
||||
const bytes = await entry.async("uint8array");
|
||||
const blob = new Blob([bytes], { type: mime });
|
||||
const rawBuffer = bytes.buffer as ArrayBuffer;
|
||||
const sliced = rawBuffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const blob = new Blob([sliced], { type: mime });
|
||||
const dataUrl = await readBlobAsDataUrl(blob);
|
||||
const size = await getImageSizeFromBlob(blob);
|
||||
const width = size?.width ?? 0;
|
||||
@@ -230,4 +232,3 @@ export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData>
|
||||
const tree = await walkTopic(rootTopicEl, true);
|
||||
return compactTree(tree, true);
|
||||
};
|
||||
|
||||
|
||||
@@ -109,14 +109,19 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
||||
return;
|
||||
}
|
||||
if (block.type === "mindmap") {
|
||||
const resp = await fetch(`/api/mindmap/${currentDocumentId}`, { method: "DELETE" });
|
||||
const resp = await fetch(`/api/mindmap/${currentDocumentId}/${block.id}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除思维导图失败");
|
||||
return;
|
||||
}
|
||||
emitAssetsChanged(currentDocumentId);
|
||||
editor.removeBlocks([block.id]);
|
||||
emitAssetsChanged(currentDocumentId, undefined, undefined, false, [block.id]);
|
||||
// 侧边栏/全局删除监听也会尝试移除对应块,这里做 try/catch 避免重复删除导致报错
|
||||
try {
|
||||
editor.removeBlocks([block.id]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
@@ -199,7 +204,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
||||
{ label: "数字列表", type: "numberedListItem", shortcut: "Ctrl+Shift+7" },
|
||||
{ label: "折叠列表", type: "toggleListItem", shortcut: "Ctrl+Shift+8" },
|
||||
{ label: "折叠标题", type: "heading", props: { level: 2, isToggleable: true } },
|
||||
{ label: "引述文字", type: "blockquote" },
|
||||
{ label: "引述文字", type: "quote" },
|
||||
{ label: "代码片段", type: "codeBlock" },
|
||||
],
|
||||
[turnToPage],
|
||||
@@ -212,10 +217,10 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
||||
return;
|
||||
}
|
||||
if (!option.type) return;
|
||||
editor.updateBlock(block, {
|
||||
type: option.type,
|
||||
props: option.props ?? {},
|
||||
});
|
||||
editor.updateBlock(block as any, {
|
||||
type: option.type as any,
|
||||
props: (option.props ?? {}) as any,
|
||||
} as any);
|
||||
},
|
||||
[block, editor],
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getDefaultReactSlashMenuItems,
|
||||
type DefaultReactSuggestionItem,
|
||||
} from "@blocknote/react";
|
||||
import { filterSuggestionItems, type Block, type BlockNoteEditor } from "@blocknote/core";
|
||||
import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FileImage,
|
||||
@@ -39,6 +39,68 @@ const matchKeywords = (query: string, aliases: string[]) => {
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
function insertOrUpdateBlockForSlashMenuCompat(
|
||||
editor: BlockNoteEditor<CustomBlockSchema>,
|
||||
partialBlock: unknown,
|
||||
) {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextType = (partialBlock as { type?: unknown })?.type;
|
||||
const shouldAppendParagraph = nextType === "mindmap";
|
||||
|
||||
const content = Array.isArray(referenceBlock.content) ? referenceBlock.content : [];
|
||||
const text = content
|
||||
.map((node) => (node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
const looksLikeSlashCommand = text === "" || text.startsWith("/");
|
||||
|
||||
if (referenceBlock.type === "paragraph" && looksLikeSlashCommand) {
|
||||
// 兼容默认 slash menu 行为:将当前段落“就地替换”为目标块类型,避免插入后又被 slash 菜单逻辑清理掉
|
||||
editor.updateBlock(referenceBlock, partialBlock as never);
|
||||
if (shouldAppendParagraph) {
|
||||
const inserted = editor.insertBlocks(
|
||||
[{ type: "paragraph" } as never],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
const paragraph = inserted[0];
|
||||
if (paragraph) {
|
||||
try {
|
||||
editor.setTextCursorPosition(paragraph, "start");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldAppendParagraph) {
|
||||
const inserted = editor.insertBlocks(
|
||||
[partialBlock as never, { type: "paragraph" } as never],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
const paragraph = inserted[1] ?? inserted[inserted.length - 1];
|
||||
if (paragraph) {
|
||||
try {
|
||||
editor.setTextCursorPosition(paragraph, "start");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
editor.insertBlocks([partialBlock as never], referenceBlock, "after");
|
||||
}
|
||||
|
||||
const GROUP_TRANSLATIONS: Record<string, string> = {
|
||||
"Headings": "标题",
|
||||
"Subheadings": "副标题",
|
||||
@@ -134,9 +196,9 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
|
||||
const getItems = useCallback(
|
||||
async (query: string) => {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[0];
|
||||
|
||||
// 注意:不要把 cursor/referenceBlock 在 getItems 阶段“捕获”后长期复用。
|
||||
// Slash 菜单打开后,BlockNote 会持续更新光标与块对象;若使用陈旧引用,
|
||||
// 可能出现插入块“瞬间出现又消失/不落库”的现象(尤其是插入自定义块时)。
|
||||
const createTableItem: DefaultReactSuggestionItem = {
|
||||
title: "在线表格",
|
||||
group: "高级",
|
||||
@@ -144,19 +206,13 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
icon: <Table className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: async () => {
|
||||
const documentId = currentDocumentId;
|
||||
|
||||
try {
|
||||
const newTable = await createOnlineTable(documentId);
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "onlineTable",
|
||||
props: { tableId: newTable.id, title: newTable.title },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "onlineTable",
|
||||
props: { tableId: newTable.id, title: newTable.title },
|
||||
content: [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create table:", error);
|
||||
// TODO: 插入错误提示块
|
||||
@@ -171,44 +227,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
aliases: ["mindmap", "swdt", "导图"],
|
||||
icon: <Spline className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: () => {
|
||||
// mindmap.json / /api/mindmap/:docId 是“按页面(documentId)维度”存储的,
|
||||
// 同一页面插入多个导图会共享同一份数据,用户会感知为“重复/镜像”。
|
||||
// 这里先限制一页只允许一个导图块,避免产生歧义。
|
||||
const blocks = editor.topLevelBlocks as Block<CustomBlockSchema>[];
|
||||
let existingMindmapId: string | null = null;
|
||||
const walk = (target: Block<CustomBlockSchema>[]) => {
|
||||
target.forEach((b) => {
|
||||
if (existingMindmapId) return;
|
||||
if (b.type === "mindmap") {
|
||||
existingMindmapId = b.id;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(b.children) && b.children.length > 0) {
|
||||
walk(b.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(blocks);
|
||||
if (existingMindmapId) {
|
||||
try {
|
||||
const el = document.querySelector<HTMLElement>(`[data-id="${existingMindmapId}"]`);
|
||||
el?.scrollIntoView?.({ behavior: "smooth", block: "center" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
window.alert("当前页面已存在思维导图,暂不支持插入多个。");
|
||||
return;
|
||||
}
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "mindmap",
|
||||
props: { docId: currentDocumentId },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "mindmap",
|
||||
props: { docId: currentDocumentId },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -218,28 +241,29 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
aliases: ["page", "ym", "子页面", "嵌入页面块"],
|
||||
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: async () => {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const cursorBlock = cursor?.block as any;
|
||||
const firstText =
|
||||
Array.isArray(cursorBlock?.content) && cursorBlock.content.length > 0
|
||||
? (cursorBlock.content[0] as any)?.text
|
||||
: undefined;
|
||||
const response = await fetch("/api/documents/create-child", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
parentId: currentDocumentId,
|
||||
title: cursor?.block?.content?.[0]?.text ?? "未命名页面",
|
||||
blocks: cursor ? [cursor.block] : [],
|
||||
title: typeof firstText === "string" && firstText.trim() ? firstText : "未命名页面",
|
||||
blocks: cursorBlock ? [cursorBlock] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
const { pageId, title } = await response.json();
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "pageReference",
|
||||
props: { pageId, title },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "pageReference",
|
||||
props: { pageId, title },
|
||||
content: [],
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
};
|
||||
@@ -251,16 +275,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
aliases: preset.aliases,
|
||||
icon: <PilcrowSquare className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: preset.level },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "heading",
|
||||
props: { level: preset.level },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -270,16 +289,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
aliases: ["toggle", "zd", "fold"],
|
||||
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: 2, isToggleable: true },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "heading",
|
||||
props: { level: 2, isToggleable: true },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -290,16 +304,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
|
||||
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "advancedTodo",
|
||||
props: { status: "todo" },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "advancedTodo",
|
||||
props: { status: "todo" },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -310,16 +319,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
aliases: ["jdt", "progress", "jindu"],
|
||||
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "progressMeter",
|
||||
props: { percent: 0, auto: true },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "progressMeter",
|
||||
props: { percent: 0, auto: true },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -329,16 +333,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
aliases: ["zdgjdb", "foldtodo"],
|
||||
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "advancedTodo",
|
||||
props: { status: "todo" },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "advancedTodo",
|
||||
props: { status: "todo" },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -354,25 +353,20 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
].filter((item) => matchKeywords(query, item.aliases ?? []));
|
||||
|
||||
const insertMediaSelection = (selection: MediaSelection) => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "media",
|
||||
props: {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? "image",
|
||||
fileName: selection.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? "",
|
||||
ocrStatus: "idle",
|
||||
},
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "media",
|
||||
props: {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? "image",
|
||||
fileName: selection.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? "",
|
||||
ocrStatus: "idle",
|
||||
},
|
||||
content: [],
|
||||
});
|
||||
};
|
||||
|
||||
const handleMediaPick = (mediaType: MediaKind) => {
|
||||
|
||||
@@ -155,7 +155,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
}, [tableData]);
|
||||
|
||||
const normalizedSheets = useMemo(() => {
|
||||
return luckysheetSheets.map((sheet) => ({
|
||||
return luckysheetSheets.map((sheet: any) => ({
|
||||
...sheet,
|
||||
celldata: Array.isArray(sheet.celldata) ? sheet.celldata : [],
|
||||
config: {
|
||||
@@ -380,7 +380,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
if (editor && document.activeElement === editor && isInlineEditorVisible()) {
|
||||
return;
|
||||
}
|
||||
luckysheetInstance.enterEditMode();
|
||||
luckysheetInstance.enterEditMode?.();
|
||||
focusLuckysheetEditor();
|
||||
}, 0);
|
||||
},
|
||||
@@ -395,7 +395,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const handlePointerUp = (event: PointerEvent | MouseEvent | TouchEvent) => {
|
||||
const handlePointerUp: EventListener = (event) => {
|
||||
const target = event.target instanceof Node ? event.target : null;
|
||||
if (target && !container.contains(target)) {
|
||||
return;
|
||||
@@ -412,7 +412,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
tryEnterSingleClickEdit(normalized);
|
||||
});
|
||||
};
|
||||
const events: Array<keyof DocumentEventMap> = ["pointerup", "mouseup", "touchend"];
|
||||
const events = ["pointerup", "mouseup", "touchend"] as const;
|
||||
events.forEach((eventName) => {
|
||||
container.addEventListener(eventName, handlePointerUp, true);
|
||||
});
|
||||
@@ -437,7 +437,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
isApplyingSnapshotRef.current = true;
|
||||
|
||||
if (containerRef.current.children.length > 0) {
|
||||
window.luckysheet.destroy(LUCKY_SHEET_CONTAINER_ID);
|
||||
window.luckysheet?.destroy?.(LUCKY_SHEET_CONTAINER_ID);
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
console.log("[FullScreenTableEditor] init luckysheet", { tableId, sheets: options.data });
|
||||
}
|
||||
try {
|
||||
window.luckysheet.create(options);
|
||||
window.luckysheet?.create?.(options as any);
|
||||
} catch (error) {
|
||||
console.error("Luckysheet 初始化失败", error);
|
||||
setTableError("Luckysheet 初始化失败,请重试");
|
||||
@@ -522,7 +522,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
|
||||
return () => {
|
||||
if (window.luckysheet) {
|
||||
window.luckysheet.destroy(LUCKY_SHEET_CONTAINER_ID);
|
||||
window.luckysheet?.destroy?.(LUCKY_SHEET_CONTAINER_ID);
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
|
||||
@@ -180,7 +180,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
if (editor && document.activeElement === editor && isInlineEditorVisible()) {
|
||||
return;
|
||||
}
|
||||
luckysheetInstance.enterEditMode();
|
||||
luckysheetInstance.enterEditMode?.();
|
||||
focusLuckysheetEditor();
|
||||
}, 0);
|
||||
return true;
|
||||
@@ -277,7 +277,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
? table.snapshot.luckysheet
|
||||
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
|
||||
|
||||
window.luckysheet.create({
|
||||
window.luckysheet?.create?.({
|
||||
container: containerId,
|
||||
title: table.title ?? tableId,
|
||||
lang: "zh",
|
||||
@@ -306,7 +306,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} as any);
|
||||
|
||||
return () => {
|
||||
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
|
||||
@@ -323,14 +323,14 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const handlePointerUp = (event: PointerEvent | MouseEvent | TouchEvent) => {
|
||||
const handlePointerUp: EventListener = (event) => {
|
||||
const target = event.target instanceof Node ? event.target : null;
|
||||
if (target && !container.contains(target)) {
|
||||
return;
|
||||
}
|
||||
tryEnterInlineEdit();
|
||||
};
|
||||
const events: Array<keyof DocumentEventMap> = ["pointerup", "mouseup", "touchend"];
|
||||
const events = ["pointerup", "mouseup", "touchend"] as const;
|
||||
events.forEach((eventName) => container.addEventListener(eventName, handlePointerUp, true));
|
||||
return () => {
|
||||
events.forEach((eventName) => container.removeEventListener(eventName, handlePointerUp, true));
|
||||
|
||||
@@ -46,14 +46,21 @@ export function FileTree({
|
||||
<div
|
||||
className="space-y-0.5"
|
||||
onDragOver={(event) => {
|
||||
if (onDropFiles) event.preventDefault();
|
||||
if (!onDropFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer.files;
|
||||
const activeDocRow = rows.find(
|
||||
(row) => row.kind === "doc" && row.docId === activeId,
|
||||
);
|
||||
const firstDocRow = rows.find((row) => row.kind === "doc");
|
||||
const targetDocId = firstDocRow?.docId ?? "";
|
||||
const targetDocId = activeDocRow?.docId ?? firstDocRow?.docId ?? "";
|
||||
onDropFiles(targetDocId, files);
|
||||
}
|
||||
}}
|
||||
@@ -119,7 +126,11 @@ export function FileTree({
|
||||
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
|
||||
return;
|
||||
}
|
||||
if (onDropFiles) event.preventDefault();
|
||||
if (!onDropFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
|
||||
@@ -110,7 +110,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const [trashSearch, setTrashSearch] = useState("");
|
||||
const [emptyingTrash, setEmptyingTrash] = useState(false);
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
||||
const [mindmapDocs, setMindmapDocs] = useState<string[]>(sidebarData.mindmapDocs ?? []);
|
||||
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -136,12 +136,12 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}, [sidebarData.mediaAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setMindmapDocs(sidebarData.mindmapDocs ?? []);
|
||||
}, [sidebarData.mindmapDocs]);
|
||||
setMindmapAssets(sidebarData.mindmapAssets ?? []);
|
||||
}, [sidebarData.mindmapAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
|
||||
}, [mediaAssets, sidebarData.documents]);
|
||||
}, [mediaAssets, mindmapAssets, sidebarData.documents]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
@@ -152,10 +152,17 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
|
||||
const asset = custom.detail?.asset as MediaAsset | undefined;
|
||||
if (asset?.id) {
|
||||
setMediaAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
if (asset.asset_type === "mindmap") {
|
||||
setMindmapAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else {
|
||||
setMediaAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
}
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
};
|
||||
@@ -234,53 +241,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
const assetsByDoc = useMemo(() => {
|
||||
const map: Record<string, MediaAsset[]> = {};
|
||||
const assets = mediaAssets;
|
||||
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? [])];
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (!map[asset.document_id]) {
|
||||
map[asset.document_id] = [];
|
||||
}
|
||||
map[asset.document_id].push(asset);
|
||||
const exists = map[asset.document_id].some((a) => a.id === asset.id && a.asset_type === asset.asset_type);
|
||||
if (!exists) {
|
||||
map[asset.document_id].push(asset);
|
||||
}
|
||||
});
|
||||
|
||||
if (mindmapDocs.length > 0) {
|
||||
mindmapDocs.forEach((docId) => {
|
||||
const doc = sidebarData.documents.find((d) => d.id === docId);
|
||||
const workspaceId = doc?.workspace_id ?? "";
|
||||
if (!map[docId]) {
|
||||
map[docId] = [];
|
||||
}
|
||||
// 避免重复插入
|
||||
const alreadyExists = map[docId].some(
|
||||
(asset) => asset.asset_type === "mindmap",
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
map[docId].push({
|
||||
id: `mindmap-${docId}`,
|
||||
workspace_id: workspaceId,
|
||||
document_id: docId,
|
||||
asset_type: "mindmap",
|
||||
file_url: `/documents/${docId}`,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: "mindmap.json",
|
||||
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: "",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [sidebarData.documents, mediaAssets, mindmapDocs]);
|
||||
}, [mediaAssets, mindmapAssets]);
|
||||
|
||||
const fileTreeRows = useMemo(
|
||||
() =>
|
||||
@@ -552,7 +525,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
const rows = payload.rowIds
|
||||
.map((rowId) => fileTreeRowById.get(rowId))
|
||||
.map((rowId) => fileTreeRowById.get(rowId as any))
|
||||
.filter(Boolean) as FileTreeRow[];
|
||||
|
||||
const docItemsMap = new Map<string, boolean>();
|
||||
@@ -643,14 +616,15 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
|
||||
const path =
|
||||
asset.asset_type === "mindmap"
|
||||
? `documents/${asset.document_id}/mindmap.json`
|
||||
? ((asset.file_url ? asset.file_url.replace(/^\//, "") : "") ||
|
||||
`documents/${asset.document_id}/${asset.file_name ?? "mindmap.json"}`)
|
||||
: asset.storage_path || asset.file_url || asset.file_name || "附件";
|
||||
await copyText(path, "存储路径已复制");
|
||||
}, []);
|
||||
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
const resp = await fetch(`/api/mindmap/${asset.document_id}`);
|
||||
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
|
||||
if (!resp.ok) {
|
||||
window.alert("下载失败");
|
||||
return;
|
||||
@@ -661,7 +635,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "mindmap.json";
|
||||
a.download = asset.file_name ?? "mindmap.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
@@ -736,16 +710,20 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
async (assetIds: string[], assetHint?: MediaAsset) => {
|
||||
const uniqueAssetIds = Array.from(new Set(assetIds));
|
||||
const assets = uniqueAssetIds
|
||||
.map((id) => mediaAssets.find((item) => item.id === id))
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
|
||||
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
|
||||
assets.unshift(assetHint);
|
||||
}
|
||||
|
||||
const mindmapDocIds = Array.from(
|
||||
new Set(assets.filter((item) => item.asset_type === "mindmap").map((item) => item.document_id)),
|
||||
);
|
||||
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
|
||||
const mindmapIdsByDocId = new Map<string, string[]>();
|
||||
mindmapAssetsToDelete.forEach((item) => {
|
||||
const prev = mindmapIdsByDocId.get(item.document_id) ?? [];
|
||||
prev.push(item.id);
|
||||
mindmapIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
const fileAssetIdsByDocId = new Map<string, string[]>();
|
||||
assets
|
||||
.filter((item) => item.asset_type !== "mindmap")
|
||||
@@ -756,8 +734,8 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
});
|
||||
const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();
|
||||
|
||||
for (const docId of mindmapDocIds) {
|
||||
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
|
||||
for (const asset of mindmapAssetsToDelete) {
|
||||
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除思维导图失败");
|
||||
@@ -778,17 +756,15 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (mindmapDocIds.length > 0) {
|
||||
setMindmapDocs((prev) => prev.filter((id) => !mindmapDocIds.includes(id)));
|
||||
}
|
||||
if (uniqueAssetIds.length > 0) {
|
||||
setMediaAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
setMindmapAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
}
|
||||
setAssetMenu(null);
|
||||
mindmapDocIds.forEach((docId) => emitAssetsChanged(docId, undefined, undefined, true));
|
||||
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
|
||||
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
|
||||
},
|
||||
[mediaAssets, sidebarQuery],
|
||||
[mediaAssets, mindmapAssets, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleDeleteFileTreeSelection = useCallback(async () => {
|
||||
@@ -960,8 +936,83 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
[moveLocalNode, refreshTree, setExpanded],
|
||||
);
|
||||
|
||||
const handleFileTreeDropFiles = useCallback(
|
||||
(docId: string, files: FileList) => {
|
||||
void (async () => {
|
||||
const droppedFiles = Array.from(files ?? []);
|
||||
if (droppedFiles.length === 0) return;
|
||||
|
||||
const inferredTargetDocId =
|
||||
docId ||
|
||||
inferPasteTargetDocId({
|
||||
focusedRowId: fileTreeSelection.focusedRowId,
|
||||
rowById: fileTreeRowById,
|
||||
activeDocId: activeId || null,
|
||||
}) ||
|
||||
"";
|
||||
|
||||
if (!inferredTargetDocId) {
|
||||
setTimeout(() => window.alert("请选择一个目标页面后再拖入文件"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDoc = sidebarData.documents.find((doc) => doc.id === inferredTargetDocId) ?? null;
|
||||
const workspaceId = targetDoc?.workspace_id ?? sidebarData.activeWorkspaceId ?? "";
|
||||
if (!workspaceId) {
|
||||
setTimeout(() => window.alert("无法识别当前工作区,上传失败"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const file of droppedFiles) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("workspaceId", workspaceId);
|
||||
form.append("documentId", inferredTargetDocId);
|
||||
const resp = await fetch("/api/media/upload", { method: "POST", body: form });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
errors.push(`${file.name}: ${payload?.error ?? "上传失败"}`);
|
||||
continue;
|
||||
}
|
||||
const payload = (await resp.json()) as { asset?: MediaAsset };
|
||||
if (payload.asset?.id) {
|
||||
emitAssetsChanged(inferredTargetDocId, payload.asset);
|
||||
} else {
|
||||
errors.push(`${file.name}: 返回数据缺少 asset`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`${file.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await sidebarQuery.refetch();
|
||||
|
||||
if (errors.length > 0) {
|
||||
setTimeout(() => {
|
||||
const shown = errors.slice(0, 6).join("\n");
|
||||
window.alert(
|
||||
errors.length === 1
|
||||
? `部分文件上传失败:\n${shown}`
|
||||
: `部分文件上传失败(${errors.length}个):\n${shown}${errors.length > 6 ? "\n..." : ""}`,
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[
|
||||
activeId,
|
||||
fileTreeRowById,
|
||||
fileTreeSelection.focusedRowId,
|
||||
sidebarData.activeWorkspaceId,
|
||||
sidebarData.documents,
|
||||
sidebarQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFileTreeInternalDrop = useCallback(
|
||||
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
|
||||
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
|
||||
void (async () => {
|
||||
const targetDocId = inferDropTargetDocId(args.targetRow);
|
||||
if (!targetDocId) {
|
||||
@@ -978,7 +1029,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
});
|
||||
|
||||
const rows = uniqueRowIds
|
||||
.map((rowId) => fileTreeRowById.get(rowId))
|
||||
.map((rowId) => fileTreeRowById.get(rowId as any))
|
||||
.filter(Boolean) as FileTreeRow[];
|
||||
|
||||
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
|
||||
@@ -1378,7 +1429,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
{viewMode === "section" ? (
|
||||
<>
|
||||
<SectionList
|
||||
id="starred"
|
||||
label="星标置顶"
|
||||
icon={SECTION_ICONS.starred}
|
||||
nodes={starredNodes}
|
||||
@@ -1386,7 +1436,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onToggle={() => toggleSection("starred")}
|
||||
/>
|
||||
<SectionList
|
||||
id="public"
|
||||
label="公共页面"
|
||||
icon={SECTION_ICONS.public}
|
||||
nodes={publicNodes}
|
||||
@@ -1394,7 +1443,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onToggle={() => toggleSection("public")}
|
||||
/>
|
||||
<SectionList
|
||||
id="shared"
|
||||
label="共享页面"
|
||||
icon={SECTION_ICONS.shared}
|
||||
nodes={sharedNodes}
|
||||
@@ -1402,7 +1450,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onToggle={() => toggleSection("shared")}
|
||||
/>
|
||||
<SectionList
|
||||
id="templates"
|
||||
label="模板中心"
|
||||
icon={SECTION_ICONS.templates}
|
||||
nodes={templateNodes}
|
||||
@@ -1441,18 +1488,23 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<div ref={fileTreeContainerRef} className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<div
|
||||
ref={fileTreeContainerRef}
|
||||
data-testid="file-tree-container"
|
||||
className="h-full rounded-md border border-[#eff2f6] bg-white"
|
||||
>
|
||||
<FileTree
|
||||
rows={fileTreeRows}
|
||||
activeId={activeId}
|
||||
selectedRowIds={fileTreeSelection.selectedRowIds}
|
||||
onRowClick={handleFileTreeRowClick}
|
||||
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||
onToggleExpand={toggleExpand}
|
||||
onCreateChild={handleCreate}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onDropFiles={handleFileTreeDropFiles}
|
||||
onInternalDrop={handleFileTreeInternalDrop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface SidebarInitialData {
|
||||
* 已存在思维导图文件(本地或 supabase)对应的页面 id 列表
|
||||
*/
|
||||
mindmapDocs?: string[];
|
||||
/**
|
||||
* 思维导图文件列表(用于文件树显示,多导图时一页可有多个)
|
||||
*/
|
||||
mindmapAssets?: MediaAsset[];
|
||||
/**
|
||||
* 可选的媒体资源列表(旧字段向后兼容)
|
||||
*/
|
||||
|
||||
@@ -9,11 +9,18 @@ type AssetsChangedPayload = {
|
||||
asset?: unknown;
|
||||
assetIds?: string[];
|
||||
mindmapDeleted?: boolean;
|
||||
mindmapAssetIds?: string[];
|
||||
};
|
||||
|
||||
export function emitAssetsChanged(docId?: string, asset?: unknown, assetIds?: string[], mindmapDeleted?: boolean) {
|
||||
export function emitAssetsChanged(
|
||||
docId?: string,
|
||||
asset?: unknown,
|
||||
assetIds?: string[],
|
||||
mindmapDeleted?: boolean,
|
||||
mindmapAssetIds?: string[],
|
||||
) {
|
||||
if (typeof window === "undefined") return;
|
||||
const detail: AssetsChangedPayload = { docId, asset, assetIds, mindmapDeleted };
|
||||
const detail: AssetsChangedPayload = { docId, asset, assetIds, mindmapDeleted, mindmapAssetIds };
|
||||
window.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail }));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,21 @@ const tryAccess = async (file: string) => {
|
||||
export async function detectLocalMindmapDocs(docIds: string[]): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
for (const id of docIds) {
|
||||
const preferred = path.join(preferredBaseDir, id, "mindmap.json");
|
||||
const folder = path.join(preferredBaseDir, id);
|
||||
const preferredLegacy = path.join(folder, "mindmap.json");
|
||||
const legacy = path.join(legacyBaseDir, id, "mindmap.json");
|
||||
if ((await tryAccess(preferred)) || (await tryAccess(legacy))) {
|
||||
let has = false;
|
||||
if ((await tryAccess(preferredLegacy)) || (await tryAccess(legacy))) {
|
||||
has = true;
|
||||
} else {
|
||||
try {
|
||||
const entries = await fs.readdir(folder);
|
||||
has = entries.some((name) => /^mindmap-.+\.json$/i.test(name));
|
||||
} catch {
|
||||
has = false;
|
||||
}
|
||||
}
|
||||
if (has) {
|
||||
results.push(id);
|
||||
}
|
||||
}
|
||||
@@ -26,3 +38,50 @@ export async function detectLocalMindmapDocs(docIds: string[]): Promise<string[]
|
||||
}
|
||||
|
||||
export { preferredBaseDir, legacyBaseDir };
|
||||
|
||||
export type LocalMindmapFile = {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
fileName: string;
|
||||
source: "preferred" | "legacy";
|
||||
};
|
||||
|
||||
export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMindmapFile[]> {
|
||||
const results: LocalMindmapFile[] = [];
|
||||
for (const id of docIds) {
|
||||
const folder = path.join(preferredBaseDir, id);
|
||||
try {
|
||||
const entries = await fs.readdir(folder);
|
||||
for (const name of entries) {
|
||||
if (name === "mindmap.json") {
|
||||
// mindmap.json 属于旧版单文件导图:mindmapId 必须包含 docId,避免不同页面间 ID 冲突
|
||||
results.push({
|
||||
documentId: id,
|
||||
mindmapId: `legacy-${id}`,
|
||||
fileName: name,
|
||||
source: "preferred",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const m = /^mindmap-(.+)\.json$/i.exec(name);
|
||||
if (m && m[1]) {
|
||||
results.push({ documentId: id, mindmapId: m[1], fileName: name, source: "preferred" });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 旧目录(仅支持 mindmap.json)
|
||||
const legacy = path.join(legacyBaseDir, id, "mindmap.json");
|
||||
if (await tryAccess(legacy)) {
|
||||
results.push({
|
||||
documentId: id,
|
||||
mindmapId: `legacy-${id}`,
|
||||
fileName: "mindmap.json",
|
||||
source: "legacy",
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,16 @@ const wrapCookies = (store: RequestCookies) => {
|
||||
getAll: (...args: Parameters<RequestCookies["getAll"]>) =>
|
||||
store.getAll(...args).map((cookie) => ({ ...cookie, value: decodeValue(cookie.value) })),
|
||||
set: (...args: Parameters<RequestCookies["set"]>) => {
|
||||
const [name, value, options] = args;
|
||||
if (typeof value === "string") {
|
||||
store.set(name, encodeValue(value), options);
|
||||
} else {
|
||||
store.set(name, value);
|
||||
const [name, value, options] = args as unknown as [
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
];
|
||||
if (typeof name === "string" && typeof value === "string") {
|
||||
(store as any).set(name, encodeValue(value), options as any);
|
||||
return;
|
||||
}
|
||||
(store as any).set(...(args as any));
|
||||
},
|
||||
delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
|
||||
has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
|
||||
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
|
||||
import type { Database } from "@/types/supabase";
|
||||
@@ -36,7 +35,8 @@ export async function fetchSidebarDataset(
|
||||
throw new Error(`获取工作空间文档列表失败:${error.message}`);
|
||||
}
|
||||
|
||||
const documents: DocumentRecord[] = (documentRows ?? []).map((row) => ({
|
||||
const documentRowsAny = (documentRows ?? []) as any[];
|
||||
const documents: DocumentRecord[] = documentRowsAny.map((row) => ({
|
||||
access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id,
|
||||
@@ -61,7 +61,8 @@ export async function fetchSidebarDataset(
|
||||
throw new Error(`获取垃圾桶内容失败:${trashError.message}`);
|
||||
}
|
||||
|
||||
const trashedDocuments: TrashRecord[] = (trashRows ?? []).map((row) => ({
|
||||
const trashRowsAny = (trashRows ?? []) as any[];
|
||||
const trashedDocuments: TrashRecord[] = trashRowsAny.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
parent_id: row.parent_id,
|
||||
@@ -69,8 +70,7 @@ export async function fetchSidebarDataset(
|
||||
access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
|
||||
}));
|
||||
|
||||
const mindmapDocs =
|
||||
documentRows?.filter((row) => row.mindmap_data != null).map((row) => row.id) ?? [];
|
||||
const mindmapDocs = documentRowsAny.filter((row) => row.mindmap_data != null).map((row) => row.id);
|
||||
|
||||
const { data: assetRows, error: assetError } = await client
|
||||
.from("media_assets")
|
||||
|
||||
@@ -4,13 +4,13 @@ import { getDecodedCookies } from "@/lib/server-cookies";
|
||||
export const createSupabaseServerClient = async () => {
|
||||
const cookieStore = await getDecodedCookies();
|
||||
return createServerComponentClient({
|
||||
cookies: () => cookieStore,
|
||||
});
|
||||
cookies: () => cookieStore as any,
|
||||
} as any);
|
||||
};
|
||||
|
||||
export const createSupabaseRouteClient = async () => {
|
||||
const cookieStore = await getDecodedCookies();
|
||||
return createRouteHandlerClient({
|
||||
cookies: () => cookieStore,
|
||||
});
|
||||
cookies: () => cookieStore as any,
|
||||
} as any);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "@/types/supabase";
|
||||
|
||||
type TypedClient = SupabaseClient<Database>;
|
||||
type TypedClient = SupabaseClient<any>;
|
||||
|
||||
interface WorkspaceMembershipRow {
|
||||
workspace_id: string;
|
||||
is_default: boolean;
|
||||
workspaces: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
icon_url: string | null;
|
||||
} | null;
|
||||
workspaces:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
icon_url: string | null;
|
||||
}
|
||||
| Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
icon_url: string | null;
|
||||
}>
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
@@ -80,7 +87,7 @@ export async function fetchWorkspaceSummaries(
|
||||
throw new Error(`拉取工作空间列表失败:${error.message}`);
|
||||
}
|
||||
|
||||
const rows: WorkspaceMembershipRow[] = memberRows ?? [];
|
||||
const rows: WorkspaceMembershipRow[] = (memberRows ?? []) as any;
|
||||
const workspaceIds = rows.map((row) => row.workspace_id);
|
||||
|
||||
const memberCountMap: Record<string, number> = {};
|
||||
@@ -102,15 +109,16 @@ export async function fetchWorkspaceSummaries(
|
||||
|
||||
const summaries: WorkspaceSummary[] = rows
|
||||
.map((row) => {
|
||||
if (!row.workspaces) {
|
||||
const workspace = Array.isArray(row.workspaces) ? row.workspaces[0] : row.workspaces;
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.workspaces.id,
|
||||
name: row.workspaces.name,
|
||||
type: row.workspaces.type,
|
||||
iconUrl: row.workspaces.icon_url,
|
||||
memberCount: memberCountMap[row.workspaces.id] ?? 1,
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
type: workspace.type,
|
||||
iconUrl: workspace.icon_url,
|
||||
memberCount: memberCountMap[workspace.id] ?? 1,
|
||||
isDefault: row.is_default,
|
||||
} satisfies WorkspaceSummary;
|
||||
})
|
||||
|
||||
@@ -405,6 +405,7 @@ export type Database = {
|
||||
},
|
||||
];
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
|
||||
Reference in New Issue
Block a user