237 lines
7.4 KiB
Vue
237 lines
7.4 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
import MindElixir, { type MindElixirData, type MindElixirInstance } from "mind-elixir";
|
|
import "mind-elixir/style.css";
|
|
|
|
/**
|
|
* mind-elixir 封装:展示/编辑思维导图。
|
|
* 节点着色约定(对比模式):绿=回忆命中,红=遗漏,蓝=额外。
|
|
*/
|
|
|
|
export interface MindMapTreeNode {
|
|
title: string;
|
|
notes?: string;
|
|
sourceType?: string;
|
|
sourceId?: number;
|
|
children?: MindMapTreeNode[];
|
|
}
|
|
|
|
const props = defineProps<{
|
|
/** 树数据(根节点) */
|
|
tree: MindMapTreeNode | null;
|
|
/** 是否可编辑 */
|
|
editable?: boolean;
|
|
/** 是否按 MATCHED/MISSED 标记着色 */
|
|
colorByCompare?: boolean;
|
|
/** 画布高度,如 "520px"、"60vh",默认 480px */
|
|
height?: string;
|
|
/** 节点可选择模式(点击节点触发 node-select 事件) */
|
|
selectable?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
/** 点击带溯源信息的节点 */
|
|
(e: "node-click", node: { sourceType: string; sourceId: number }): void;
|
|
/** 选择模式下点击节点,返回标题+路径 */
|
|
(e: "node-select", node: { title: string; path: string; childCount: number }): void;
|
|
}>();
|
|
|
|
const container = ref<HTMLElement | null>(null);
|
|
let instance: MindElixirInstance | null = null;
|
|
let nodeCounter = 0;
|
|
/** 节点路径映射:elixir node id → { title, path, childCount } */
|
|
let nodePathMap: Record<string, { title: string; path: string; childCount: number }> = {};
|
|
|
|
/** 递归构建节点路径映射 */
|
|
const buildPathMap = (node: MindMapTreeNode, ancestors: string[]): { title: string; path: string; childCount: number } => {
|
|
const title = node.title || "(未命名)";
|
|
const path = [...ancestors, title].join(" / ");
|
|
const childCount = (node.children || []).reduce((sum, c) => sum + 1 + countAllDescendants(c), 0);
|
|
return { title, path, childCount };
|
|
};
|
|
|
|
const countAllDescendants = (node: MindMapTreeNode): number => {
|
|
return (node.children || []).reduce((sum, c) => sum + 1 + countAllDescendants(c), 0);
|
|
};
|
|
|
|
const COLOR_MATCHED = { background: "#c8e6c9", color: "#1b5e20" };
|
|
const COLOR_MISSED = { background: "#ffcdd2", color: "#b71c1c" };
|
|
|
|
/**
|
|
* 浅色主题:基于内置 Latte,画布/节点配色对齐全站薄荷绿主题(base.css 变量值)。
|
|
* 注意:mind-elixir 主题只接受字面量,不能引用 CSS 变量,此处保留字面量。
|
|
*/
|
|
const LPT_LIGHT_THEME = {
|
|
...MindElixir.THEME,
|
|
name: "LPT Light",
|
|
palette: ["#237556", "#2f8f68", "#43a879", "#1a5a42", "#5cb88a", "#7cc9a3"],
|
|
cssVar: {
|
|
...MindElixir.THEME.cssVar,
|
|
"--bgcolor": "#f7fbf8",
|
|
"--color": "#4b6156",
|
|
"--root-bgcolor": "#237556",
|
|
"--root-color": "#ffffff",
|
|
"--main-color": "#1b2a23",
|
|
"--main-bgcolor": "#ffffff",
|
|
"--main-bgcolor-transparent": "rgba(255, 255, 255, 0.85)",
|
|
"--selected": "#43a879",
|
|
"--accent-color": "#237556",
|
|
"--panel-color": "#1b2a23",
|
|
"--panel-bgcolor": "#ffffff",
|
|
"--panel-border-color": "#d6e6dc",
|
|
},
|
|
};
|
|
|
|
const compareStatus = (notes?: string): "MATCHED" | "MISSED" | null => {
|
|
if (!notes) return null;
|
|
if (notes.startsWith("MATCHED|")) return "MATCHED";
|
|
if (notes.startsWith("MISSED|")) return "MISSED";
|
|
return null;
|
|
};
|
|
|
|
const toElixirNode = (node: MindMapTreeNode, ancestors: string[] = []): any => {
|
|
nodeCounter += 1;
|
|
const elixirId = `n${nodeCounter}`;
|
|
const result: any = {
|
|
id: elixirId,
|
|
topic: node.title || "(未命名)",
|
|
children: (node.children || []).map((c) => toElixirNode(c, [...ancestors, node.title || "(未命名)"])),
|
|
};
|
|
// 记录路径映射
|
|
if (props.selectable) {
|
|
nodePathMap[elixirId] = buildPathMap(node, ancestors);
|
|
}
|
|
if (node.sourceType && node.sourceId != null) {
|
|
result.hyperLink = "";
|
|
result.tags = [node.sourceType === "REPORT" ? "报告" : node.sourceType === "FRAGMENT" ? "残片" : "应用"];
|
|
result.sourceType = node.sourceType;
|
|
result.sourceId = node.sourceId;
|
|
}
|
|
if (props.colorByCompare) {
|
|
const status = compareStatus(node.notes);
|
|
if (status === "MATCHED") {
|
|
result.style = COLOR_MATCHED;
|
|
} else if (status === "MISSED") {
|
|
result.style = COLOR_MISSED;
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const toElixirData = (tree: MindMapTreeNode): MindElixirData => {
|
|
nodeCounter = 0;
|
|
const rootTitle = tree.title || "思维导图";
|
|
// register root node in path map so clicking it also works
|
|
if (props.selectable) {
|
|
nodePathMap["root"] = {
|
|
title: rootTitle,
|
|
path: rootTitle,
|
|
childCount: (tree.children || []).reduce(
|
|
(sum, c) => sum + 1 + countAllDescendants(c),
|
|
0,
|
|
),
|
|
};
|
|
}
|
|
return {
|
|
nodeData: {
|
|
id: "root",
|
|
topic: rootTitle,
|
|
// pass root title as ancestor so child paths include it (e.g. "根 / 子节点")
|
|
children: (tree.children || []).map((c) =>
|
|
toElixirNode(c, [rootTitle]),
|
|
),
|
|
},
|
|
} as MindElixirData;
|
|
};
|
|
|
|
/** 从 mind-elixir 数据导出缩进大纲文本(与后端 MindMapTreeTool.parseOutline 格式一致) */
|
|
const toOutline = (): string => {
|
|
if (!instance) return "";
|
|
const data = instance.getData();
|
|
const lines: string[] = [data.nodeData.topic || "思维导图"];
|
|
const walk = (node: any, level: number) => {
|
|
lines.push(`${" ".repeat(level)}- ${node.topic || ""}`);
|
|
(node.children || []).forEach((c: any) => walk(c, level + 1));
|
|
};
|
|
(data.nodeData.children || []).forEach((c: any) => walk(c, 1));
|
|
return lines.join("\n");
|
|
};
|
|
|
|
defineExpose({ toOutline });
|
|
|
|
const render = () => {
|
|
if (!container.value || !props.tree) return;
|
|
if (instance) {
|
|
instance.destroy();
|
|
instance = null;
|
|
}
|
|
nodePathMap = {};
|
|
instance = new MindElixir({
|
|
el: container.value,
|
|
direction: MindElixir.SIDE,
|
|
draggable: !!props.editable,
|
|
editable: !!props.editable,
|
|
contextMenu: !!props.editable,
|
|
toolBar: true,
|
|
keypress: !!props.editable,
|
|
theme: LPT_LIGHT_THEME,
|
|
});
|
|
instance.init(toElixirData(props.tree));
|
|
|
|
// mind-elixir v5: selectNode(el) is called on every node click, but the
|
|
// selectNewNode event is never fired (it requires selectNode(el, true),
|
|
// which the default click handler never passes). Monkey-patch selectNode
|
|
// to hook into node clicks for both selectable and source-navigation modes.
|
|
const origSelectNode = instance.selectNode.bind(instance);
|
|
instance.selectNode = function (el: any, fireEvent?: boolean) {
|
|
origSelectNode(el, fireEvent);
|
|
if (!el?.nodeObj) return;
|
|
const nodeData = el.nodeObj;
|
|
// selectable mode → emit node-select with title/path/childCount
|
|
if (props.selectable && nodeData?.id) {
|
|
const info = nodePathMap[nodeData.id];
|
|
if (info) {
|
|
emit("node-select", info);
|
|
return;
|
|
}
|
|
}
|
|
// source navigation → emit node-click with sourceType/sourceId
|
|
if (nodeData?.sourceType && nodeData?.sourceId != null) {
|
|
emit("node-click", {
|
|
sourceType: String(nodeData.sourceType),
|
|
sourceId: Number(nodeData.sourceId),
|
|
});
|
|
}
|
|
};
|
|
};
|
|
|
|
watch(
|
|
() => [props.tree, props.editable, props.colorByCompare],
|
|
() => render(),
|
|
{ deep: false },
|
|
);
|
|
|
|
onMounted(() => render());
|
|
|
|
onBeforeUnmount(() => {
|
|
if (instance) {
|
|
instance.destroy();
|
|
instance = null;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="mind-map-viewer" ref="container" :style="{ height: height || '480px' }"></div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mind-map-viewer {
|
|
width: 100%;
|
|
border: 1px solid var(--border-soft, #e0e0e0);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background: #fbfefc;
|
|
}
|
|
</style>
|