0.1.11 ai修复与全屏
This commit is contained in:
@@ -209,7 +209,7 @@ export function BlockNoteEditor({
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
[documentId],
|
||||
[documentId, normalizedInitialContent],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -627,6 +627,9 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
openTableFullScreen: (tableId: string) => {
|
||||
setFullScreenTableId(tableId);
|
||||
},
|
||||
insertMediaAsset: (asset: MediaAsset) => {
|
||||
insertMediaAssetBlock(asset);
|
||||
},
|
||||
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Network, Paperclip, Settings2, Send, X } from "lucide-react";
|
||||
|
||||
type AgentAssetItem = {
|
||||
kind: "media" | "local-mindmap" | "test-pdf";
|
||||
id: string;
|
||||
title: string;
|
||||
fileUrl: string;
|
||||
mimeType?: string | null;
|
||||
assetType?: string | null;
|
||||
fileName?: string | null;
|
||||
};
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
type ToolName =
|
||||
| "mindmap_get"
|
||||
| "mindmap_get_subtree"
|
||||
| "search_web"
|
||||
| "mindmap_apply_ops"
|
||||
| "pdf_replace_mindmap";
|
||||
|
||||
const TOOL_LABEL: Record<ToolName, string> = {
|
||||
mindmap_get: "读导图(摘要)",
|
||||
mindmap_get_subtree: "读子树(按 uid)",
|
||||
search_web: "联网检索(SearxNG)",
|
||||
mindmap_apply_ops: "写入导图(ops)",
|
||||
pdf_replace_mindmap: "PDF→导图(替换当前)",
|
||||
};
|
||||
|
||||
const DEFAULT_TOOLS: ToolName[] = [
|
||||
"mindmap_get",
|
||||
"mindmap_get_subtree",
|
||||
"search_web",
|
||||
"mindmap_apply_ops",
|
||||
"pdf_replace_mindmap",
|
||||
];
|
||||
|
||||
const ONLINE_MODELS = [
|
||||
"",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
] as const;
|
||||
|
||||
export function MindmapAiAgentPanel({
|
||||
documentId,
|
||||
mindmapId,
|
||||
mindmap,
|
||||
activeNodes,
|
||||
}: {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
mindmap: any;
|
||||
activeNodes: any[];
|
||||
}) {
|
||||
const [messages, setMessages] = useState<AgentMessage[]>([
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"你好,我是思维导图 AI Agent。你可以:\n- 直接说需求(例如:补完选中节点、总结 @PDF 并写入导图、从 @PDF 生成章/节结构导图)\n- 使用 @ 选择文件或上传文件\n- 让 AI 自动选择工具,或手动勾选允许使用的工具",
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(true);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
|
||||
const [assets, setAssets] = useState<AgentAssetItem[]>([]);
|
||||
const [workspaceId, setWorkspaceId] = useState<string>("");
|
||||
const [attachments, setAttachments] = useState<AgentAssetItem[]>([]);
|
||||
const [debug, setDebug] = useState<string>("");
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
||||
const m = window.localStorage.getItem("mindmap_ai_model") || "";
|
||||
if (p === "local" || p === "online") setAiProvider(p);
|
||||
if (typeof m === "string") setAiModel(m);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem("mindmap_ai_provider", aiProvider);
|
||||
window.localStorage.setItem("mindmap_ai_model", aiModel);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [aiProvider, aiModel]);
|
||||
|
||||
// @ 选择
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null);
|
||||
|
||||
const persistMindmapData = (data: unknown): boolean => {
|
||||
try {
|
||||
const w = window as unknown as {
|
||||
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
||||
};
|
||||
const fn = w.__mindmapPersistById?.[mindmapId];
|
||||
if (typeof fn === "function") {
|
||||
fn(data);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const selectedUids = useMemo(() => {
|
||||
const list = Array.isArray(activeNodes) ? activeNodes : [];
|
||||
return list
|
||||
.slice(0, 3)
|
||||
.map((n) => String(n?.nodeData?.data?.uid ?? n?.nodeData?.uid ?? n?.getData?.("uid") ?? n?.uid ?? ""))
|
||||
.filter(Boolean);
|
||||
}, [activeNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!documentId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) return;
|
||||
if (cancelled) return;
|
||||
setWorkspaceId(String(json?.workspaceId ?? ""));
|
||||
setAssets(Array.isArray(json?.items) ? (json.items as AgentAssetItem[]) : []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [documentId]);
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
const q = mentionQuery.trim().toLowerCase();
|
||||
const base = assets.slice(0, 200);
|
||||
if (!q) return base.slice(0, 12);
|
||||
return base
|
||||
.filter((a) => {
|
||||
const hay = `${a.title} ${a.fileName ?? ""}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
}, [assets, mentionQuery]);
|
||||
|
||||
const updateMentionState = (value: string, cursor: number) => {
|
||||
const before = value.slice(0, Math.max(0, cursor));
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at === -1) {
|
||||
setMentionOpen(false);
|
||||
setMentionQuery("");
|
||||
setMentionRange(null);
|
||||
return;
|
||||
}
|
||||
// 若 @ 前是非空白字符,视为 email/路径等,避免误触发
|
||||
if (at > 0 && /\S/.test(before[at - 1] || "")) {
|
||||
setMentionOpen(false);
|
||||
setMentionQuery("");
|
||||
setMentionRange(null);
|
||||
return;
|
||||
}
|
||||
const token = before.slice(at + 1);
|
||||
// 遇到换行/空格则不触发
|
||||
if (/\s/.test(token)) {
|
||||
setMentionOpen(false);
|
||||
setMentionQuery("");
|
||||
setMentionRange(null);
|
||||
return;
|
||||
}
|
||||
setMentionOpen(true);
|
||||
setMentionQuery(token);
|
||||
setMentionRange({ start: at, end: cursor });
|
||||
};
|
||||
|
||||
const insertMention = (item: AgentAssetItem) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el || !mentionRange) return;
|
||||
const next = `${input.slice(0, mentionRange.start)}@${item.title}${input.slice(mentionRange.end)}`;
|
||||
setInput(next);
|
||||
setMentionOpen(false);
|
||||
setMentionQuery("");
|
||||
setMentionRange(null);
|
||||
// 去重加入附件
|
||||
setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item]));
|
||||
// 光标移动到插入后
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
const pos = mentionRange.start + 1 + item.title.length;
|
||||
el.focus();
|
||||
el.setSelectionRange(pos, pos);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const toggleTool = (tool: ToolName) => {
|
||||
setSelectedTools((prev) => {
|
||||
if (prev.includes(tool)) return prev.filter((t) => t !== tool);
|
||||
return [...prev, tool];
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
if (!workspaceId || !documentId) {
|
||||
window.alert("缺少 workspaceId/documentId,无法上传。请刷新后重试。");
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("workspaceId", workspaceId);
|
||||
form.append("documentId", documentId);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/media/upload", { method: "POST", body: form });
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) throw new Error(String(json?.error ?? `上传失败:${res.status}`));
|
||||
const asset = json?.asset;
|
||||
const item: AgentAssetItem = {
|
||||
kind: "media",
|
||||
id: String(asset?.id ?? `media:${Date.now()}`),
|
||||
title: String(asset?.file_name ?? file.name),
|
||||
fileUrl: String(asset?.file_url ?? ""),
|
||||
mimeType: String(asset?.mime_type ?? file.type ?? ""),
|
||||
assetType: String(asset?.asset_type ?? "file"),
|
||||
fileName: String(asset?.file_name ?? file.name),
|
||||
};
|
||||
setAttachments((prev) => (prev.some((x) => x.id === item.id) ? prev : [...prev, item]));
|
||||
// 刷新资产列表
|
||||
try {
|
||||
const listRes = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(documentId)}`);
|
||||
const listJson = (await listRes.json().catch(() => null)) as any;
|
||||
if (listRes.ok && Array.isArray(listJson?.items)) {
|
||||
setAssets(listJson.items as AgentAssetItem[]);
|
||||
setWorkspaceId(String(listJson?.workspaceId ?? workspaceId));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const content = input.trim();
|
||||
if (!content) return;
|
||||
setDebug("");
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/mindmap-ai/agent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
mindmapId,
|
||||
selectedUids,
|
||||
messages: nextMessages,
|
||||
attachments: attachments.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
fileUrl: a.fileUrl,
|
||||
mimeType: a.mimeType ?? null,
|
||||
})),
|
||||
toolChoice: toolAuto ? { mode: "auto" } : { mode: "manual", tools: selectedTools },
|
||||
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
|
||||
}),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) throw new Error(String(json?.error ?? `请求失败:${res.status}`));
|
||||
|
||||
const assistantText = String(json?.message ?? "");
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: assistantText || "(无输出)" }]);
|
||||
|
||||
if (json?.data) {
|
||||
mindmap?.setData?.(json.data);
|
||||
mindmap?.command?.clearHistory?.();
|
||||
persistMindmapData(json.data);
|
||||
}
|
||||
|
||||
if (json?.trace) {
|
||||
setDebug(JSON.stringify(json.trace, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-2 pb-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
{selectedUids.length ? `选中节点:${selectedUids[0]}` : "未选中节点(将以整图为上下文)"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${networkOn ? "border-blue-200 bg-blue-50 text-blue-700" : "border-gray-200 text-gray-500"}`}
|
||||
title="联网检索(SearxNG)"
|
||||
onClick={() => setNetworkOn((v) => !v)}
|
||||
>
|
||||
<Network className="h-3 w-3" />
|
||||
联网
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs ${toolPickerOpen ? "border-gray-300 bg-gray-50 text-gray-700" : "border-gray-200 text-gray-600"}`}
|
||||
title="选择允许使用的工具"
|
||||
onClick={() => setToolPickerOpen((v) => !v)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
工具
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolPickerOpen && (
|
||||
<div className="mb-3 rounded-md border border-gray-200 bg-white p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-gray-700">工具选择</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-1 text-xs ${toolAuto ? "bg-blue-500 text-white" : "border border-gray-200 text-gray-600"}`}
|
||||
onClick={() => setToolAuto((v) => !v)}
|
||||
title="自动:AI 自行选择;手动:仅允许勾选工具"
|
||||
>
|
||||
{toolAuto ? "自动" : "手动"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{DEFAULT_TOOLS.map((t) => (
|
||||
<label key={t} className={`flex cursor-pointer items-center gap-2 text-xs ${toolAuto ? "opacity-50" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={toolAuto}
|
||||
checked={selectedTools.includes(t)}
|
||||
onChange={() => toggleTool(t)}
|
||||
/>
|
||||
<span className="text-gray-700">{TOOL_LABEL[t]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-gray-400">
|
||||
提示:如果你希望 AI 一定要“写入导图”,请在需求中明确说“请写入并保存”。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white p-2">
|
||||
<div className="space-y-2">
|
||||
{messages.map((m, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`whitespace-pre-wrap rounded-md px-2 py-2 text-sm ${m.role === "user" ? "bg-gray-50 text-gray-900" : "bg-white text-gray-800"}`}
|
||||
>
|
||||
<div className="mb-1 text-[11px] text-gray-400">{m.role === "user" ? "你" : "AI"}</div>
|
||||
<div>{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{attachments.map((a) => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] text-gray-700"
|
||||
title={a.fileUrl}
|
||||
>
|
||||
@{a.title}
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-700"
|
||||
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 rounded-md border border-gray-200 bg-white px-2 py-2 text-xs text-gray-700">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="text-gray-500">AI:</div>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
data-testid="mindmap-ai-provider-online"
|
||||
checked={aiProvider === "online"}
|
||||
onChange={() => setAiProvider("online")}
|
||||
/>
|
||||
在线
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
data-testid="mindmap-ai-provider-local"
|
||||
checked={aiProvider === "local"}
|
||||
onChange={() => setAiProvider("local")}
|
||||
/>
|
||||
本地
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">模型:</div>
|
||||
{aiProvider === "online" ? (
|
||||
<select
|
||||
data-testid="mindmap-ai-model-select"
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m || "__default__"} value={m}>
|
||||
{m ? m : "默认(ai.md)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
data-testid="mindmap-ai-model-input"
|
||||
className="w-[220px] rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{aiProvider === "local" ? (
|
||||
<div className="mt-1 text-[11px] text-gray-400">
|
||||
本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` 或 `ai.local.md` / `ai-local.md`
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2">
|
||||
{mentionOpen && filteredAssets.length > 0 && (
|
||||
<div className="absolute bottom-[calc(100%+8px)] left-0 right-0 z-30 max-h-56 overflow-auto rounded-md border border-gray-200 bg-white shadow">
|
||||
{filteredAssets.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
|
||||
onClick={() => insertMention(a)}
|
||||
>
|
||||
<span className="truncate text-gray-800">{a.title}</span>
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{a.kind}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
data-testid="mindmap-ai-input"
|
||||
className="w-full resize-none rounded-md border border-gray-200 bg-white p-2 text-sm outline-none focus:border-blue-300"
|
||||
rows={4}
|
||||
value={input}
|
||||
placeholder="输入你的需求。使用 @ 选择文件(PDF/附件/本地导图),例如:总结 @卤化反应原理_1-9.pdf 并写入导图(章->节->要点)。"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setInput(v);
|
||||
updateMentionState(v, e.target.selectionStart ?? v.length);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// 焦点在 AI 输入框时,不应触发导图 Enter/Tab 快捷键
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
setMentionOpen(false);
|
||||
setToolPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
// Enter 发送;Shift+Enter 换行
|
||||
if (!e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
if (!loading) void send();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const el = e.currentTarget;
|
||||
updateMentionState(el.value, el.selectionStart ?? el.value.length);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传文件到当前页面附件"
|
||||
disabled={loading}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
上传
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void uploadFiles(e.target.files);
|
||||
}}
|
||||
accept="*/*"
|
||||
/>
|
||||
<div className="text-[11px] text-gray-400">Enter 发送 · Shift+Enter 换行</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={loading || !input.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "执行中..." : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{debug ? (
|
||||
<details className="mt-2 rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-600">
|
||||
<summary className="cursor-pointer select-none">调试信息(tool trace)</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap">{debug}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -296,6 +296,7 @@ const MindmapBlockView = ({
|
||||
block,
|
||||
editor,
|
||||
fullscreen = false,
|
||||
onExitFullscreen,
|
||||
}: {
|
||||
block: SpecificBlock<
|
||||
CustomBlockSchema,
|
||||
@@ -305,6 +306,7 @@ const MindmapBlockView = ({
|
||||
>;
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
fullscreen?: boolean;
|
||||
onExitFullscreen?: () => void;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -327,9 +329,8 @@ const MindmapBlockView = ({
|
||||
const hotkeyScopeRef = useRef(false);
|
||||
const lastInteractionAtRef = useRef(0);
|
||||
const skipNextPasteRef = useRef(false);
|
||||
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
||||
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
||||
const recentNodeDblclickRef = useRef(false);
|
||||
const [fullscreenApiActive, setFullscreenApiActive] = useState(false);
|
||||
const deletingRef = useRef(false);
|
||||
|
||||
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
||||
@@ -441,6 +442,7 @@ const MindmapBlockView = ({
|
||||
|
||||
// 这里用 setTimeout(0) 而不是 microtask:BlockNote/ProseMirror 可能会在同一轮事件里重新抢回焦点,
|
||||
// 导致“选中节点后 Ctrl+V 把思维导图替换成纯文本”。延后一拍把焦点拉回 wrapper,保证快捷键/粘贴作用域稳定。
|
||||
if (effectiveFullscreen) return;
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
@@ -455,13 +457,14 @@ const MindmapBlockView = ({
|
||||
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
||||
document.removeEventListener("mousedown", onPointerDownCapture, true);
|
||||
};
|
||||
}, []);
|
||||
}, [effectiveFullscreen]);
|
||||
|
||||
// 关键:阻止鼠标事件冒泡到 BlockNote/ProseMirror(它们会在 contenteditable 上处理 mousedown,从而产生 NodeSelection)。
|
||||
// 不能用 React 的 onMouseDown(事件委托在 document,太晚了),必须用原生监听挂在 wrapper 上,确保在 bubble 链路中先于 editor DOM。
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
if (effectiveFullscreen) return;
|
||||
|
||||
const stopBubble = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
@@ -525,7 +528,10 @@ const MindmapBlockView = ({
|
||||
|
||||
const exitLocalFullscreen = useCallback(() => {
|
||||
setActiveSidebar(null);
|
||||
if (fullscreen) return;
|
||||
if (fullscreen) {
|
||||
onExitFullscreen?.();
|
||||
return;
|
||||
}
|
||||
if (typeof document === "undefined") {
|
||||
setLocalFullscreen(false);
|
||||
return;
|
||||
@@ -540,29 +546,12 @@ const MindmapBlockView = ({
|
||||
return;
|
||||
}
|
||||
setLocalFullscreen(false);
|
||||
}, [fullscreen]);
|
||||
}, [fullscreen, onExitFullscreen]);
|
||||
|
||||
const enterLocalFullscreen = useCallback(() => {
|
||||
if (fullscreen) return;
|
||||
setLocalFullscreen(true);
|
||||
setActiveSidebar(null);
|
||||
if (typeof document === "undefined") return;
|
||||
if (!document.fullscreenEnabled) return;
|
||||
if (document.fullscreenElement) return;
|
||||
// 必须在“用户手势回调”中同步调用,避免 Fullscreen API 被浏览器拒绝
|
||||
try {
|
||||
const target = document.documentElement as unknown as {
|
||||
requestFullscreen?: () => Promise<void>;
|
||||
};
|
||||
const p = target.requestFullscreen?.();
|
||||
if (p && typeof p.catch === "function") {
|
||||
p.catch(() => {
|
||||
// ignore:失败则保持 Portal 伪全屏
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore:失败则保持 Portal 伪全屏
|
||||
}
|
||||
}, [fullscreen]);
|
||||
|
||||
// 沉浸式全屏:锁定页面滚动、支持 ESC 退出(仅对内嵌全屏生效)
|
||||
@@ -596,24 +585,6 @@ const MindmapBlockView = ({
|
||||
};
|
||||
}, [localFullscreen, fullscreen, exitLocalFullscreen]);
|
||||
|
||||
// “真全屏”:使用 Fullscreen API 隐藏浏览器/桌面窗口的外层 UI(Electron/Web 都可用)
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const onFsChange = () => {
|
||||
const active = Boolean(document.fullscreenElement);
|
||||
setFullscreenApiActive(active);
|
||||
// 注意:浏览器在打开原生对话框(例如 file picker / alert / confirm)时可能会自动退出
|
||||
// Fullscreen API。此时不应退出“沉浸式全屏”UI,否则会导致用户在全屏编辑中执行导入/新建
|
||||
// 等操作时被强制退出全屏。
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", onFsChange);
|
||||
};
|
||||
}, [localFullscreen]);
|
||||
|
||||
// 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键)
|
||||
useLayoutEffect(() => {
|
||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||
@@ -706,6 +677,21 @@ const MindmapBlockView = ({
|
||||
//(例如 Ctrl+C/Ctrl+V 作为文本复制粘贴)。
|
||||
if (isNodeTextEditing) return;
|
||||
|
||||
if ((key === "Delete" || key === "Backspace") && !e.shiftKey && !e.altKey && !isMod) {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
inst.execCommand?.("REMOVE_NODE");
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMod && lower === "c") {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
@@ -1043,10 +1029,10 @@ const MindmapBlockView = ({
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data }),
|
||||
body: JSON.stringify({ data, createOnly: true }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(
|
||||
@@ -1426,9 +1412,14 @@ const MindmapBlockView = ({
|
||||
window.__mindmapInstance = instance;
|
||||
const w = window as unknown as {
|
||||
__mindmapInstancesById?: Record<string, MindMapInstance>;
|
||||
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
||||
};
|
||||
if (!w.__mindmapInstancesById) w.__mindmapInstancesById = {};
|
||||
w.__mindmapInstancesById[mindmapId] = instance;
|
||||
if (!w.__mindmapPersistById) w.__mindmapPersistById = {};
|
||||
w.__mindmapPersistById[mindmapId] = (data: unknown) => {
|
||||
persistDataRef.current?.(data);
|
||||
};
|
||||
}
|
||||
|
||||
setMindmap(instance);
|
||||
@@ -1471,13 +1462,17 @@ const MindmapBlockView = ({
|
||||
// 这里用内部事件标记“当前在思维导图作用域内”,确保 Ctrl+C/Ctrl+V 不会被 BlockNote 抢走。
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
// 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus
|
||||
// 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。
|
||||
if (!effectiveFullscreen) {
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
instance.on?.("node_click", (node: unknown) => {
|
||||
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
||||
@@ -1499,13 +1494,17 @@ const MindmapBlockView = ({
|
||||
);
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
// 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus
|
||||
// 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。
|
||||
if (!effectiveFullscreen) {
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
instance.on?.("painter_start", () => setPainterMode(true));
|
||||
instance.on?.("painter_end", () => setPainterMode(false));
|
||||
@@ -1601,10 +1600,16 @@ const MindmapBlockView = ({
|
||||
window.__mindmapInstance = null;
|
||||
}
|
||||
try {
|
||||
const w = window as unknown as { __mindmapInstancesById?: Record<string, MindMapInstance> };
|
||||
const w = window as unknown as {
|
||||
__mindmapInstancesById?: Record<string, MindMapInstance>;
|
||||
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
||||
};
|
||||
if (w.__mindmapInstancesById && w.__mindmapInstancesById[mindmapId] === createdInstance) {
|
||||
delete w.__mindmapInstancesById[mindmapId];
|
||||
}
|
||||
if (w.__mindmapPersistById && w.__mindmapPersistById[mindmapId]) {
|
||||
delete w.__mindmapPersistById[mindmapId];
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -2392,7 +2397,7 @@ const MindmapBlockView = ({
|
||||
>
|
||||
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
思维导图编辑(全屏{fullscreenApiActive ? "·真" : ""})
|
||||
思维导图编辑(全屏)
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2421,6 +2426,8 @@ const MindmapBlockView = ({
|
||||
onSelect={setActiveSidebar}
|
||||
/>
|
||||
<MindmapSidebar
|
||||
documentId={docId}
|
||||
mindmapId={mindmapId}
|
||||
mindmap={mindmap}
|
||||
activeNodes={activeNodes}
|
||||
activeTab={activeSidebar}
|
||||
@@ -2510,6 +2517,8 @@ const MindmapBlockView = ({
|
||||
onSelect={setActiveSidebar}
|
||||
/>
|
||||
<MindmapSidebar
|
||||
documentId={docId}
|
||||
mindmapId={mindmapId}
|
||||
mindmap={mindmap}
|
||||
activeNodes={activeNodes}
|
||||
activeTab={activeSidebar}
|
||||
|
||||
@@ -24,8 +24,9 @@ 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";
|
||||
import { MindmapAiAgentPanel } from "./MindmapAiAgentPanel";
|
||||
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
|
||||
const loadIconModules = async () => {
|
||||
const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js");
|
||||
@@ -34,6 +35,8 @@ const loadIconModules = async () => {
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
mindmap: any;
|
||||
activeNodes: MindMapNode[];
|
||||
activeTab: SidebarPanel | null;
|
||||
@@ -1067,7 +1070,17 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
|
||||
|
||||
type AiMode = "chat" | "full" | "partial";
|
||||
|
||||
const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
|
||||
const AiPanel = ({
|
||||
mindmap,
|
||||
activeNodes,
|
||||
documentId,
|
||||
mindmapId,
|
||||
}: {
|
||||
mindmap: any;
|
||||
activeNodes: MindMapNode[];
|
||||
documentId: string;
|
||||
mindmapId: string;
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [mode, setMode] = useState<AiMode>("full");
|
||||
const [model, setModel] = useState("qwen3:30b-a3b-instruct-2507-q4_K_M");
|
||||
@@ -1077,6 +1090,38 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
const [loading, setLoading] = useState(false);
|
||||
const controllerRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
// 文档驱动:从 PDF 大纲生成导图(M1)
|
||||
const [docSource, setDocSource] = useState<"test" | "url">("test");
|
||||
const [testPdfName, setTestPdfName] = useState("卤化反应原理_1-9.pdf");
|
||||
const [docUrl, setDocUrl] = useState("");
|
||||
const [docTitle, setDocTitle] = useState("");
|
||||
const [docPreferProvider, setDocPreferProvider] = useState<"online" | "ollama" | "heuristic">("online");
|
||||
const [docMaxPages, setDocMaxPages] = useState(9);
|
||||
const [docLoading, setDocLoading] = useState(false);
|
||||
const [docDebug, setDocDebug] = useState("");
|
||||
|
||||
// AI Agent:补完选中节点(服务端:SearxNG + 在线 AI -> ops -> 落盘)
|
||||
const [expandInstruction, setExpandInstruction] = useState("");
|
||||
const [expandLoading, setExpandLoading] = useState(false);
|
||||
const [expandDebug, setExpandDebug] = useState("");
|
||||
const [expandUseSearx, setExpandUseSearx] = useState(true);
|
||||
|
||||
const persistMindmapData = (data: unknown): boolean => {
|
||||
try {
|
||||
const w = window as unknown as {
|
||||
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
||||
};
|
||||
const fn = w.__mindmapPersistById?.[mindmapId];
|
||||
if (typeof fn === "function") {
|
||||
fn(data);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const resetStream = () => {
|
||||
setStreamText("");
|
||||
};
|
||||
@@ -1087,6 +1132,133 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const runDocOutlineToMindmap = async () => {
|
||||
setDocDebug("");
|
||||
setDocLoading(true);
|
||||
try {
|
||||
const body =
|
||||
docSource === "test"
|
||||
? {
|
||||
source: { kind: "test", name: testPdfName },
|
||||
ollama: { baseUrl, model },
|
||||
options: { preferProvider: docPreferProvider, maxPages: docMaxPages },
|
||||
}
|
||||
: {
|
||||
source: { kind: "url", fileUrl: docUrl, title: docTitle || undefined },
|
||||
ollama: { baseUrl, model },
|
||||
options: { preferProvider: docPreferProvider, maxPages: docMaxPages },
|
||||
};
|
||||
|
||||
const res = await fetch("/api/mindmap-ai/outline-to-mindmap", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) {
|
||||
throw new Error(String(json?.error ?? `请求失败:${res.status}`));
|
||||
}
|
||||
if (!json?.mindmapData) {
|
||||
throw new Error("接口返回缺少 mindmapData");
|
||||
}
|
||||
mindmap?.setData?.(json.mindmapData);
|
||||
mindmap?.command?.clearHistory?.();
|
||||
// 直接用服务端返回的结构化数据保存(避免 setData 异步导致快照仍为旧数据,从而覆盖成空树)
|
||||
const ok = persistMindmapData(json.mindmapData);
|
||||
if (!ok) {
|
||||
// 兜底:延迟触发一次 data_change,让外层自行抓取快照保存
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
mindmap?.emit?.("data_change");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
setDocDebug(
|
||||
`已生成:${json?.meta?.title ?? "文档"};provider=${json?.meta?.providerUsed ?? "unknown"};候选行 ${json?.candidates?.length ?? 0};节点 ${json?.plan?.chapters ? "plan" : (json?.outline?.length ?? 0)}`,
|
||||
);
|
||||
} catch (e) {
|
||||
setDocDebug(`生成失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
window.alert(`从 PDF 生成导图失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setDocLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runExpandSelectedNode = async () => {
|
||||
setExpandDebug("");
|
||||
if (!documentId || !mindmapId) {
|
||||
window.alert("缺少 documentId/mindmapId,无法补完。");
|
||||
return;
|
||||
}
|
||||
const list = getActiveList();
|
||||
if (!list?.length) {
|
||||
window.alert("请先选中一个节点再补完。");
|
||||
return;
|
||||
}
|
||||
const node = list[0] as any;
|
||||
const uid =
|
||||
node?.nodeData?.data?.uid ||
|
||||
node?.nodeData?.uid ||
|
||||
node?.getData?.("uid") ||
|
||||
node?.uid ||
|
||||
"";
|
||||
if (!uid) {
|
||||
window.alert("选中节点缺少 uid,无法补完。");
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
node?.getData?.("text") ||
|
||||
node?.nodeData?.data?.text ||
|
||||
node?.data?.text ||
|
||||
"";
|
||||
setExpandLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/mindmap-ai/expand-node", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
mindmapId,
|
||||
targetUid: uid,
|
||||
instruction: expandInstruction || undefined,
|
||||
sources: { searxng: expandUseSearx },
|
||||
}),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) {
|
||||
throw new Error(String(json?.error ?? `请求失败:${res.status}`));
|
||||
}
|
||||
if (!json?.data) {
|
||||
throw new Error("接口返回缺少 data");
|
||||
}
|
||||
mindmap?.setData?.(json.data);
|
||||
mindmap?.command?.clearHistory?.();
|
||||
// 直接用服务端返回的结构化数据保存(避免 setData 异步导致快照仍为旧数据,从而覆盖成空树)
|
||||
const ok = persistMindmapData(json.data);
|
||||
if (!ok) {
|
||||
// 兜底:延迟触发一次 data_change,让外层自行抓取快照保存
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
mindmap?.emit?.("data_change");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
setExpandDebug(
|
||||
`已补完:${String(text || "目标节点").slice(0, 50)};新增 ${json?.applied ?? 0};searx=${json?.meta?.searched ? "on" : "off"}(${json?.meta?.searxCount ?? 0})`,
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setExpandDebug(`补完失败:${msg}`);
|
||||
window.alert(`补完节点失败:${msg}`);
|
||||
} finally {
|
||||
setExpandLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runChat = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
resetStream();
|
||||
@@ -1359,6 +1531,139 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">文档导图(按大纲生成)</Label>
|
||||
{docSource === "test" && (
|
||||
<a
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
href={`/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testPdfName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开 PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-2 py-2 text-sm hover:bg-gray-50 ${docSource === "test" ? "border-blue-500 text-blue-600" : "border-gray-300 text-gray-700"}`}
|
||||
onClick={() => setDocSource("test")}
|
||||
>
|
||||
测试 PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-2 py-2 text-sm hover:bg-gray-50 ${docSource === "url" ? "border-blue-500 text-blue-600" : "border-gray-300 text-gray-700"}`}
|
||||
onClick={() => setDocSource("url")}
|
||||
>
|
||||
URL / Signed URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{docSource === "test" ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">测试文件名(wolai-frontend/test)</Label>
|
||||
<Input
|
||||
value={testPdfName}
|
||||
onChange={(e) => setTestPdfName(e.target.value)}
|
||||
placeholder="例如:卤化反应原理_1-9.pdf"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">仅本地开发可用:用于快速验证“生成导图 + 节点跳页链接”。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">PDF 地址</Label>
|
||||
<Input
|
||||
value={docUrl}
|
||||
onChange={(e) => setDocUrl(e.target.value)}
|
||||
placeholder="http://127.0.0.1:xxx/file.pdf 或 supabase signed url"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">标题(可选)</Label>
|
||||
<Input value={docTitle} onChange={(e) => setDocTitle(e.target.value)} placeholder="不填则使用“文档”" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">出于安全考虑,当前仅允许本机或 supabase 域名。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">优先模型</Label>
|
||||
<NativeSelect
|
||||
value={docPreferProvider}
|
||||
onChange={(v) => setDocPreferProvider(v as "online" | "ollama" | "heuristic")}
|
||||
options={[
|
||||
{ label: "在线 AI(默认)", value: "online" },
|
||||
{ label: "本地 Ollama", value: "ollama" },
|
||||
{ label: "规则兜底", value: "heuristic" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">最大页数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
step={1}
|
||||
value={docMaxPages}
|
||||
onChange={(e) => setDocMaxPages(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={docLoading || (docSource === "url" && !docUrl.trim())}
|
||||
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={runDocOutlineToMindmap}
|
||||
>
|
||||
{docLoading ? "生成中..." : "从 PDF 生成导图(替换当前)"}
|
||||
</button>
|
||||
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">AI 补完(选中节点)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
pressed={expandUseSearx}
|
||||
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Network className="h-4 w-4 mr-1" />
|
||||
联网
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||||
rows={3}
|
||||
value={expandInstruction}
|
||||
onChange={(e) => setExpandInstruction(e.target.value)}
|
||||
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={expandLoading}
|
||||
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={runExpandSelectedNode}
|
||||
>
|
||||
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
|
||||
</button>
|
||||
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
|
||||
<p className="text-xs text-gray-400">
|
||||
说明:服务端会用 SearxNG 检索证据 + 在线 AI 生成 ops,并自动落盘到当前 mindmap 文件中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Label className="text-xs text-gray-500">模式</Label>
|
||||
<NativeSelect
|
||||
value={mode}
|
||||
@@ -1462,7 +1767,14 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
|
||||
);
|
||||
};
|
||||
|
||||
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
|
||||
export const MindmapSidebar = ({
|
||||
documentId,
|
||||
mindmapId,
|
||||
mindmap,
|
||||
activeNodes,
|
||||
activeTab,
|
||||
onClose,
|
||||
}: SidebarProps) => {
|
||||
const content = useMemo(() => {
|
||||
switch (activeTab as SidebarPanel | null) {
|
||||
case "style":
|
||||
@@ -1484,11 +1796,18 @@ export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: Sid
|
||||
case "note":
|
||||
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||||
case "ai":
|
||||
return <AiPanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||||
return (
|
||||
<MindmapAiAgentPanel
|
||||
mindmap={mindmap}
|
||||
activeNodes={activeNodes}
|
||||
documentId={documentId}
|
||||
mindmapId={mindmapId}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [activeTab, mindmap, activeNodes]);
|
||||
}, [activeTab, mindmap, activeNodes, documentId, mindmapId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!activeTab) return "";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
|
||||
@@ -12,6 +12,7 @@ import { DocumentHistoryDrawer } from "@/components/editor/document-history-draw
|
||||
import type { DocumentSnapshot } from "@/types/document";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -31,6 +32,7 @@ export interface DocumentContentProps {
|
||||
initialContent: unknown;
|
||||
initialOptions: PageOptionsState;
|
||||
initialStats: DocumentStats | null;
|
||||
openTableId?: string | null;
|
||||
}
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
@@ -52,14 +54,45 @@ export function DocumentContent({
|
||||
initialContent,
|
||||
initialOptions,
|
||||
initialStats,
|
||||
openTableId,
|
||||
}: DocumentContentProps) {
|
||||
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
||||
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
const router = useRouter();
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||
const [content, setContent] = useState<unknown>(initialContent);
|
||||
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0);
|
||||
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
|
||||
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingOpenTableRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const tableId = (openTableId ?? "").trim();
|
||||
if (!tableId) return;
|
||||
if (!editorBridge?.openTableFullScreen) return;
|
||||
if (pendingOpenTableRef.current === tableId) return;
|
||||
pendingOpenTableRef.current = tableId;
|
||||
|
||||
editorBridge.openTableFullScreen(tableId);
|
||||
|
||||
// 清理 URL 参数,避免刷新/回退时重复触发
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("openTableId");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} catch {
|
||||
// fallback:不影响主流程
|
||||
router.replace(`/documents/${documentId}`);
|
||||
}
|
||||
}
|
||||
}, [documentId, editorBridge, openTableId, router]);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
@@ -75,6 +108,72 @@ export function DocumentContent({
|
||||
}, [initialStats]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
const load = async () => {
|
||||
setContentError(null);
|
||||
setContentLoading(initialContent == null);
|
||||
setContent(initialContent);
|
||||
setShowContentLoadingIndicator(false);
|
||||
|
||||
if (initialContent != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentLoadingTimerRef.current) {
|
||||
clearTimeout(contentLoadingTimerRef.current);
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
// 避免“秒闪”的加载提示:只有当加载超过短阈值时才显示提示
|
||||
contentLoadingTimerRef.current = setTimeout(() => {
|
||||
if (!canceled) {
|
||||
setShowContentLoadingIndicator(true);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/documents/content?documentId=${encodeURIComponent(documentId)}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload?.error ?? "加载页面内容失败");
|
||||
}
|
||||
const payload = (await response.json()) as { content?: unknown };
|
||||
if (canceled) return;
|
||||
setContent(payload.content ?? null);
|
||||
} catch (error) {
|
||||
if (canceled) return;
|
||||
if ((error as { name?: string })?.name === "AbortError") return;
|
||||
setContentError(error instanceof Error ? error.message : "加载页面内容失败");
|
||||
} finally {
|
||||
if (!canceled) {
|
||||
setContentLoading(false);
|
||||
setShowContentLoadingIndicator(false);
|
||||
}
|
||||
if (contentLoadingTimerRef.current) {
|
||||
clearTimeout(contentLoadingTimerRef.current);
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
controller.abort();
|
||||
if (contentLoadingTimerRef.current) {
|
||||
clearTimeout(contentLoadingTimerRef.current);
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [documentId, initialContent, contentReloadKey]);
|
||||
|
||||
const persistTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
const payload = nextTitle.trim() || "无标题";
|
||||
@@ -226,14 +325,39 @@ export function DocumentContent({
|
||||
<p className="text-sm text-gray-400">最近更新:{formattedUpdatedAt}</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={initialContent}
|
||||
pageOptions={options}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
/>
|
||||
{contentLoading ? (
|
||||
showContentLoadingIndicator ? (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-gray-400">
|
||||
页面内容加载中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64" />
|
||||
)
|
||||
) : contentError ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-sm text-red-600">
|
||||
<div>{contentError}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-200 bg-red-50 px-3 py-1 text-sm text-red-700 hover:bg-red-100"
|
||||
onClick={() => {
|
||||
setContentError(null);
|
||||
setContentLoading(true);
|
||||
setContentReloadKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={content}
|
||||
pageOptions={options}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
/>
|
||||
)}
|
||||
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -213,6 +213,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
props: { tableId: newTable.id, title: newTable.title },
|
||||
content: [],
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: newTable.id } }));
|
||||
} catch (error) {
|
||||
console.error("Failed to create table:", error);
|
||||
// TODO: 插入错误提示块
|
||||
|
||||
@@ -44,6 +44,12 @@ export function PageBacklinksPanel({ workspaceId, documentId, className }: PageB
|
||||
});
|
||||
|
||||
const records = useMemo(() => data ?? [], [data]);
|
||||
// 避免页面切换时“先出现加载态、随后又消失”的闪烁:
|
||||
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。
|
||||
if (!error && records.length === 0 && (isLoading || isFetching)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoading && !error && records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { getFileTreeRowLabel } from "@/lib/file-tree/types";
|
||||
@@ -38,6 +39,29 @@ export function FileTree({
|
||||
onDropFiles,
|
||||
onInternalDrop,
|
||||
}: FileTreeProps) {
|
||||
const [dragOverRowId, setDragOverRowId] = useState<string | null>(null);
|
||||
|
||||
const dragOverRange = useMemo(() => {
|
||||
if (!dragOverRowId) return null;
|
||||
const startIndex = rows.findIndex((row) => row.rowId === dragOverRowId);
|
||||
if (startIndex < 0) return null;
|
||||
|
||||
const target = rows[startIndex];
|
||||
const targetDepth = target.depth;
|
||||
|
||||
// VS Code 的树在拖拽悬停到“展开的文件夹”时,会把该节点的可渲染范围都
|
||||
// 标记为 drop feedback(包含它的所有可见子节点)。我们用“扁平化 rows +
|
||||
// depth”来近似计算该范围。
|
||||
let endIndex = startIndex + 1;
|
||||
if (target.kind === "doc" && target.isExpanded && target.hasChildren) {
|
||||
while (endIndex < rows.length && rows[endIndex].depth > targetDepth) {
|
||||
endIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { startIndex, endIndex };
|
||||
}, [dragOverRowId, rows]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||
}
|
||||
@@ -47,6 +71,12 @@ export function FileTree({
|
||||
className="w-full min-w-0 max-w-full space-y-0.5 overflow-x-hidden"
|
||||
onDragOver={(event) => {
|
||||
if (!onDropFiles) return;
|
||||
if (event.target === event.currentTarget) {
|
||||
setDragOverRowId(null);
|
||||
}
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length);
|
||||
if (!hasFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
@@ -55,6 +85,7 @@ export function FileTree({
|
||||
onDrop={(event) => {
|
||||
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||
event.preventDefault();
|
||||
setDragOverRowId(null);
|
||||
const files = event.dataTransfer.files;
|
||||
const activeDocRow = rows.find(
|
||||
(row) => row.kind === "doc" && row.docId === activeId,
|
||||
@@ -64,18 +95,27 @@ export function FileTree({
|
||||
onDropFiles(targetDocId, files);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
const relatedTarget = (event as unknown as { relatedTarget?: EventTarget | null }).relatedTarget;
|
||||
if (relatedTarget && event.currentTarget.contains(relatedTarget as Node)) return;
|
||||
setDragOverRowId(null);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onBlankMouseDown?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rows.map((row) => {
|
||||
{rows.map((row, index) => {
|
||||
const label = getFileTreeRowLabel(row);
|
||||
const selected = selectedRowIds.has(row.rowId);
|
||||
const active = row.kind !== "asset" && row.docId === activeId;
|
||||
const active = row.kind !== "asset" && row.docId === activeId;
|
||||
const draggable =
|
||||
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
|
||||
const inDropFeedback =
|
||||
dragOverRange &&
|
||||
index >= dragOverRange.startIndex &&
|
||||
index < dragOverRange.endIndex;
|
||||
const baseClass =
|
||||
"flex w-full min-w-0 max-w-full select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
||||
const activeClass =
|
||||
@@ -90,7 +130,12 @@ export function FileTree({
|
||||
return (
|
||||
<div
|
||||
key={row.rowId}
|
||||
className={cn(baseClass, active && activeClass, selected && "bg-[#e8f2ff] text-[#2563eb]")}
|
||||
className={cn(
|
||||
baseClass,
|
||||
active && activeClass,
|
||||
selected && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
inDropFeedback && "bg-gray-200/70",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={(event) => onRowClick(row, event)}
|
||||
onDoubleClick={(event) => onRowDoubleClick(row, event)}
|
||||
@@ -118,21 +163,29 @@ export function FileTree({
|
||||
event.dataTransfer.setData("text/plain", payload);
|
||||
event.dataTransfer.effectAllowed = event.altKey ? "copyMove" : "move";
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragOverRowId(null);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const hasInternal = types.includes("application/x-mnote-file-tree");
|
||||
if (hasInternal && onInternalDrop) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
|
||||
setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId));
|
||||
return;
|
||||
}
|
||||
if (!onDropFiles) return;
|
||||
const hasFiles = types.includes("Files") || Boolean(event.dataTransfer.files?.length);
|
||||
if (!hasFiles) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files?.length) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
setDragOverRowId((prev) => (prev === row.rowId ? prev : row.rowId));
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
setDragOverRowId(null);
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const isInternal = types.includes("application/x-mnote-file-tree");
|
||||
if (isInternal && onInternalDrop) {
|
||||
|
||||
@@ -48,10 +48,9 @@ import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
||||
import { reduceFileTreeSelection } from "@/lib/file-tree/selection";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { parseFileTreeRowId } from "@/lib/file-tree/types";
|
||||
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||
import {
|
||||
inferPasteTargetDocId,
|
||||
isTextInputTarget,
|
||||
@@ -110,9 +109,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [trashSearch, setTrashSearch] = useState("");
|
||||
const [trashTab, setTrashTab] = useState<"documents" | "assets">("documents");
|
||||
const [emptyingTrash, setEmptyingTrash] = useState(false);
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
|
||||
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
|
||||
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -141,9 +142,13 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
setMindmapAssets(sidebarData.mindmapAssets ?? []);
|
||||
}, [sidebarData.mindmapAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableAssets(sidebarData.tableAssets ?? []);
|
||||
}, [sidebarData.tableAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
|
||||
}, [mediaAssets, mindmapAssets, sidebarData.documents]);
|
||||
}, [mediaAssets, mindmapAssets, tableAssets, sidebarData.documents]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
@@ -159,6 +164,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else if (asset.asset_type === "luckysheet") {
|
||||
setTableAssets((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;
|
||||
@@ -176,6 +186,17 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
};
|
||||
}, [sidebarQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const onSaved = () => void sidebarQuery.refetch();
|
||||
const onDeleted = () => void sidebarQuery.refetch();
|
||||
window.addEventListener("online-table-saved", onSaved as EventListener);
|
||||
window.addEventListener("online-table-deleted", onDeleted as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("online-table-saved", onSaved as EventListener);
|
||||
window.removeEventListener("online-table-deleted", onDeleted as EventListener);
|
||||
};
|
||||
}, [sidebarQuery]);
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
await sidebarQuery.refetch();
|
||||
}, [sidebarQuery]);
|
||||
@@ -241,9 +262,21 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
);
|
||||
}, [sidebarData.trashedDocuments, trashSearch]);
|
||||
|
||||
const filteredTrashedMediaAssets = useMemo(() => {
|
||||
const assets = [
|
||||
...(sidebarData.trashedMediaAssets ?? []),
|
||||
...(sidebarData.trashedMindmapAssets ?? []),
|
||||
];
|
||||
const keyword = trashSearch.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return assets;
|
||||
}
|
||||
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
|
||||
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]);
|
||||
|
||||
const assetsByDoc = useMemo(() => {
|
||||
const map: Record<string, MediaAsset[]> = {};
|
||||
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? [])];
|
||||
const assets = [...(mediaAssets ?? []), ...(mindmapAssets ?? []), ...(tableAssets ?? [])];
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (!map[asset.document_id]) {
|
||||
@@ -255,7 +288,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [mediaAssets, mindmapAssets]);
|
||||
}, [mediaAssets, mindmapAssets, tableAssets]);
|
||||
|
||||
const fileTreeRows = useMemo(
|
||||
() =>
|
||||
@@ -290,17 +323,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
return map;
|
||||
}, [sidebarData.documents]);
|
||||
|
||||
const selectedAssetIdsForMenu = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
fileTreeSelection.selectedRowIds.forEach((rowId) => {
|
||||
const parsed = parseFileTreeRowId(rowId);
|
||||
if (parsed?.kind === "asset") {
|
||||
ids.push(parsed.assetId);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [fileTreeSelection.selectedRowIds]);
|
||||
|
||||
const activeWorkspace =
|
||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||
sidebarData.workspaces[0];
|
||||
@@ -386,7 +408,17 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
const handleOpenAsset = useCallback((asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
router.push(`/documents/${asset.document_id}`);
|
||||
router.push(`/mindmap/${asset.document_id}/${asset.id}`);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
if (activeId && activeId === asset.document_id && editorBridge?.openTableFullScreen) {
|
||||
editorBridge.openTableFullScreen(asset.id);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
router.push(`/documents/${asset.document_id}?openTableId=${encodeURIComponent(asset.id)}`);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -398,7 +430,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}, [router, setOpen]);
|
||||
}, [activeId, editorBridge, router, setOpen]);
|
||||
|
||||
const handleFileTreeBlankMouseDown = useCallback(() => {
|
||||
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||
@@ -418,8 +450,20 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 与 VS Code 的单击打开不同:为了避免误触导致重资源文件(思维导图/表格等)被
|
||||
// 直接打开,我们在“无修饰键”的单击时只跳转到对应页面的 index.md(即文档本身)。
|
||||
if (event.button !== 0) return;
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey) return;
|
||||
|
||||
const targetDocId = row.docId;
|
||||
if (!targetDocId) return;
|
||||
if (activeId && activeId === targetDocId) return;
|
||||
|
||||
// doc/index/asset 都统一跳转到所属页面(index.md)
|
||||
handleOpenDocument(targetDocId, "main");
|
||||
},
|
||||
[fileTreeVisibleRowIds],
|
||||
[activeId, fileTreeVisibleRowIds, handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => {
|
||||
@@ -602,7 +646,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const handleCopyAssetLink = useCallback(
|
||||
async (asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
await copyText(buildDocumentUrl(asset.document_id), "页面链接已复制");
|
||||
await copyText(buildMindmapUrl(asset.document_id, asset.id), "思维导图链接已复制");
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
await copyText(buildTableUrl(asset.id), "表格链接已复制");
|
||||
return;
|
||||
}
|
||||
const url = asset.signed_url ?? asset.file_url ?? "";
|
||||
@@ -615,16 +663,18 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
|
||||
const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {
|
||||
const path =
|
||||
asset.asset_type === "mindmap"
|
||||
? ((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 || "附件";
|
||||
: asset.asset_type === "luckysheet"
|
||||
? (`tables/${asset.id}`)
|
||||
: asset.storage_path || asset.file_url || asset.file_name || "附件";
|
||||
await copyText(path, "存储路径已复制");
|
||||
}, []);
|
||||
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
|
||||
if (!resp.ok) {
|
||||
@@ -642,6 +692,10 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
window.alert("在线表格暂不支持下载(后续可做导出 JSON/Excel)");
|
||||
return;
|
||||
}
|
||||
const url = asset.signed_url ?? asset.file_url;
|
||||
if (!url) {
|
||||
window.alert("暂无可用的下载链接");
|
||||
@@ -658,7 +712,30 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
window.alert("思维导图暂不支持重命名");
|
||||
return;
|
||||
}
|
||||
const input = window.prompt("输入新文件名", asset.file_name ?? "");
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
const currentTitle =
|
||||
(asset.file_name ?? "").toLowerCase().endsWith(".luckysheet")
|
||||
? (asset.file_name ?? "").slice(0, -".luckysheet".length)
|
||||
: (asset.file_name ?? "");
|
||||
const input = window.prompt("输入新表格名", currentTitle);
|
||||
if (!input || !input.trim()) return;
|
||||
const newTitle = input.trim();
|
||||
const resp = await fetch(`/api/tables/${asset.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: newTitle }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "重命名失败");
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: asset.id } }));
|
||||
await sidebarQuery.refetch();
|
||||
setAssetMenu(null);
|
||||
return;
|
||||
}
|
||||
const input = window.prompt("输入新文件名", asset.file_name ?? "");
|
||||
if (!input || !input.trim()) return;
|
||||
const newName = input.trim();
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
@@ -684,7 +761,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
window.alert("思维导图文件无需移动,请在页面中直接编辑");
|
||||
return;
|
||||
}
|
||||
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
||||
if (asset.asset_type === "luckysheet") {
|
||||
window.alert("在线表格暂不支持移动(后续可实现跨页面迁移)");
|
||||
return;
|
||||
}
|
||||
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
||||
if (!target || !target.trim()) return;
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
@@ -712,7 +793,7 @@ 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) ?? mindmapAssets.find((item) => item.id === id))
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
|
||||
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
|
||||
@@ -720,6 +801,10 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
|
||||
const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
|
||||
const fileAssetsToDelete = assets.filter(
|
||||
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
|
||||
);
|
||||
const mindmapIdsByDocId = new Map<string, string[]>();
|
||||
mindmapAssetsToDelete.forEach((item) => {
|
||||
const prev = mindmapIdsByDocId.get(item.document_id) ?? [];
|
||||
@@ -727,13 +812,11 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
mindmapIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
const fileAssetIdsByDocId = new Map<string, string[]>();
|
||||
assets
|
||||
.filter((item) => item.asset_type !== "mindmap")
|
||||
.forEach((item) => {
|
||||
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
|
||||
prev.push(item.id);
|
||||
fileAssetIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
fileAssetsToDelete.forEach((item) => {
|
||||
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
|
||||
prev.push(item.id);
|
||||
fileAssetIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();
|
||||
|
||||
for (const asset of mindmapAssetsToDelete) {
|
||||
@@ -745,6 +828,16 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of tableAssetsToDelete) {
|
||||
const resp = await fetch(`/api/tables/${asset.id}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除在线表格失败");
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId: asset.id } }));
|
||||
}
|
||||
|
||||
if (fileAssetIds.length > 0) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
@@ -759,14 +852,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
if (uniqueAssetIds.length > 0) {
|
||||
setMediaAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
setMindmapAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
const mindmapSet = new Set(mindmapAssetsToDelete.map((item) => item.id));
|
||||
const tableSet = new Set(tableAssetsToDelete.map((item) => item.id));
|
||||
const fileSet = new Set(fileAssetsToDelete.map((item) => item.id));
|
||||
setMediaAssets((prev) => prev.filter((item) => !fileSet.has(item.id)));
|
||||
setMindmapAssets((prev) => prev.filter((item) => !mindmapSet.has(item.id)));
|
||||
setTableAssets((prev) => prev.filter((item) => !tableSet.has(item.id)));
|
||||
}
|
||||
setAssetMenu(null);
|
||||
mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
|
||||
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
|
||||
await sidebarQuery.refetch();
|
||||
},
|
||||
[mediaAssets, mindmapAssets, sidebarQuery],
|
||||
[mediaAssets, mindmapAssets, sidebarQuery, tableAssets],
|
||||
);
|
||||
|
||||
const handleDeleteFileTreeSelection = useCallback(async () => {
|
||||
@@ -781,7 +879,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
|
||||
const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : "";
|
||||
const assetText = assetIds.length > 0 ? `${assetIds.length} 个附件(彻底删除)` : "";
|
||||
const selectedAssets = assetIds
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
const mindmapCount = selectedAssets.filter((item) => item.asset_type === "mindmap").length;
|
||||
const tableCount = selectedAssets.filter((item) => item.asset_type === "luckysheet").length;
|
||||
const fileCount = selectedAssets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
|
||||
const unknownCount = Math.max(0, assetIds.length - mindmapCount - tableCount - fileCount);
|
||||
const assetTextParts: string[] = [];
|
||||
if (fileCount > 0) assetTextParts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
|
||||
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(删除)`);
|
||||
if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格(删除)`);
|
||||
if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象(删除)`);
|
||||
const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : "";
|
||||
const joinText = docText && assetText ? " + " : "";
|
||||
const ok = window.confirm(`确认删除选中的 ${docText}${joinText}${assetText} 吗?`);
|
||||
if (!ok) return;
|
||||
@@ -826,6 +936,9 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
fileTreeRows,
|
||||
fileTreeSelection.selectedRowIds,
|
||||
handleDeleteAssets,
|
||||
mediaAssets,
|
||||
mindmapAssets,
|
||||
tableAssets,
|
||||
refreshTree,
|
||||
router,
|
||||
]);
|
||||
@@ -1269,6 +1382,124 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
}, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);
|
||||
|
||||
const handleRestoreMediaAssetFromTrash = useCallback(
|
||||
async (assetId: string) => {
|
||||
if (!confirmTrashAction("确认恢复该附件吗?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restore", assetIds: [assetId] }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "恢复附件失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handlePurgeMediaAssetFromTrash = useCallback(
|
||||
async (assetId: string) => {
|
||||
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/media/purge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assetId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "彻底删除附件失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleEmptyMediaTrash = useCallback(async () => {
|
||||
if (!sidebarData.activeWorkspaceId) {
|
||||
window.alert("暂无可清空的工作空间");
|
||||
return;
|
||||
}
|
||||
if (!confirmTrashAction("清空附件垃圾桶后将无法恢复,是否继续?")) {
|
||||
return;
|
||||
}
|
||||
setEmptyingTrash(true);
|
||||
try {
|
||||
const [mediaResp, mindmapResp] = await Promise.all([
|
||||
fetch("/api/media/empty-trash", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
|
||||
}),
|
||||
fetch("/api/mindmap-trash/empty", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
|
||||
}),
|
||||
]);
|
||||
if (!mediaResp.ok) {
|
||||
const payload = await mediaResp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "清空附件垃圾桶失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
if (!mindmapResp.ok) {
|
||||
const payload = await mindmapResp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "清空思维导图垃圾桶失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
} finally {
|
||||
setEmptyingTrash(false);
|
||||
}
|
||||
}, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);
|
||||
|
||||
const handleRestoreMindmapFromTrash = useCallback(
|
||||
async (documentId: string, mindmapId: string) => {
|
||||
if (!confirmTrashAction("确认恢复该思维导图吗?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restore" }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "恢复思维导图失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handlePurgeMindmapFromTrash = useCallback(
|
||||
async (documentId: string, mindmapId: string) => {
|
||||
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "purge" }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "彻底删除思维导图失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleWorkspaceSwitch = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
if (workspaceId === sidebarData.activeWorkspaceId) {
|
||||
@@ -1533,7 +1764,12 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
<Trash2 className="h-4 w-4 text-gray-500" />
|
||||
垃圾桶
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{sidebarData.trashedDocuments.length} 条</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{sidebarData.trashedDocuments.length +
|
||||
(sidebarData.trashedMediaAssets?.length ?? 0) +
|
||||
(sidebarData.trashedMindmapAssets?.length ?? 0)}{" "}
|
||||
条
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1584,12 +1820,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onCopyPath={handleCopyAssetPath}
|
||||
onRename={handleRenameAsset}
|
||||
onMove={handleMoveAsset}
|
||||
onDelete={(ids) =>
|
||||
void handleDeleteAssets(
|
||||
selectedAssetIdsForMenu.length > 0 ? selectedAssetIdsForMenu : ids,
|
||||
assetMenu.asset,
|
||||
)
|
||||
}
|
||||
onDelete={() => void handleDeleteFileTreeSelection()}
|
||||
onDownload={handleDownloadAsset}
|
||||
/>
|
||||
)}
|
||||
@@ -1597,11 +1828,43 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>垃圾桶</DrawerTitle>
|
||||
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
|
||||
{trashTab === "documents" ? (
|
||||
<p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
附件删除后 10 分钟内可撤销;也可在此手动清空(不可撤销)。
|
||||
</p>
|
||||
)}
|
||||
</DrawerHeader>
|
||||
<div className="space-y-4 px-4 pb-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1 text-sm ${
|
||||
trashTab === "documents"
|
||||
? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
|
||||
: "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setTrashTab("documents")}
|
||||
>
|
||||
页面 ({sidebarData.trashedDocuments.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1 text-sm ${
|
||||
trashTab === "assets"
|
||||
? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
|
||||
: "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setTrashTab("assets")}
|
||||
>
|
||||
附件 (
|
||||
{(sidebarData.trashedMediaAssets?.length ?? 0) + (sidebarData.trashedMindmapAssets?.length ?? 0)}
|
||||
)
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="搜索删除的页面..."
|
||||
placeholder={trashTab === "documents" ? "搜索删除的页面..." : "搜索删除的附件..."}
|
||||
value={trashSearch}
|
||||
onChange={(event) => setTrashSearch(event.target.value)}
|
||||
/>
|
||||
@@ -1618,39 +1881,88 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void handleEmptyTrash()}
|
||||
onClick={() => void (trashTab === "documents" ? handleEmptyTrash() : handleEmptyMediaTrash())}
|
||||
disabled={emptyingTrash}
|
||||
>
|
||||
{emptyingTrash ? "清空中..." : "清空垃圾桶"}
|
||||
{emptyingTrash
|
||||
? "清空中..."
|
||||
: trashTab === "documents"
|
||||
? "清空垃圾桶"
|
||||
: "清空附件垃圾桶"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[#eaeaea]">
|
||||
{filteredTrash.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
|
||||
{trashTab === "documents" ? (
|
||||
filteredTrash.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
|
||||
) : (
|
||||
filteredTrash.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{item.title || "无标题"}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
删除时间:{new Date(item.deleted_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
|
||||
onClick={() => void handleRestoreFromTrash(item.id)}
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
||||
onClick={() => void handlePurgeFromTrash(item.id)}
|
||||
>
|
||||
彻底删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : filteredTrashedMediaAssets.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">暂无已删除附件</div>
|
||||
) : (
|
||||
filteredTrash.map((item) => (
|
||||
filteredTrashedMediaAssets.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{item.title || "无标题"}</div>
|
||||
<div className="min-w-0 pr-2">
|
||||
<div className="truncate font-medium text-gray-800">{item.file_name || "未命名附件"}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
删除时间:{new Date(item.deleted_at).toLocaleString()}
|
||||
删除时间:{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
类型:{item.mime_type ?? item.asset_type ?? "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
|
||||
onClick={() => void handleRestoreFromTrash(item.id)}
|
||||
onClick={() =>
|
||||
void (item.asset_type === "mindmap"
|
||||
? handleRestoreMindmapFromTrash(item.document_id, item.id)
|
||||
: handleRestoreMediaAssetFromTrash(item.id))
|
||||
}
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
||||
onClick={() => void handlePurgeFromTrash(item.id)}
|
||||
onClick={() =>
|
||||
void (item.asset_type === "mindmap"
|
||||
? handlePurgeMindmapFromTrash(item.document_id, item.id)
|
||||
: handlePurgeMediaAssetFromTrash(item.id))
|
||||
}
|
||||
>
|
||||
彻底删除
|
||||
</button>
|
||||
@@ -1957,6 +2269,20 @@ const buildDocumentUrl = (documentId: string): string => {
|
||||
return `${window.location.origin}/documents/${documentId}`;
|
||||
};
|
||||
|
||||
const buildMindmapUrl = (documentId: string, mindmapId: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/mindmap/${documentId}/${mindmapId}`;
|
||||
}
|
||||
return `${window.location.origin}/mindmap/${documentId}/${mindmapId}`;
|
||||
};
|
||||
|
||||
const buildTableUrl = (tableId: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/tables/${tableId}/view`;
|
||||
}
|
||||
return `${window.location.origin}/tables/${tableId}/view`;
|
||||
};
|
||||
|
||||
const copyText = async (text: string, successMessage: string) => {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface SidebarInitialData {
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets?: MediaAsset[];
|
||||
trashedMindmapAssets?: MediaAsset[];
|
||||
/**
|
||||
* 在线表格(Luckysheet)在文件树中的“虚拟文件”列表。
|
||||
* 仅用于文件树展示与操作(单击跳转 index / 双击全屏打开 / 同步删除)。
|
||||
*/
|
||||
tableAssets?: MediaAsset[];
|
||||
/**
|
||||
* 已存在思维导图文件(本地或 supabase)对应的页面 id 列表
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user