2025-12-28 22:05:37 +08:00
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
|
|
import React, { useEffect, useMemo, useState } from "react";
|
2026-01-02 07:25:50 +08:00
|
|
|
|
import Image from "next/image";
|
2025-12-28 22:05:37 +08:00
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Toggle } from "@/components/ui/toggle";
|
2026-01-02 07:25:50 +08:00
|
|
|
|
import { Bold, Italic, Underline, Type, Strikethrough, Palette, X, Network } from "lucide-react";
|
2025-12-28 22:05:37 +08:00
|
|
|
|
import {
|
|
|
|
|
|
fontFamilyList,
|
|
|
|
|
|
fontSizeList,
|
|
|
|
|
|
lineHeightList,
|
|
|
|
|
|
borderDasharrayList,
|
|
|
|
|
|
borderRadiusList,
|
|
|
|
|
|
borderWidthList,
|
|
|
|
|
|
lineWidthList,
|
|
|
|
|
|
shapeList,
|
|
|
|
|
|
lineStyleList,
|
|
|
|
|
|
themeList,
|
|
|
|
|
|
layoutList,
|
|
|
|
|
|
outlineDepthOptions,
|
2025-12-31 19:26:38 +08:00
|
|
|
|
backgroundRepeatList,
|
|
|
|
|
|
backgroundPositionList,
|
|
|
|
|
|
backgroundSizeList,
|
2025-12-28 22:05:37 +08:00
|
|
|
|
} from "./mindmapOptions";
|
2025-12-30 18:39:38 +08:00
|
|
|
|
import iconConfig from "./mindmapIconConfig";
|
|
|
|
|
|
import imageConfig from "./mindmapImageConfig";
|
2026-01-10 10:35:21 +08:00
|
|
|
|
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
|
2026-01-08 06:28:14 +08:00
|
|
|
|
import type { MindMapNode } from "./mindmapTypes";
|
2026-01-10 10:35:21 +08:00
|
|
|
|
import { MindmapAiAgentPanel } from "./MindmapAiAgentPanel";
|
2025-12-30 18:39:38 +08:00
|
|
|
|
// 避免 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 };
|
|
|
|
|
|
};
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
|
|
|
|
|
type SidebarProps = {
|
2026-01-10 10:35:21 +08:00
|
|
|
|
documentId: string;
|
|
|
|
|
|
mindmapId: string;
|
2025-12-28 22:05:37 +08:00
|
|
|
|
mindmap: any;
|
|
|
|
|
|
activeNodes: MindMapNode[];
|
|
|
|
|
|
activeTab: SidebarPanel | null;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-01-02 07:25:50 +08:00
|
|
|
|
"textDecoration", "borderWidth", "borderColor", "fillColor", "shape",
|
2025-12-28 22:05:37 +08:00
|
|
|
|
"lineColor", "lineWidth", "lineDasharray", "borderRadius", "borderDasharray"
|
|
|
|
|
|
].forEach((prop) => {
|
|
|
|
|
|
newStyle[prop] = node.getStyle(prop, false);
|
|
|
|
|
|
});
|
2026-01-02 07:25:50 +08:00
|
|
|
|
// 异步更新,避免同步 setState 抛 lint
|
|
|
|
|
|
Promise.resolve().then(() => setStyle(newStyle));
|
2025-12-28 22:05:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, [activeNodes]);
|
|
|
|
|
|
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const updateStyle = (prop: string, value: any) => {
|
|
|
|
|
|
setStyle((prev) => ({ ...prev, [prop]: value }));
|
|
|
|
|
|
activeNodes.forEach((node) => {
|
|
|
|
|
|
node.setStyle?.(prop, value);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
|
|
|
|
|
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",
|
2025-12-31 19:26:38 +08:00
|
|
|
|
"paddingX", "paddingY",
|
|
|
|
|
|
"backgroundImage", "backgroundRepeat", "backgroundPosition", "backgroundSize",
|
2025-12-28 22:05:37 +08:00
|
|
|
|
].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)}
|
|
|
|
|
|
/>
|
2025-12-31 19:26:38 +08:00
|
|
|
|
<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>
|
2025-12-28 22:05:37 +08:00
|
|
|
|
</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[] }) => {
|
2025-12-30 18:39:38 +08:00
|
|
|
|
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;
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
2025-12-30 18:39:38 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
const [stickerGroups] = useState(imageConfig);
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
2025-12-30 18:39:38 +08:00
|
|
|
|
const addIcon = (type: string, name: string) => {
|
|
|
|
|
|
const key = `${type}_${name}`;
|
|
|
|
|
|
activeNodes.forEach((node) => {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const rawIcons = node.getData("icon");
|
|
|
|
|
|
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
|
|
|
|
|
|
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
|
2025-12-30 18:39:38 +08:00
|
|
|
|
newIcons.push(key);
|
2026-01-08 06:28:14 +08:00
|
|
|
|
node.setIcon?.(newIcons);
|
2025-12-30 18:39:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
2025-12-30 18:39:38 +08:00
|
|
|
|
const removeIcon = (type: string) => {
|
|
|
|
|
|
activeNodes.forEach((node) => {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const rawIcons = node.getData("icon");
|
|
|
|
|
|
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
|
|
|
|
|
|
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
|
|
|
|
|
|
node.setIcon?.(newIcons);
|
2025-12-30 18:39:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
2025-12-30 18:39:38 +08:00
|
|
|
|
const setSticker = (img: { url: string; width?: number; height?: number }) => {
|
|
|
|
|
|
activeNodes.forEach((node) => {
|
|
|
|
|
|
// simple-mind-map 支持 setImage 接收对象,包含 url/width/height
|
2026-01-08 06:28:14 +08:00
|
|
|
|
node.setImage?.({
|
2025-12-30 18:39:38 +08:00
|
|
|
|
url: img.url,
|
|
|
|
|
|
width: img.width || 100,
|
|
|
|
|
|
height: img.height || 100,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const clearSticker = () => {
|
|
|
|
|
|
activeNodes.forEach((node) => {
|
|
|
|
|
|
// 传入 null 以清除贴纸
|
2026-01-08 06:28:14 +08:00
|
|
|
|
node.setImage?.(null);
|
2025-12-30 18:39:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
2026-01-08 06:28:14 +08:00
|
|
|
|
{group.list.map((item: any) => (
|
2025-12-30 18:39:38 +08:00
|
|
|
|
<button
|
|
|
|
|
|
key={`${group.type}-${item.name}`}
|
|
|
|
|
|
onClick={() => addIcon(group.type, item.name)}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
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"
|
2025-12-30 18:39:38 +08:00
|
|
|
|
>
|
|
|
|
|
|
{item.icon ? (
|
|
|
|
|
|
typeof item.icon === "string" && item.icon.trim().startsWith("<svg") ? (
|
|
|
|
|
|
<span
|
2025-12-31 19:26:38 +08:00
|
|
|
|
className="inline-flex h-6 w-6 items-center justify-center overflow-hidden"
|
2025-12-30 18:39:38 +08:00
|
|
|
|
dangerouslySetInnerHTML={{ __html: item.icon }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-01-02 07:25:50 +08:00
|
|
|
|
<Image src={item.icon} alt={item.name} width={24} height={24} className="h-6 w-6 object-contain" />
|
2025-12-30 18:39:38 +08:00
|
|
|
|
)
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-xs text-gray-700">{item.name}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
2025-12-28 22:05:37 +08:00
|
|
|
|
))}
|
2025-12-30 18:39:38 +08:00
|
|
|
|
</div>
|
2025-12-28 22:05:37 +08:00
|
|
|
|
</div>
|
2025-12-30 18:39:38 +08:00
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{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"
|
|
|
|
|
|
>
|
2026-01-02 07:25:50 +08:00
|
|
|
|
<Image src={item.url} alt={group.name} width={56} height={56} className="h-14 w-14 object-contain" />
|
2025-12-30 18:39:38 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
// 仅通过已有方法触发激活,避免直接改 renderer 属性
|
|
|
|
|
|
if (typeof r.clearActiveNodeList === "function") {
|
|
|
|
|
|
r.clearActiveNodeList();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof r.addNodeToActiveList === "function") {
|
|
|
|
|
|
r.addNodeToActiveList(node, true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 兜底:仍保留最小副作用写入
|
|
|
|
|
|
try {
|
|
|
|
|
|
r?.setActiveNode?.(node);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 最后兜底:不再直接改引用,避免 lint 报错
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
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 }) => {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
|
|
|
|
|
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 });
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-02 07:25:50 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
</div>
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2025-12-28 22:05:37 +08:00
|
|
|
|
</div>
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2025-12-28 22:05:37 +08:00
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<Label className="text-xs text-gray-500">AI 功能</Label>
|
2026-01-02 07:25:50 +08:00
|
|
|
|
<Toggle size="sm" pressed={aiOn} onPressedChange={updateAi}>
|
2025-12-28 22:05:37 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
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 "";
|
|
|
|
|
|
}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
2025-12-31 19:26:38 +08:00
|
|
|
|
|
|
|
|
|
|
// 当选中节点变化时,自动展示首个节点的备注
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!activeNodes?.length) {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
Promise.resolve().then(() => setNote(""));
|
2025-12-31 19:26:38 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const current = readNote(activeNodes[0]);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
Promise.resolve().then(() =>
|
|
|
|
|
|
setNote(typeof current === "string" ? current : ""),
|
|
|
|
|
|
);
|
2025-12-31 19:26:38 +08:00
|
|
|
|
}, [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>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 22:05:37 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
2025-12-31 19:26:38 +08:00
|
|
|
|
<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>
|
2025-12-28 22:05:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
type AiMode = "chat" | "full" | "partial";
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const AiPanel = ({
|
|
|
|
|
|
mindmap,
|
|
|
|
|
|
activeNodes,
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
mindmapId,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
mindmap: any;
|
|
|
|
|
|
activeNodes: MindMapNode[];
|
|
|
|
|
|
documentId: string;
|
|
|
|
|
|
mindmapId: string;
|
|
|
|
|
|
}) => {
|
2025-12-28 22:05:37 +08:00
|
|
|
|
const [prompt, setPrompt] = useState("");
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
// 文档驱动:从 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const resetStream = () => {
|
|
|
|
|
|
setStreamText("");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const stop = () => {
|
|
|
|
|
|
controllerRef.current?.abort();
|
|
|
|
|
|
controllerRef.current = null;
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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?.());
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-28 22:05:37 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
2026-01-10 10:35:21 +08:00
|
|
|
|
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<Label className="text-xs text-gray-500">文档导图(按大纲生成)</Label>
|
|
|
|
|
|
{docSource === "test" && (
|
|
|
|
|
|
<a
|
|
|
|
|
|
className="text-xs text-blue-600 hover:underline"
|
|
|
|
|
|
href={`/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testPdfName)}`}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
>
|
|
|
|
|
|
打开 PDF
|
|
|
|
|
|
</a>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`rounded-md border px-2 py-2 text-sm hover:bg-gray-50 ${docSource === "test" ? "border-blue-500 text-blue-600" : "border-gray-300 text-gray-700"}`}
|
|
|
|
|
|
onClick={() => setDocSource("test")}
|
|
|
|
|
|
>
|
|
|
|
|
|
测试 PDF
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`rounded-md border px-2 py-2 text-sm hover:bg-gray-50 ${docSource === "url" ? "border-blue-500 text-blue-600" : "border-gray-300 text-gray-700"}`}
|
|
|
|
|
|
onClick={() => setDocSource("url")}
|
|
|
|
|
|
>
|
|
|
|
|
|
URL / Signed URL
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{docSource === "test" ? (
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs text-gray-500">测试文件名(wolai-frontend/test)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={testPdfName}
|
|
|
|
|
|
onChange={(e) => setTestPdfName(e.target.value)}
|
|
|
|
|
|
placeholder="例如:卤化反应原理_1-9.pdf"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-xs text-gray-400">仅本地开发可用:用于快速验证“生成导图 + 节点跳页链接”。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs text-gray-500">PDF 地址</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={docUrl}
|
|
|
|
|
|
onChange={(e) => setDocUrl(e.target.value)}
|
|
|
|
|
|
placeholder="http://127.0.0.1:xxx/file.pdf 或 supabase signed url"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs text-gray-500">标题(可选)</Label>
|
|
|
|
|
|
<Input value={docTitle} onChange={(e) => setDocTitle(e.target.value)} placeholder="不填则使用“文档”" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-xs text-gray-400">出于安全考虑,当前仅允许本机或 supabase 域名。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs text-gray-500">优先模型</Label>
|
|
|
|
|
|
<NativeSelect
|
|
|
|
|
|
value={docPreferProvider}
|
|
|
|
|
|
onChange={(v) => setDocPreferProvider(v as "online" | "ollama" | "heuristic")}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ label: "在线 AI(默认)", value: "online" },
|
|
|
|
|
|
{ label: "本地 Ollama", value: "ollama" },
|
|
|
|
|
|
{ label: "规则兜底", value: "heuristic" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs text-gray-500">最大页数</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
max={200}
|
|
|
|
|
|
step={1}
|
|
|
|
|
|
value={docMaxPages}
|
|
|
|
|
|
onChange={(e) => setDocMaxPages(Number(e.target.value) || 1)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={docLoading || (docSource === "url" && !docUrl.trim())}
|
|
|
|
|
|
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
|
|
|
|
|
onClick={runDocOutlineToMindmap}
|
|
|
|
|
|
>
|
|
|
|
|
|
{docLoading ? "生成中..." : "从 PDF 生成导图(替换当前)"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2 rounded-md border border-gray-200 p-3">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<Label className="text-xs text-gray-500">AI 补完(选中节点)</Label>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Toggle
|
|
|
|
|
|
pressed={expandUseSearx}
|
|
|
|
|
|
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Network className="h-4 w-4 mr-1" />
|
|
|
|
|
|
联网
|
|
|
|
|
|
</Toggle>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="w-full rounded-md border border-gray-200 p-2 text-sm"
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={expandInstruction}
|
|
|
|
|
|
onChange={(e) => setExpandInstruction(e.target.value)}
|
|
|
|
|
|
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={expandLoading}
|
|
|
|
|
|
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
|
|
|
|
|
onClick={runExpandSelectedNode}
|
|
|
|
|
|
>
|
|
|
|
|
|
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
|
|
|
|
|
|
<p className="text-xs text-gray-400">
|
|
|
|
|
|
说明:服务端会用 SearxNG 检索证据 + 在线 AI 生成 ops,并自动落盘到当前 mindmap 文件中。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
<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" },
|
|
|
|
|
|
]}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
/>
|
2026-01-02 07:25:50 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
export const MindmapSidebar = ({
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
mindmapId,
|
|
|
|
|
|
mindmap,
|
|
|
|
|
|
activeNodes,
|
|
|
|
|
|
activeTab,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
}: SidebarProps) => {
|
2025-12-28 22:05:37 +08:00
|
|
|
|
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":
|
2025-12-31 19:26:38 +08:00
|
|
|
|
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
|
2025-12-28 22:05:37 +08:00
|
|
|
|
case "ai":
|
2026-01-10 10:35:21 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<MindmapAiAgentPanel
|
|
|
|
|
|
mindmap={mindmap}
|
|
|
|
|
|
activeNodes={activeNodes}
|
|
|
|
|
|
documentId={documentId}
|
|
|
|
|
|
mindmapId={mindmapId}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2025-12-28 22:05:37 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-01-10 10:35:21 +08:00
|
|
|
|
}, [activeTab, mindmap, activeNodes, documentId, mindmapId]);
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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="flex-1 overflow-y-auto px-4 py-4">
|
|
|
|
|
|
{content}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|