双向删除同步

This commit is contained in:
liaibo
2026-01-02 07:25:50 +08:00
parent 1db64c1c55
commit b1288487af
48 changed files with 2406 additions and 8841 deletions
@@ -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;
}