Files
lpt-fe/src/components/MindMapViewer.vue
T
cat-shark ebbbeb4c85 fix(导图): Array.map 传递多参数导致 ancestors 默认值失效
.map(toElixirNode) 传递 (item, index, array) 三个参数,
index 作为数字覆盖 ancestors 默认值 [],导致 [...ancestors] 报错。
改为箭头函数只传第一个参数。
2026-07-05 18:22:36 +08:00

193 lines
5.8 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;
/** 当前选中的节点路径(用于高亮,仅在 selectable 模式下生效) */
selectedPath?: string;
}>();
const emit = defineEmits<{
/** 编辑模式下结构变化时抛出最新大纲文本 */
(e: "change", outline: string): void;
/** 点击带溯源信息的节点 */
(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" };
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;
return {
nodeData: {
id: "root",
topic: tree.title || "思维导图",
children: (tree.children || []).map(c => toElixirNode(c)),
},
} 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,
});
instance.init(toElixirData(props.tree));
if (props.editable) {
instance.bus.addListener("operation", () => {
emit("change", toOutline());
});
}
instance.bus.addListener("selectNewNode", (node: any) => {
if (props.selectable && node?.id) {
const info = nodePathMap[node.id];
if (info) {
emit("node-select", info);
return;
}
}
if (node?.sourceType && node?.sourceId != null) {
emit("node-click", { sourceType: String(node.sourceType), sourceId: Number(node.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>