- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录 - 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目 - 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑 - 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
1846 lines
67 KiB
TypeScript
1846 lines
67 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
import React, { useEffect, useMemo, useState } from "react";
|
||
|
||
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
|
||
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
|
||
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 } from "lucide-react";
|
||
import {
|
||
fontFamilyList,
|
||
fontSizeList,
|
||
lineHeightList,
|
||
borderDasharrayList,
|
||
borderRadiusList,
|
||
borderWidthList,
|
||
lineWidthList,
|
||
shapeList,
|
||
lineStyleList,
|
||
themeList,
|
||
layoutList,
|
||
outlineDepthOptions,
|
||
backgroundRepeatList,
|
||
backgroundPositionList,
|
||
backgroundSizeList,
|
||
} from "./mindmapOptions";
|
||
import iconConfig from "./mindmapIconConfig";
|
||
import imageConfig from "./mindmapImageConfig";
|
||
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");
|
||
const { mergerIconList } = await import("simple-mind-map/src/utils/index.js");
|
||
return { nodeIconList, mergerIconList };
|
||
};
|
||
|
||
type SidebarProps = {
|
||
documentId: string;
|
||
mindmapId: string;
|
||
mindmap: any;
|
||
activeNodes: MindMapNode[];
|
||
activeTab: SidebarPanel | null;
|
||
onClose: () => void;
|
||
};
|
||
|
||
// 旧 Next route 已退场;补完节点能力保留在 AI Agent 的 mindmap_expand_node 工具中。
|
||
const MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED = false;
|
||
|
||
const ColorInput = ({
|
||
value,
|
||
onChange,
|
||
label,
|
||
}: {
|
||
value: string;
|
||
onChange: (val: string) => void;
|
||
label?: string;
|
||
}) => (
|
||
<div className="flex items-center gap-2">
|
||
{label && <span className="text-xs text-gray-500">{label}</span>}
|
||
<div className="relative h-6 w-full flex-1 overflow-hidden rounded border border-gray-200">
|
||
<input
|
||
type="color"
|
||
value={value || "#000000"}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className="absolute inset-[-50%] h-[200%] w-[200%] cursor-pointer border-0 p-0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const NativeSelect = ({
|
||
value,
|
||
onChange,
|
||
options,
|
||
placeholder,
|
||
disabled,
|
||
}: {
|
||
value: string | number;
|
||
onChange: (val: string) => void;
|
||
options: { label: string | number; value: string | number }[];
|
||
placeholder?: string;
|
||
disabled?: boolean;
|
||
}) => (
|
||
<div className="relative">
|
||
<select
|
||
disabled={disabled}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className="flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 appearance-none"
|
||
>
|
||
{placeholder && <option value="" disabled>{placeholder}</option>}
|
||
{options.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
);
|
||
|
||
const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||
const [style, setStyle] = useState<Record<string, any>>({});
|
||
|
||
useEffect(() => {
|
||
if (activeNodes.length > 0) {
|
||
const node = activeNodes[0];
|
||
const newStyle: Record<string, any> = {};
|
||
[
|
||
"fontFamily", "fontSize", "lineHeight", "color", "fontWeight", "fontStyle",
|
||
"textDecoration", "borderWidth", "borderColor", "fillColor", "shape",
|
||
"lineColor", "lineWidth", "lineDasharray", "borderRadius", "borderDasharray"
|
||
].forEach((prop) => {
|
||
newStyle[prop] = node.getStyle(prop, false);
|
||
});
|
||
// 异步更新,避免同步 setState 抛 lint
|
||
Promise.resolve().then(() => setStyle(newStyle));
|
||
}
|
||
}, [activeNodes]);
|
||
|
||
const updateStyle = (prop: string, value: any) => {
|
||
setStyle((prev) => ({ ...prev, [prop]: value }));
|
||
activeNodes.forEach((node) => {
|
||
node.setStyle?.(prop, value);
|
||
});
|
||
};
|
||
|
||
if (activeNodes.length === 0) {
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center p-4 text-center text-gray-400">
|
||
<Type className="mb-2 h-10 w-10 opacity-20" />
|
||
<p className="text-sm">请选择一个节点以编辑样式</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="space-y-3">
|
||
<Label className="text-xs font-semibold text-gray-500">文字</Label>
|
||
<NativeSelect
|
||
value={style.fontFamily || fontFamilyList[0]?.value}
|
||
options={fontFamilyList.map((f) => ({ label: f.name, value: f.value }))}
|
||
onChange={(v) => updateStyle("fontFamily", v)}
|
||
/>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<NativeSelect
|
||
value={style.fontSize || 14}
|
||
options={fontSizeList.map((s) => ({ label: `${s}px`, value: s }))}
|
||
onChange={(v) => updateStyle("fontSize", Number(v))}
|
||
/>
|
||
<NativeSelect
|
||
value={style.lineHeight || 1.5}
|
||
options={lineHeightList.map((h) => ({ label: h, value: h }))}
|
||
onChange={(v) => updateStyle("lineHeight", Number(v))}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Toggle
|
||
size="sm"
|
||
pressed={style.fontWeight === "bold"}
|
||
onPressedChange={(pressed) => updateStyle("fontWeight", pressed ? "bold" : "normal")}
|
||
>
|
||
<Bold className="h-4 w-4" />
|
||
</Toggle>
|
||
<Toggle
|
||
size="sm"
|
||
pressed={style.fontStyle === "italic"}
|
||
onPressedChange={(pressed) => updateStyle("fontStyle", pressed ? "italic" : "normal")}
|
||
>
|
||
<Italic className="h-4 w-4" />
|
||
</Toggle>
|
||
<Toggle
|
||
size="sm"
|
||
pressed={style.textDecoration === "underline"}
|
||
onPressedChange={(pressed) => updateStyle("textDecoration", pressed ? "underline" : "none")}
|
||
>
|
||
<Underline className="h-4 w-4" />
|
||
</Toggle>
|
||
<Toggle
|
||
size="sm"
|
||
pressed={style.textDecoration === "line-through"}
|
||
onPressedChange={(pressed) => updateStyle("textDecoration", pressed ? "line-through" : "none")}
|
||
>
|
||
<Strikethrough className="h-4 w-4" />
|
||
</Toggle>
|
||
</div>
|
||
<ColorInput
|
||
label="颜色"
|
||
value={style.color}
|
||
onChange={(v) => updateStyle("color", v)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<Label className="text-xs font-semibold text-gray-500">边框 & 背景</Label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<NativeSelect
|
||
value={style.shape || "rectangle"}
|
||
options={shapeList.map(s => ({label: s.name, value: s.value}))}
|
||
onChange={(v) => updateStyle("shape", v)}
|
||
/>
|
||
<NativeSelect
|
||
value={style.borderDasharray || "none"}
|
||
options={borderDasharrayList.map(s => ({label: s.name, value: s.value}))}
|
||
onChange={(v) => updateStyle("borderDasharray", v)}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<NativeSelect
|
||
value={Number.isFinite(style.borderWidth) ? style.borderWidth : (style.borderWidth ?? 0)}
|
||
options={borderWidthList.map((n) => ({ label: `${n}px`, value: n }))}
|
||
onChange={(v) => updateStyle("borderWidth", Number(v))}
|
||
/>
|
||
<NativeSelect
|
||
value={Number.isFinite(style.borderRadius) ? style.borderRadius : (style.borderRadius ?? 0)}
|
||
options={borderRadiusList.map((n) => ({ label: `${n}px`, value: n }))}
|
||
onChange={(v) => updateStyle("borderRadius", Number(v))}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<ColorInput
|
||
label="边框"
|
||
value={style.borderColor}
|
||
onChange={(v) => updateStyle("borderColor", v)}
|
||
/>
|
||
<ColorInput
|
||
label="填充"
|
||
value={style.fillColor}
|
||
onChange={(v) => updateStyle("fillColor", v)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<Label className="text-xs font-semibold text-gray-500">连线</Label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<NativeSelect
|
||
value={style.lineDasharray || "none"}
|
||
options={borderDasharrayList.map(s => ({label: s.name, value: s.value}))}
|
||
onChange={(v) => updateStyle("lineDasharray", v)}
|
||
/>
|
||
<NativeSelect
|
||
value={Number.isFinite(style.lineWidth) ? style.lineWidth : (style.lineWidth ?? 0)}
|
||
options={lineWidthList.map((n) => ({ label: `${n}px`, value: n }))}
|
||
onChange={(v) => updateStyle("lineWidth", Number(v))}
|
||
/>
|
||
</div>
|
||
<ColorInput
|
||
label="连线颜色"
|
||
value={style.lineColor}
|
||
onChange={(v) => updateStyle("lineColor", v)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const BaseStylePanel = ({ mindmap }: { mindmap: any }) => {
|
||
const [style, setStyle] = useState<Record<string, any>>({});
|
||
|
||
useEffect(() => {
|
||
if (!mindmap) return;
|
||
const initStyle = () => {
|
||
const newStyle: Record<string, any> = {};
|
||
[
|
||
"backgroundColor", "lineColor", "lineWidth", "lineStyle",
|
||
"paddingX", "paddingY",
|
||
"backgroundImage", "backgroundRepeat", "backgroundPosition", "backgroundSize",
|
||
].forEach(key => {
|
||
newStyle[key] = mindmap.getThemeConfig(key);
|
||
});
|
||
setStyle(newStyle);
|
||
};
|
||
|
||
initStyle();
|
||
}, [mindmap]);
|
||
|
||
const updateThemeConfig = (key: string, value: any) => {
|
||
if (!mindmap) return;
|
||
setStyle(prev => ({ ...prev, [key]: value }));
|
||
mindmap.setThemeConfig({ [key]: value });
|
||
};
|
||
|
||
if (!mindmap) return null;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="space-y-3">
|
||
<Label className="text-xs font-semibold text-gray-500">背景</Label>
|
||
<ColorInput
|
||
label="背景颜色"
|
||
value={style.backgroundColor}
|
||
onChange={(v) => updateThemeConfig("backgroundColor", v)}
|
||
/>
|
||
<div className="space-y-2">
|
||
<Label className="text-[11px] text-gray-500">背景图片</Label>
|
||
<Input
|
||
value={style.backgroundImage || ""}
|
||
onChange={(e) => updateThemeConfig("backgroundImage", e.target.value || "")}
|
||
placeholder="可粘贴图片地址"
|
||
/>
|
||
<div className="grid grid-cols-3 gap-2 text-[12px] text-gray-600">
|
||
<NativeSelect
|
||
value={style.backgroundRepeat || "no-repeat"}
|
||
options={backgroundRepeatList.map((i) => ({ label: i.name, value: i.value }))}
|
||
onChange={(v) => updateThemeConfig("backgroundRepeat", v)}
|
||
/>
|
||
<NativeSelect
|
||
value={style.backgroundPosition || "center center"}
|
||
options={backgroundPositionList.map((i) => ({ label: i.name, value: i.value }))}
|
||
onChange={(v) => updateThemeConfig("backgroundPosition", v)}
|
||
/>
|
||
<NativeSelect
|
||
value={style.backgroundSize || "cover"}
|
||
options={backgroundSizeList.map((i) => ({ label: i.name, value: i.value }))}
|
||
onChange={(v) => updateThemeConfig("backgroundSize", v)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<Label className="text-xs font-semibold text-gray-500">连线 (默认)</Label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<NativeSelect
|
||
value={style.lineStyle || "curve"}
|
||
options={lineStyleList.map(s => ({ label: s.name, value: s.value }))}
|
||
onChange={(v) => updateThemeConfig("lineStyle", v)}
|
||
/>
|
||
<NativeSelect
|
||
value={Number.isFinite(style.lineWidth) ? style.lineWidth : (style.lineWidth ?? 0)}
|
||
options={lineWidthList.map((n) => ({ label: `${n}px`, value: n }))}
|
||
onChange={(v) => updateThemeConfig("lineWidth", Number(v))}
|
||
/>
|
||
</div>
|
||
<ColorInput
|
||
label="连线颜色"
|
||
value={style.lineColor}
|
||
onChange={(v) => updateThemeConfig("lineColor", v)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<Label className="text-xs font-semibold text-gray-500">节点内边距 (默认)</Label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-gray-500 whitespace-nowrap">水平</span>
|
||
<Input
|
||
type="number"
|
||
className="h-8"
|
||
min={0}
|
||
value={parseInt(style.paddingX) || 0}
|
||
onChange={e => updateThemeConfig("paddingX", Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-gray-500 whitespace-nowrap">垂直</span>
|
||
<Input
|
||
type="number"
|
||
className="h-8"
|
||
min={0}
|
||
value={parseInt(style.paddingY) || 0}
|
||
onChange={e => updateThemeConfig("paddingY", Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ThemePanel = ({ mindmap }: { mindmap: any }) => {
|
||
const changeTheme = (theme: string) => {
|
||
if (!mindmap) return;
|
||
mindmap.setTheme(theme);
|
||
};
|
||
|
||
if (!mindmap) return null;
|
||
|
||
return (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{themeList.map(t => (
|
||
<button
|
||
key={t.value}
|
||
onClick={() => changeTheme(t.value)}
|
||
className="flex flex-col items-center justify-center rounded border border-gray-100 bg-gray-50 p-3 hover:border-blue-500 hover:bg-blue-50"
|
||
>
|
||
<Palette className="mb-2 h-6 w-6 text-gray-400" />
|
||
<span className="text-xs text-gray-600">{t.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const StructurePanel = ({ mindmap }: { mindmap: any }) => {
|
||
const changeLayout = (layout: string) => {
|
||
if (!mindmap) return;
|
||
mindmap.setLayout(layout);
|
||
};
|
||
|
||
if (!mindmap) return null;
|
||
|
||
return (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{layoutList.map(l => (
|
||
<button
|
||
key={l.value}
|
||
onClick={() => changeLayout(l.value)}
|
||
className="flex flex-col items-center justify-center rounded border border-gray-100 bg-gray-50 p-3 hover:border-blue-500 hover:bg-blue-50"
|
||
>
|
||
<Network className="mb-2 h-6 w-6 text-gray-400" />
|
||
<span className="text-xs text-gray-600">{l.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
|
||
const [activeSubTab, setActiveSubTab] = useState<"icon" | "sticker">("icon");
|
||
const [{ iconGroups, loading }, setIconGroups] = useState<{
|
||
iconGroups: any[];
|
||
loading: boolean;
|
||
}>({ iconGroups: [], loading: true });
|
||
useEffect(() => {
|
||
let mounted = true;
|
||
(async () => {
|
||
const { nodeIconList, mergerIconList } = await loadIconModules();
|
||
if (!mounted) return;
|
||
setIconGroups({
|
||
iconGroups: mergerIconList([
|
||
...nodeIconList,
|
||
...(iconConfig as unknown as any[]),
|
||
]),
|
||
loading: false,
|
||
});
|
||
})();
|
||
return () => {
|
||
mounted = false;
|
||
};
|
||
}, []);
|
||
const [stickerGroups] = useState(imageConfig);
|
||
|
||
const addIcon = (type: string, name: string) => {
|
||
const key = `${type}_${name}`;
|
||
activeNodes.forEach((node) => {
|
||
const rawIcons = node.getData("icon");
|
||
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
|
||
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
|
||
newIcons.push(key);
|
||
node.setIcon?.(newIcons);
|
||
});
|
||
};
|
||
|
||
const removeIcon = (type: string) => {
|
||
activeNodes.forEach((node) => {
|
||
const rawIcons = node.getData("icon");
|
||
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
|
||
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
|
||
node.setIcon?.(newIcons);
|
||
});
|
||
};
|
||
|
||
const setSticker = (img: { url: string; width?: number; height?: number }) => {
|
||
activeNodes.forEach((node) => {
|
||
// simple-mind-map 支持 setImage 接收对象,包含 url/width/height
|
||
node.setImage?.({
|
||
url: img.url,
|
||
width: img.width || 100,
|
||
height: img.height || 100,
|
||
});
|
||
});
|
||
};
|
||
|
||
const clearSticker = () => {
|
||
activeNodes.forEach((node) => {
|
||
// 传入 null 以清除贴纸
|
||
node.setImage?.(null);
|
||
});
|
||
};
|
||
|
||
if (loading) return <div className="p-4 text-center text-gray-400">图标加载中…</div>;
|
||
if (iconGroups.length === 0) return <div className="p-4 text-center text-gray-400">暂无图标</div>;
|
||
if (activeNodes.length === 0) return <div className="p-4 text-center text-gray-400">请选择节点</div>;
|
||
|
||
return (
|
||
<div className="space-y-4 py-2">
|
||
<div className="flex gap-2 px-1">
|
||
<button
|
||
className={`flex-1 rounded border px-2 py-1 text-xs ${activeSubTab === "icon" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||
onClick={() => setActiveSubTab("icon")}
|
||
>
|
||
图标
|
||
</button>
|
||
<button
|
||
className={`flex-1 rounded border px-2 py-1 text-xs ${activeSubTab === "sticker" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||
onClick={() => setActiveSubTab("sticker")}
|
||
>
|
||
贴纸
|
||
</button>
|
||
</div>
|
||
|
||
{activeSubTab === "icon" && iconGroups.map((group) => (
|
||
<div key={group.type}>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<Label className="text-xs font-bold text-gray-600">{group.name}</Label>
|
||
<button onClick={() => removeIcon(group.type)} className="text-[10px] text-blue-500 hover:underline">
|
||
清除
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{group.list.map((item: any) => (
|
||
<button
|
||
key={`${group.type}-${item.name}`}
|
||
onClick={() => addIcon(group.type, item.name)}
|
||
className="flex h-9 w-9 items-center justify-center rounded border border-gray-200 bg-white text-xs hover:border-blue-400 hover:shadow-sm overflow-hidden"
|
||
>
|
||
{item.icon ? (
|
||
typeof item.icon === "string" && item.icon.trim().startsWith("<svg") ? (
|
||
<span
|
||
className="inline-flex h-6 w-6 items-center justify-center overflow-hidden"
|
||
dangerouslySetInnerHTML={{ __html: item.icon }}
|
||
/>
|
||
) : (
|
||
<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>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{activeSubTab === "sticker" && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between px-1">
|
||
<Label className="text-xs font-bold text-gray-600">贴纸</Label>
|
||
<button onClick={clearSticker} className="text-[10px] text-blue-500 hover:underline">清除</button>
|
||
</div>
|
||
{stickerGroups.map((group: any) => (
|
||
<div key={group.name}>
|
||
<div className="mb-2 text-xs font-semibold text-gray-600">{group.name}</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{group.list.map((item: any, idx: number) => (
|
||
<button
|
||
key={`${group.name}-${idx}`}
|
||
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"
|
||
>
|
||
<Image src={item.url} alt={group.name} width={56} height={56} className="h-14 w-14 object-contain" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
|
||
const [depth, setDepth] = useState<number>(-1);
|
||
|
||
const nodes = useMemo(() => {
|
||
const root = mindmap?.renderer?.renderTree?._node;
|
||
const list: { node: any; text: string; depth: number }[] = [];
|
||
const walk = (n: any, d: number) => {
|
||
if (!n) return;
|
||
const raw =
|
||
n.nodeData?.data?.text ||
|
||
n.nodeData?.text ||
|
||
n.data?.text ||
|
||
n.data?.data?.text ||
|
||
"未命名";
|
||
const text =
|
||
typeof raw === "string"
|
||
? raw.replace(/<[^>]+>/g, "").trim() || "未命名"
|
||
: "未命名";
|
||
list.push({ node: n, text, depth: d });
|
||
(n.children || []).forEach((c: any) => walk(c, d + 1));
|
||
};
|
||
if (root) walk(root, 0);
|
||
return list;
|
||
}, [mindmap]);
|
||
|
||
const visibleNodes = useMemo(
|
||
() => nodes.filter((item) => depth < 0 || item.depth < depth),
|
||
[nodes, depth],
|
||
);
|
||
|
||
const activate = (node: any) => {
|
||
const r = mindmap?.renderer;
|
||
if (!node || !r) return;
|
||
// 仅通过已有方法触发激活,避免直接改 renderer 属性
|
||
if (typeof r.clearActiveNodeList === "function") {
|
||
r.clearActiveNodeList();
|
||
}
|
||
if (typeof r.addNodeToActiveList === "function") {
|
||
r.addNodeToActiveList(node, true);
|
||
} else {
|
||
// 兜底:仍保留最小副作用写入
|
||
try {
|
||
r?.setActiveNode?.(node);
|
||
} catch {
|
||
// 最后兜底:不再直接改引用,避免 lint 报错
|
||
}
|
||
}
|
||
mindmap.emit?.("node_active", node, [node]);
|
||
r.setRootNodeCenter?.();
|
||
};
|
||
|
||
if (!nodes.length) return <div className="p-4 text-sm text-gray-400">暂无节点</div>;
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label className="text-xs text-gray-500">显示层级</Label>
|
||
<NativeSelect
|
||
value={depth}
|
||
options={outlineDepthOptions.map((o) => ({ label: o.name, value: o.value }))}
|
||
onChange={(v) => setDepth(Number(v))}
|
||
/>
|
||
</div>
|
||
<div className="max-h-[70vh] space-y-1 overflow-auto pr-2">
|
||
{visibleNodes.map((item, idx) => (
|
||
<button
|
||
key={`${item.text}-${idx}`}
|
||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left text-sm hover:bg-gray-50"
|
||
onClick={() => activate(item.node)}
|
||
>
|
||
<span className="text-gray-300" style={{ paddingLeft: item.depth * 12 }}>
|
||
{"•".repeat(Math.max(1, item.depth + 1))}
|
||
</span>
|
||
<span className="text-gray-700">{item.text}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SettingsPanel = ({ mindmap }: { mindmap: any }) => {
|
||
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,
|
||
},
|
||
});
|
||
|
||
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 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-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-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={updateAi}>
|
||
{aiOn ? "开" : "关"}
|
||
</Toggle>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const FormulaPanel = ({ mindmap }: { mindmap: any }) => {
|
||
const [formula, setFormula] = useState("a^2+b^2=c^2");
|
||
const insert = () => {
|
||
const active = mindmap?.renderer?.activeNodeList || [];
|
||
mindmap?.execCommand?.("INSERT_FORMULA", formula, active);
|
||
};
|
||
return (
|
||
<div className="space-y-3">
|
||
<Label className="text-xs text-gray-500">LaTeX 公式</Label>
|
||
<Input value={formula} onChange={(e) => setFormula(e.target.value)} />
|
||
<button className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600" onClick={insert}>
|
||
插入公式
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
|
||
const [note, setNote] = useState("");
|
||
|
||
const readNote = (node: any) => {
|
||
try {
|
||
return (
|
||
node?.getData?.("note") ??
|
||
node?.nodeData?.data?.note ??
|
||
node?.data?.note ??
|
||
""
|
||
);
|
||
} catch {
|
||
return "";
|
||
}
|
||
};
|
||
|
||
// 当选中节点变化时,自动展示首个节点的备注
|
||
useEffect(() => {
|
||
if (!activeNodes?.length) {
|
||
Promise.resolve().then(() => setNote(""));
|
||
return;
|
||
}
|
||
const current = readNote(activeNodes[0]);
|
||
Promise.resolve().then(() =>
|
||
setNote(typeof current === "string" ? current : ""),
|
||
);
|
||
}, [activeNodes]);
|
||
|
||
const getActiveList = () =>
|
||
mindmap?.renderer?.activeNodeList?.length
|
||
? mindmap.renderer.activeNodeList
|
||
: activeNodes || [];
|
||
|
||
const apply = () => {
|
||
const list = getActiveList();
|
||
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, note));
|
||
};
|
||
|
||
const clearNote = () => {
|
||
const list = getActiveList();
|
||
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, ""));
|
||
setNote("");
|
||
};
|
||
|
||
if (!activeNodes?.length) {
|
||
return <div className="p-4 text-sm text-gray-400">请选择节点后编辑备注</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<Label className="text-xs text-gray-500">节点备注(自动载入首个选中节点)</Label>
|
||
<textarea
|
||
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
||
rows={38}
|
||
value={note}
|
||
onChange={(e) => setNote(e.target.value)}
|
||
placeholder="输入备注内容"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<button
|
||
className="flex-1 rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600"
|
||
onClick={apply}
|
||
>
|
||
保存 / 更新
|
||
</button>
|
||
<button
|
||
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||
onClick={clearNote}
|
||
>
|
||
清除备注
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
type AiMode = "chat" | "full" | "partial";
|
||
|
||
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");
|
||
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);
|
||
|
||
// 文档驱动:从 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("");
|
||
};
|
||
|
||
const stop = () => {
|
||
controllerRef.current?.abort();
|
||
controllerRef.current = null;
|
||
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();
|
||
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)) {
|
||
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">
|
||
<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>
|
||
|
||
{MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED ? (
|
||
<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>
|
||
) : null}
|
||
|
||
<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" },
|
||
]}
|
||
/>
|
||
<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>
|
||
);
|
||
};
|
||
|
||
export const MindmapSidebar = ({
|
||
documentId,
|
||
mindmapId,
|
||
mindmap,
|
||
activeNodes,
|
||
activeTab,
|
||
onClose,
|
||
}: SidebarProps) => {
|
||
const content = useMemo(() => {
|
||
switch (activeTab as SidebarPanel | null) {
|
||
case "style":
|
||
return <StylePanel activeNodes={activeNodes} />;
|
||
case "base":
|
||
return <BaseStylePanel mindmap={mindmap} />;
|
||
case "theme":
|
||
return <ThemePanel mindmap={mindmap} />;
|
||
case "structure":
|
||
return <StructurePanel mindmap={mindmap} />;
|
||
case "icons":
|
||
return <IconPanel activeNodes={activeNodes} />;
|
||
case "outline":
|
||
return <OutlinePanel mindmap={mindmap} />;
|
||
case "settings":
|
||
return <SettingsPanel mindmap={mindmap} />;
|
||
case "formula":
|
||
return <FormulaPanel mindmap={mindmap} />;
|
||
case "note":
|
||
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
|
||
case "ai":
|
||
return (
|
||
<MindmapAiAgentPanel
|
||
mindmap={mindmap}
|
||
activeNodes={activeNodes}
|
||
documentId={documentId}
|
||
mindmapId={mindmapId}
|
||
onClose={onClose}
|
||
/>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
}, [activeTab, mindmap, activeNodes, documentId, mindmapId, onClose]);
|
||
|
||
const title = useMemo(() => {
|
||
if (!activeTab) return "";
|
||
return sidebarTitles[activeTab as SidebarPanel] ?? "";
|
||
}, [activeTab]);
|
||
|
||
return (
|
||
<div
|
||
className={`absolute top-0 bottom-0 right-0 z-20 w-[300px] flex flex-col border-l border-gray-200 bg-white transition-transform duration-300 ease-in-out shadow-lg ${
|
||
activeTab ? "translate-x-0" : "translate-x-full"
|
||
}`}
|
||
>
|
||
{activeTab === "ai" ? null : (
|
||
<div className="flex items-center justify-between border-b px-4 py-3 shrink-0">
|
||
<span className="font-medium text-gray-700">{title}</span>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className={activeTab === "ai" ? "flex-1 overflow-hidden p-0" : "flex-1 overflow-y-auto px-4 py-4"}>
|
||
{content}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|