双向删除同步
This commit is contained in:
@@ -1,28 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Type,
|
||||
Strikethrough,
|
||||
Palette,
|
||||
X,
|
||||
Network,
|
||||
ListTree,
|
||||
Sliders,
|
||||
Calculator,
|
||||
StickyNote,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { Bold, Italic, Underline, Type, Strikethrough, Palette, X, Network } from "lucide-react";
|
||||
import {
|
||||
fontFamilyList,
|
||||
fontSizeList,
|
||||
lineHeightList,
|
||||
colorList,
|
||||
borderDasharrayList,
|
||||
borderRadiusList,
|
||||
borderWidthList,
|
||||
@@ -38,6 +24,7 @@ import {
|
||||
} from "./mindmapOptions";
|
||||
import iconConfig from "./mindmapIconConfig";
|
||||
import imageConfig from "./mindmapImageConfig";
|
||||
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
||||
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
|
||||
// @ts-expect-error 第三方库缺少类型定义
|
||||
const loadIconModules = async () => {
|
||||
@@ -60,19 +47,6 @@ type SidebarProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const predefinedIcons = [
|
||||
{
|
||||
type: 'priority',
|
||||
name: '优先级',
|
||||
list: ['1', '2', '3', '4', '5', '6', '7', '8', '9']
|
||||
},
|
||||
{
|
||||
type: 'progress',
|
||||
name: '进度',
|
||||
list: ['start', 'quarter', 'half', '3quarter', 'done'] // Standard keys usually
|
||||
}
|
||||
];
|
||||
|
||||
const ColorInput = ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -134,13 +108,13 @@ const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
const newStyle: Record<string, any> = {};
|
||||
[
|
||||
"fontFamily", "fontSize", "lineHeight", "color", "fontWeight", "fontStyle",
|
||||
"textDecoration", "borderWidth", "borderColor", "fillColor", "shape",
|
||||
"textDecoration", "borderWidth", "borderColor", "fillColor", "shape",
|
||||
"lineColor", "lineWidth", "lineDasharray", "borderRadius", "borderDasharray"
|
||||
].forEach((prop) => {
|
||||
newStyle[prop] = node.getStyle(prop, false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStyle(newStyle);
|
||||
// 异步更新,避免同步 setState 抛 lint
|
||||
Promise.resolve().then(() => setStyle(newStyle));
|
||||
}
|
||||
}, [activeNodes]);
|
||||
|
||||
@@ -551,7 +525,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
dangerouslySetInnerHTML={{ __html: item.icon }}
|
||||
/>
|
||||
) : (
|
||||
<img src={item.icon} alt={item.name} className="h-6 w-6 object-contain" />
|
||||
<Image src={item.icon} alt={item.name} width={24} height={24} className="h-6 w-6 object-contain" />
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-gray-700">{item.name}</span>
|
||||
@@ -578,7 +552,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||||
onClick={() => setSticker(item)}
|
||||
className="flex h-16 w-16 items-center justify-center rounded border border-gray-200 bg-white hover:border-blue-400 hover:shadow-sm"
|
||||
>
|
||||
<img src={item.url} alt={group.name} className="h-14 w-14 object-contain" />
|
||||
<Image src={item.url} alt={group.name} width={56} height={56} className="h-14 w-14 object-contain" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -623,8 +597,23 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
|
||||
const activate = (node: any) => {
|
||||
const r = mindmap?.renderer;
|
||||
if (!node || !r) return;
|
||||
r.activeNodeList = [node];
|
||||
r.lastActiveNodeList = [node];
|
||||
// 仅通过已有方法触发激活,避免直接改 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 报错
|
||||
}
|
||||
}
|
||||
mindmap.emit?.("node_active", node, [node]);
|
||||
r.setRootNodeCenter?.();
|
||||
};
|
||||
@@ -660,45 +649,336 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
|
||||
};
|
||||
|
||||
const SettingsPanel = ({ mindmap }: { mindmap: any }) => {
|
||||
const [freeDrag, setFreeDrag] = useState<boolean>(!!mindmap?.opt?.enableFreeDrag);
|
||||
const [wheel, setWheel] = useState<string>(mindmap?.opt?.mousewheelAction || "zoom");
|
||||
const [aiOn, setAiOn] = useState<boolean>(true);
|
||||
const [config, setConfig] = useState({
|
||||
openPerformance: !!mindmap?.opt?.openPerformance,
|
||||
enableFreeDrag: !!mindmap?.opt?.enableFreeDrag,
|
||||
mousewheelAction: mindmap?.opt?.mousewheelAction || "zoom",
|
||||
mousewheelZoomActionReverse: !!mindmap?.opt?.mousewheelZoomActionReverse,
|
||||
openRealtimeRenderOnNodeTextEdit: !!mindmap?.opt?.openRealtimeRenderOnNodeTextEdit,
|
||||
alwaysShowExpandBtn: !!mindmap?.opt?.alwaysShowExpandBtn,
|
||||
enableAutoEnterTextEditWhenKeydown: !!mindmap?.opt?.enableAutoEnterTextEditWhenKeydown,
|
||||
createNewNodeBehavior: mindmap?.opt?.createNewNodeBehavior || "default",
|
||||
imgTextMargin: mindmap?.opt?.imgTextMargin ?? 5,
|
||||
textContentMargin: mindmap?.opt?.textContentMargin ?? 2,
|
||||
});
|
||||
const [aiOn, setAiOn] = useState<boolean>(mindmap?.opt?.enableAi ?? true);
|
||||
const [watermark, setWatermark] = useState({
|
||||
show: !!mindmap?.opt?.watermarkConfig?.text,
|
||||
onlyExport: mindmap?.opt?.watermarkConfig?.onlyExport ?? false,
|
||||
belowNode: mindmap?.opt?.watermarkConfig?.belowNode ?? false,
|
||||
text: mindmap?.opt?.watermarkConfig?.text ?? "",
|
||||
lineSpacing: mindmap?.opt?.watermarkConfig?.lineSpacing ?? 100,
|
||||
textSpacing: mindmap?.opt?.watermarkConfig?.textSpacing ?? 100,
|
||||
angle: mindmap?.opt?.watermarkConfig?.angle ?? 30,
|
||||
textStyle: {
|
||||
color: mindmap?.opt?.watermarkConfig?.textStyle?.color ?? "#999",
|
||||
opacity: mindmap?.opt?.watermarkConfig?.textStyle?.opacity ?? 0.5,
|
||||
fontSize: mindmap?.opt?.watermarkConfig?.textStyle?.fontSize ?? 14,
|
||||
},
|
||||
});
|
||||
|
||||
const updateFreeDrag = (val: boolean) => {
|
||||
setFreeDrag(val);
|
||||
if (mindmap) mindmap.opt.enableFreeDrag = val;
|
||||
useEffect(() => {
|
||||
const opt = mindmap?.opt || {};
|
||||
Promise.resolve().then(() =>
|
||||
setConfig({
|
||||
openPerformance: !!opt.openPerformance,
|
||||
enableFreeDrag: !!opt.enableFreeDrag,
|
||||
mousewheelAction: opt.mousewheelAction || "zoom",
|
||||
mousewheelZoomActionReverse: !!opt.mousewheelZoomActionReverse,
|
||||
openRealtimeRenderOnNodeTextEdit: !!opt.openRealtimeRenderOnNodeTextEdit,
|
||||
alwaysShowExpandBtn: !!opt.alwaysShowExpandBtn,
|
||||
enableAutoEnterTextEditWhenKeydown: !!opt.enableAutoEnterTextEditWhenKeydown,
|
||||
createNewNodeBehavior: opt.createNewNodeBehavior || "default",
|
||||
imgTextMargin: opt.imgTextMargin ?? 5,
|
||||
textContentMargin: opt.textContentMargin ?? 2,
|
||||
}),
|
||||
);
|
||||
const wm = opt.watermarkConfig || {};
|
||||
Promise.resolve().then(() =>
|
||||
setWatermark({
|
||||
show: !!wm.text,
|
||||
onlyExport: wm.onlyExport ?? false,
|
||||
belowNode: wm.belowNode ?? false,
|
||||
text: wm.text ?? "",
|
||||
lineSpacing: wm.lineSpacing ?? 100,
|
||||
textSpacing: wm.textSpacing ?? 100,
|
||||
angle: wm.angle ?? 30,
|
||||
textStyle: {
|
||||
color: wm.textStyle?.color ?? "#999",
|
||||
opacity: wm.textStyle?.opacity ?? 0.5,
|
||||
fontSize: wm.textStyle?.fontSize ?? 14,
|
||||
},
|
||||
}),
|
||||
);
|
||||
Promise.resolve().then(() => setAiOn(opt.enableAi ?? true));
|
||||
}, [mindmap]);
|
||||
|
||||
const updateOpt = (key: string, value: any, needRender = false) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
mindmap?.updateConfig?.({ [key]: value });
|
||||
if (needRender && mindmap?.reRender) {
|
||||
mindmap.reRender();
|
||||
}
|
||||
};
|
||||
const updateWheel = (val: string) => {
|
||||
setWheel(val);
|
||||
if (mindmap) mindmap.opt.mousewheelAction = val;
|
||||
|
||||
const applyWatermark = (next: any) => {
|
||||
const { show, ...cfg } = next;
|
||||
const finalCfg = show ? cfg : { ...cfg, text: "" };
|
||||
mindmap?.watermark?.updateWatermark?.(finalCfg);
|
||||
mindmap?.updateConfig?.({ watermarkConfig: finalCfg });
|
||||
};
|
||||
|
||||
const updateWatermark = (patch: any) => {
|
||||
setWatermark((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
...patch,
|
||||
textStyle: { ...prev.textStyle, ...(patch.textStyle || {}) },
|
||||
};
|
||||
applyWatermark(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateAi = (val: boolean) => {
|
||||
setAiOn(val);
|
||||
mindmap?.updateConfig?.({ enableAi: val });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">自由拖拽</Label>
|
||||
<Toggle size="sm" pressed={freeDrag} onPressedChange={updateFreeDrag}>
|
||||
{freeDrag ? "开" : "关"}
|
||||
</Toggle>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">显示水印</Label>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={watermark.show}
|
||||
onPressedChange={(v) => updateWatermark({ show: v })}
|
||||
>
|
||||
{watermark.show ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
{watermark.show && (
|
||||
<div className="space-y-3 rounded-md border border-gray-100 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">仅导出时显示</Label>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={watermark.onlyExport}
|
||||
onPressedChange={(v) => updateWatermark({ onlyExport: v })}
|
||||
>
|
||||
{watermark.onlyExport ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">节点下方</Label>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={watermark.belowNode}
|
||||
onPressedChange={(v) => updateWatermark({ belowNode: v })}
|
||||
>
|
||||
{watermark.belowNode ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">水印文字</Label>
|
||||
<Input
|
||||
value={watermark.text}
|
||||
placeholder="请输入水印文字"
|
||||
onChange={(e) => updateWatermark({ text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColorInput
|
||||
label="颜色"
|
||||
value={watermark.textStyle.color}
|
||||
onChange={(val) => updateWatermark({ textStyle: { color: val } })}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">透明度 0~1</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={watermark.textStyle.opacity}
|
||||
onChange={(e) =>
|
||||
updateWatermark({
|
||||
textStyle: {
|
||||
opacity: Math.min(1, Math.max(0, Number(e.target.value) || 0)),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">字号</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={8}
|
||||
max={50}
|
||||
step={1}
|
||||
value={watermark.textStyle.fontSize}
|
||||
onChange={(e) =>
|
||||
updateWatermark({
|
||||
textStyle: { fontSize: Number(e.target.value) || 14 },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">旋转角度</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={90}
|
||||
step={5}
|
||||
value={watermark.angle}
|
||||
onChange={(e) => updateWatermark({ angle: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">行间距</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
step={10}
|
||||
value={watermark.lineSpacing}
|
||||
onChange={(e) => updateWatermark({ lineSpacing: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">字间距</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
step={10}
|
||||
value={watermark.textSpacing}
|
||||
onChange={(e) => updateWatermark({ textSpacing: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">滚轮行为</Label>
|
||||
<NativeSelect
|
||||
value={wheel}
|
||||
onChange={updateWheel}
|
||||
options={[
|
||||
{ label: "缩放", value: "zoom" },
|
||||
{ label: "平移", value: "move" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">性能模式</Label>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={config.openPerformance}
|
||||
onPressedChange={(v) => updateOpt("openPerformance", v)}
|
||||
>
|
||||
{config.openPerformance ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">自由拖拽</Label>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={config.enableFreeDrag}
|
||||
onPressedChange={(v) => updateOpt("enableFreeDrag", v)}
|
||||
>
|
||||
{config.enableFreeDrag ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">实时渲染(编辑时)</Label>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={config.openRealtimeRenderOnNodeTextEdit}
|
||||
onPressedChange={(v) => updateOpt("openRealtimeRenderOnNodeTextEdit", v)}
|
||||
>
|
||||
{config.openRealtimeRenderOnNodeTextEdit ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">展开按钮常显</Label>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={config.alwaysShowExpandBtn}
|
||||
onPressedChange={(v) => updateOpt("alwaysShowExpandBtn", v, true)}
|
||||
>
|
||||
{config.alwaysShowExpandBtn ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">键入自动进入编辑</Label>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={config.enableAutoEnterTextEditWhenKeydown}
|
||||
onPressedChange={(v) => updateOpt("enableAutoEnterTextEditWhenKeydown", v)}
|
||||
>
|
||||
{config.enableAutoEnterTextEditWhenKeydown ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">滚轮行为</Label>
|
||||
<NativeSelect
|
||||
value={config.mousewheelAction}
|
||||
onChange={(v) => updateOpt("mousewheelAction", v)}
|
||||
options={[
|
||||
{ label: "缩放", value: "zoom" },
|
||||
{ label: "平移", value: "move" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{config.mousewheelAction === "zoom" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">缩放方向反转</Label>
|
||||
<NativeSelect
|
||||
value={String(config.mousewheelZoomActionReverse)}
|
||||
onChange={(v) => updateOpt("mousewheelZoomActionReverse", v === "true")}
|
||||
options={[
|
||||
{ label: "常规", value: "false" },
|
||||
{ label: "反转", value: "true" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">创建新节点激活行为</Label>
|
||||
<NativeSelect
|
||||
value={config.createNewNodeBehavior}
|
||||
onChange={(v) => updateOpt("createNewNodeBehavior", v)}
|
||||
options={[
|
||||
{ label: "默认", value: "default" },
|
||||
{ label: "不激活", value: "notActive" },
|
||||
{ label: "仅激活新节点", value: "activeOnly" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">图片与文本间距</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={config.imgTextMargin}
|
||||
onChange={(e) => updateOpt("imgTextMargin", Number(e.target.value) || 0, true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">文本内容间距</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={config.textContentMargin}
|
||||
onChange={(e) => updateOpt("textContentMargin", Number(e.target.value) || 0, true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-gray-500">AI 功能</Label>
|
||||
<Toggle size="sm" pressed={aiOn} onPressedChange={setAiOn}>
|
||||
<Toggle size="sm" pressed={aiOn} onPressedChange={updateAi}>
|
||||
{aiOn ? "开" : "关"}
|
||||
</Toggle>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">其余设置后续接入(滚轮缩放方向、拖拽导入等)。</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -739,11 +1019,13 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
|
||||
// 当选中节点变化时,自动展示首个节点的备注
|
||||
useEffect(() => {
|
||||
if (!activeNodes?.length) {
|
||||
setNote("");
|
||||
Promise.resolve().then(() => setNote(""));
|
||||
return;
|
||||
}
|
||||
const current = readNote(activeNodes[0]);
|
||||
setNote(typeof current === "string" ? current : "");
|
||||
Promise.resolve().then(() =>
|
||||
setNote(typeof current === "string" ? current : ""),
|
||||
);
|
||||
}, [activeNodes]);
|
||||
|
||||
const getActiveList = () =>
|
||||
@@ -794,28 +1076,404 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
|
||||
);
|
||||
};
|
||||
|
||||
const AiPanel = () => {
|
||||
type AiMode = "chat" | "full" | "partial";
|
||||
|
||||
const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const send = () => window.alert("AI 对话占位,后续接入大模型:\n" + prompt);
|
||||
const [mode, setMode] = useState<AiMode>("full");
|
||||
const [model, setModel] = useState("qwen3:30b-a3b-instruct-2507-q4_K_M");
|
||||
const [baseUrl, setBaseUrl] = useState("http://localhost:11434/api/chat");
|
||||
const [systemPrompt, setSystemPrompt] = useState("请用 Markdown,仅使用标题和无序列表结构输出思维导图内容。不要添加额外解释。");
|
||||
const [streamText, setStreamText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const controllerRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
const resetStream = () => {
|
||||
setStreamText("");
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = null;
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const runChat = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
resetStream();
|
||||
setLoading(true);
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
try {
|
||||
const res = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: prompt.trim() },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("无法读取流");
|
||||
const decoder = new TextDecoder();
|
||||
let full = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
// Ollama 返回 json 行
|
||||
const lines = chunk.split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.message?.content) {
|
||||
full += json.message.content;
|
||||
setStreamText((prev) => prev + json.message.content);
|
||||
}
|
||||
} catch {
|
||||
// 忽略非 json 行
|
||||
}
|
||||
}
|
||||
}
|
||||
return full;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
controllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 将 AI 文本解析为子节点数组,尽量兼容宽松 Markdown 列表
|
||||
const parseChildrenFromText = async (text: string) => {
|
||||
const { transformMarkdownTo } = await import("simple-mind-map/src/parse/markdownTo.js");
|
||||
const { createUid } = await import("simple-mind-map/src/utils/index.js");
|
||||
const safeText = text || "";
|
||||
let children = transformMarkdownTo(safeText)?.children || [];
|
||||
if (!children.length) {
|
||||
const lines = safeText
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const listLines = lines.filter((l) => /^[-*+]\s+/.test(l)).map((l) => l.replace(/^[-*+]\s+/, ""));
|
||||
const headingLines = lines.filter((l) => /^#+\s+/.test(l)).map((l) => l.replace(/^#+\s+/, ""));
|
||||
const useLines = listLines.length ? listLines : headingLines.length ? headingLines : lines;
|
||||
children = useLines.map((t) => ({ data: { text: t } }));
|
||||
}
|
||||
if (!children.length) {
|
||||
children = [{ data: { text: safeText.slice(0, 200) || "新节点" } }];
|
||||
}
|
||||
|
||||
const fill = (nodes: any[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (!node.data) node.data = {};
|
||||
if (!node.data.text) node.data.text = "新节点";
|
||||
if (!node.data.uid) node.data.uid = createUid();
|
||||
if (node.children?.length) fill(node.children);
|
||||
});
|
||||
};
|
||||
fill(children);
|
||||
return JSON.parse(JSON.stringify(children));
|
||||
};
|
||||
|
||||
const getActiveList = () =>
|
||||
(activeNodes && activeNodes.length ? activeNodes : mindmap?.renderer?.activeNodeList || []) as any[];
|
||||
|
||||
const updateDataByUids = async (uids: string[], updater: (node: any) => void) => {
|
||||
const source = mindmap?.getData?.(true) || mindmap?.getData?.();
|
||||
const dataClone = source ? JSON.parse(JSON.stringify(source)) : null;
|
||||
if (!dataClone) {
|
||||
window.alert("无法获取导图数据,操作失败。");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 确保整棵树都有 uid,避免官方逻辑依赖 uid 时抛错
|
||||
const { createUid } = await import("simple-mind-map/src/utils/index.js");
|
||||
const sanitizeNode = (node: any) => {
|
||||
if (!node || typeof node !== "object") return null;
|
||||
if (!node.data) node.data = {};
|
||||
if (!node.data.uid) node.data.uid = createUid();
|
||||
if (typeof node.data.text !== "string") {
|
||||
node.data.text =
|
||||
node.data.text != null ? String(node.data.text) : "新节点";
|
||||
}
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children = node.children
|
||||
.map((c: any) => sanitizeNode(c))
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
node.children = [];
|
||||
}
|
||||
return node;
|
||||
};
|
||||
sanitizeNode(dataClone);
|
||||
|
||||
const cleanRenderCallbacks = () => {
|
||||
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",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
cleanRenderCallbacks();
|
||||
|
||||
const walk = (node: any) => {
|
||||
if (uids.includes(node?.data?.uid)) {
|
||||
updater(node);
|
||||
}
|
||||
if (node.children?.length) {
|
||||
node.children.forEach(walk);
|
||||
}
|
||||
};
|
||||
walk(dataClone);
|
||||
cleanRenderCallbacks();
|
||||
mindmap?.updateData?.(dataClone);
|
||||
cleanRenderCallbacks();
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyFullMindmap = async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
try {
|
||||
const { transformMarkdownTo } = await import("simple-mind-map/src/parse/markdownTo.js");
|
||||
const data = transformMarkdownTo(content);
|
||||
if (!data?.data) {
|
||||
data.data = { text: "中心主题" };
|
||||
}
|
||||
mindmap?.setData?.(data);
|
||||
mindmap?.command?.clearHistory?.();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
window.alert("解析 AI 结果失败,请检查格式(需 Markdown 标题和无序列表)。");
|
||||
}
|
||||
};
|
||||
|
||||
const applyPartialMindmap = async (text: string, presetChildren?: any[]) => {
|
||||
const list = getActiveList();
|
||||
if (!list?.length) {
|
||||
window.alert("请选择节点后再续写。");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const children = presetChildren || (await parseChildrenFromText(text));
|
||||
const uids = list
|
||||
.map(
|
||||
(node: any) =>
|
||||
node?.nodeData?.data?.uid ||
|
||||
node?.nodeData?.uid ||
|
||||
node?.getData?.("uid") ||
|
||||
node?.uid,
|
||||
)
|
||||
.filter(Boolean);
|
||||
if (!uids.length) {
|
||||
window.alert("未获取到选中节点的 UID,续写失败。");
|
||||
return;
|
||||
}
|
||||
const ok = await updateDataByUids(uids as string[], (node) => {
|
||||
node.children = Array.isArray(node.children) ? node.children : [];
|
||||
const childrenCopy = JSON.parse(JSON.stringify(children));
|
||||
node.children.push(...childrenCopy);
|
||||
});
|
||||
if (!ok) return;
|
||||
mindmap?.renderer?.render?.(mindmap?.getData?.());
|
||||
} catch (e) {
|
||||
console.error("AI Markdown Parsing Error:", e);
|
||||
if (e instanceof Error) {
|
||||
console.error("Error Message:", e.message);
|
||||
console.error("Error Stack:", e.stack);
|
||||
}
|
||||
window.alert("解析 AI 返回的 Markdown 失败,请检查格式。");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = (await runChat()) || streamText;
|
||||
if (mode === "full" && text) {
|
||||
await applyFullMindmap(text);
|
||||
}
|
||||
if (mode === "partial" && text) {
|
||||
await applyPartialMindmap(text);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToNote = () => {
|
||||
const text = streamText.trim();
|
||||
if (!text) {
|
||||
window.alert("暂无可保存的内容");
|
||||
return;
|
||||
}
|
||||
const list = getActiveList();
|
||||
if (!list?.length) {
|
||||
window.alert("请选择节点后再保存备注");
|
||||
return;
|
||||
}
|
||||
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, text));
|
||||
};
|
||||
|
||||
const handleAppendSingleChild = async () => {
|
||||
const text = streamText.trim();
|
||||
if (!text) {
|
||||
window.alert("暂无可生成的内容");
|
||||
return;
|
||||
}
|
||||
const children = await parseChildrenFromText(text.slice(0, 500));
|
||||
const first = children.length ? [children[0]] : [{ data: { text } }];
|
||||
await applyPartialMindmap("", first);
|
||||
};
|
||||
|
||||
const handleAppendMultiChild = async () => {
|
||||
const text = streamText.trim();
|
||||
if (!text) {
|
||||
window.alert("暂无可生成的内容");
|
||||
return;
|
||||
}
|
||||
const children = await parseChildrenFromText(text);
|
||||
await applyPartialMindmap("", children);
|
||||
};
|
||||
|
||||
const handleReplaceCurrent = async () => {
|
||||
const text = streamText.trim();
|
||||
if (!text) {
|
||||
window.alert("暂无可替换的内容");
|
||||
return;
|
||||
}
|
||||
const list = getActiveList();
|
||||
if (!list?.length) {
|
||||
window.alert("请选择节点后再修改");
|
||||
return;
|
||||
}
|
||||
const uids = list
|
||||
.map(
|
||||
(node: any) =>
|
||||
node?.nodeData?.data?.uid ||
|
||||
node?.nodeData?.uid ||
|
||||
node?.getData?.("uid") ||
|
||||
node?.uid,
|
||||
)
|
||||
.filter(Boolean) as string[];
|
||||
const ok = await updateDataByUids(uids, (node) => {
|
||||
if (!node.data) node.data = {};
|
||||
node.data.text = text;
|
||||
});
|
||||
if (ok) {
|
||||
mindmap?.renderer?.render?.(mindmap?.getData?.());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs text-gray-500">AI 对话</Label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||||
rows={4}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="输入需求,后续将调用 AI 生成/优化节点"
|
||||
<Label className="text-xs text-gray-500">模式</Label>
|
||||
<NativeSelect
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as AiMode)}
|
||||
options={[
|
||||
{ label: "整图生成", value: "full" },
|
||||
{ label: "对话", value: "chat" },
|
||||
{ label: "选中节点续写", value: "partial" },
|
||||
]}
|
||||
/>
|
||||
<button className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600" onClick={send}>
|
||||
发送
|
||||
</button>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">模型</Label>
|
||||
<Input value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">接口地址</Label>
|
||||
<Input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">系统提示</Label>
|
||||
<Input value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{mode === "full" ? "生成主题" : mode === "partial" ? "续写内容" : "对话输入"}</Label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||||
rows={5}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={
|
||||
mode === "full"
|
||||
? "例如:帮我生成一份 OKR 思维导图"
|
||||
: mode === "partial"
|
||||
? "描述要续写的方向或要补充的要点"
|
||||
: "输入你想询问的内容"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
onClick={handleSend}
|
||||
>
|
||||
{loading ? "生成中..." : "发送"}
|
||||
</button>
|
||||
<button
|
||||
disabled={!loading}
|
||||
className="w-20 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
onClick={stop}
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">流式输出</Label>
|
||||
<div className="h-40 overflow-auto rounded-md border border-gray-200 p-2 text-sm whitespace-pre-wrap">
|
||||
{streamText || "等待输出..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">输出处理</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
|
||||
onClick={handleSaveToNote}
|
||||
>
|
||||
生成到备注
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
|
||||
onClick={handleAppendSingleChild}
|
||||
>
|
||||
生成为单一子节点
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
|
||||
onClick={handleAppendMultiChild}
|
||||
>
|
||||
生成为多个子节点
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
|
||||
onClick={handleReplaceCurrent}
|
||||
>
|
||||
修改当前节点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{mode === "full" && (
|
||||
<p className="text-xs text-gray-400">
|
||||
将使用 AI 生成完整思维导图,返回的 Markdown 会自动解析并替换当前导图。请确保本地 Ollama 已启动且模型已下载。
|
||||
</p>
|
||||
)}
|
||||
{mode === "partial" && (
|
||||
<p className="text-xs text-gray-400">
|
||||
续写模式:请选择一个或多个节点,AI 返回的 Markdown 子列表会追加为所选节点的子节点。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
||||
|
||||
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
|
||||
const content = useMemo(() => {
|
||||
switch (activeTab as SidebarPanel | null) {
|
||||
@@ -838,7 +1496,7 @@ export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: Sid
|
||||
case "note":
|
||||
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||||
case "ai":
|
||||
return <AiPanel />;
|
||||
return <AiPanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user