feat(复习):mind-elixir 思维导图可视化
- MindMapViewer 组件封装 mind-elixir:展示/编辑/对比着色三种形态 - 对比结果以导图呈现:绿=命中、红=遗漏,点击带标签节点回看原文 - 标准导图展开后以导图渲染,编辑改为图上直接操作(Tab/Enter/双击) - 保存时从导图导出缩进大纲,与后端 MindMapTreeTool 格式一致
This commit is contained in:
@@ -0,0 +1,158 @@
|
|||||||
|
<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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 编辑模式下结构变化时抛出最新大纲文本 */
|
||||||
|
(e: "change", outline: string): void;
|
||||||
|
/** 点击带溯源信息的节点 */
|
||||||
|
(e: "node-click", node: { sourceType: string; sourceId: number }): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null);
|
||||||
|
let instance: MindElixirInstance | null = null;
|
||||||
|
let nodeCounter = 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): any => {
|
||||||
|
nodeCounter += 1;
|
||||||
|
const result: any = {
|
||||||
|
id: `n${nodeCounter}`,
|
||||||
|
topic: node.title || "(未命名)",
|
||||||
|
children: (node.children || []).map(toElixirNode),
|
||||||
|
};
|
||||||
|
if (node.sourceType && node.sourceId != null) {
|
||||||
|
result.hyperLink = ""; // 占位,溯源通过 dangerouslySetInnerHTML 外的 tags 展示
|
||||||
|
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(toElixirNode),
|
||||||
|
},
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
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("selectNode", (node: any) => {
|
||||||
|
if (node?.sourceType && node?.sourceId != null) {
|
||||||
|
emit("node-click", { sourceType: node.sourceType, sourceId: 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"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mind-map-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 480px;
|
||||||
|
border: 1px solid var(--border-soft, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fbfefc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type RecallRecord,
|
type RecallRecord,
|
||||||
} from "@/api/standardMindMap";
|
} from "@/api/standardMindMap";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import MindMapViewer, { type MindMapTreeNode } from "@/components/MindMapViewer.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,11 +31,34 @@ const standardExpanded = ref(false);
|
|||||||
const editingStandard = ref(false);
|
const editingStandard = ref(false);
|
||||||
const editOutline = ref("");
|
const editOutline = ref("");
|
||||||
const savingStandard = ref(false);
|
const savingStandard = ref(false);
|
||||||
|
const editViewer = ref<InstanceType<typeof MindMapViewer> | null>(null);
|
||||||
|
|
||||||
// 回忆历史
|
// 回忆历史
|
||||||
const historyRecords = ref<RecallRecord[]>([]);
|
const historyRecords = ref<RecallRecord[]>([]);
|
||||||
const showHistory = ref(false);
|
const showHistory = ref(false);
|
||||||
|
|
||||||
|
// 解析标准导图 JSON 为树节点
|
||||||
|
const standardTree = computed<MindMapTreeNode | null>(() => {
|
||||||
|
if (!standard.value?.content) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(standard.value.content) as MindMapTreeNode;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 对比结果树(带 MATCHED/MISSED 标注)
|
||||||
|
const compareTree = computed<MindMapTreeNode | null>(() => {
|
||||||
|
return (lastResult.value?.matchedTree as MindMapTreeNode) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击着色节点跳转原文
|
||||||
|
const goToNodeSource = (node: { sourceType: string; sourceId: number }) => {
|
||||||
|
if (node.sourceType === "REPORT" || node.sourceType === "FRAGMENT") {
|
||||||
|
router.push(`/review/detail/${node.sourceType.toLowerCase()}/${node.sourceId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 格式化覆盖率
|
// 格式化覆盖率
|
||||||
const ratioPercent = computed(() => {
|
const ratioPercent = computed(() => {
|
||||||
if (!lastResult.value?.recallRatio) return "0%";
|
if (!lastResult.value?.recallRatio) return "0%";
|
||||||
@@ -130,6 +154,17 @@ const saveStandardEdit = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 从导图编辑器导出大纲并保存
|
||||||
|
const saveStandardEditFromViewer = async () => {
|
||||||
|
const outline = editViewer.value?.toOutline() || "";
|
||||||
|
if (!outline.trim()) {
|
||||||
|
ElMessage.warning("导图内容不可为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editOutline.value = outline;
|
||||||
|
await saveStandardEdit();
|
||||||
|
};
|
||||||
|
|
||||||
const cancelStandardEdit = () => {
|
const cancelStandardEdit = () => {
|
||||||
editingStandard.value = false;
|
editingStandard.value = false;
|
||||||
editOutline.value = "";
|
editOutline.value = "";
|
||||||
@@ -297,9 +332,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 对比结果 -->
|
<!-- 对比结果 -->
|
||||||
<div v-if="hasCompared && resultText" class="compare-result">
|
<div v-if="hasCompared && compareTree" class="compare-result">
|
||||||
<h4>📊 对比结果</h4>
|
<h4>📊 对比结果</h4>
|
||||||
<pre class="compare-tree">{{ resultText }}</pre>
|
<p class="panel-hint">绿色=回忆命中,红色=遗漏。点击带标签的节点可回看原文。</p>
|
||||||
|
<MindMapViewer
|
||||||
|
:tree="compareTree"
|
||||||
|
:color-by-compare="true"
|
||||||
|
@node-click="goToNodeSource"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -339,12 +379,11 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="editingStandard" class="edit-area">
|
<div v-else-if="editingStandard" class="edit-area">
|
||||||
<p class="panel-hint">编辑大纲文本,保存后标准导图将被更新。</p>
|
<p class="panel-hint">直接在导图上编辑(Tab 加子节点 / Enter 加同级 / 双击改文字),保存后生效。</p>
|
||||||
<el-input v-model="editOutline" type="textarea" :rows="12" />
|
<MindMapViewer ref="editViewer" :tree="standardTree" :editable="true" />
|
||||||
<p v-if="standard.summary" class="generator-info">{{ standard.summary }}</p>
|
|
||||||
<div class="edit-actions">
|
<div class="edit-actions">
|
||||||
<el-button @click="cancelStandardEdit">取消</el-button>
|
<el-button @click="cancelStandardEdit">取消</el-button>
|
||||||
<el-button type="primary" :loading="savingStandard" @click="saveStandardEdit">
|
<el-button type="primary" :loading="savingStandard" @click="saveStandardEditFromViewer">
|
||||||
保存更新
|
保存更新
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,7 +394,8 @@ onMounted(() => {
|
|||||||
<span>来源:{{ standard.generator }}</span>
|
<span>来源:{{ standard.generator }}</span>
|
||||||
<span>参考:{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span>
|
<span>参考:{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span>
|
||||||
</div>
|
</div>
|
||||||
<pre class="standard-outline">{{ standard.outline || standard.content }}</pre>
|
<MindMapViewer v-if="standardTree" :tree="standardTree" @node-click="goToNodeSource" />
|
||||||
|
<pre v-else class="standard-outline">{{ standard.outline || standard.content }}</pre>
|
||||||
<p v-if="standard.summary" class="generator-info">{{ standard.summary }}</p>
|
<p v-if="standard.summary" class="generator-info">{{ standard.summary }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user