feat(复习): 节点选择交互 + 移除 session 维度

- MindMapViewer 新增 selectable 模式,点击节点 emit node-select 事件
- ReviewRecall.vue 移除会话选择器,改为节点选择交互
- 支持 URL ?focusPath= 参数自动预设复习起点
- 节点选择弹窗确认后回忆导图根节点=选中节点标题
- ReviewDetail 和 Welcome 中调用 findNode API 匹配节点后跳转
- API: recallCompare 使用 focusPath 替代 sessionNum
- API: 新增 findNode 端点
This commit is contained in:
2026-07-05 18:10:42 +08:00
parent 5a5a294f06
commit 84fe452e80
6 changed files with 150 additions and 54 deletions
+37 -4
View File
@@ -25,6 +25,10 @@ const props = defineProps<{
colorByCompare?: boolean;
/** 画布高度,如 "520px"、"60vh",默认 480px */
height?: string;
/** 节点可选择模式(点击节点触发 node-select 事件) */
selectable?: boolean;
/** 当前选中的节点路径(用于高亮,仅在 selectable 模式下生效) */
selectedPath?: string;
}>();
const emit = defineEmits<{
@@ -32,11 +36,27 @@ 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" };
@@ -48,15 +68,20 @@ const compareStatus = (notes?: string): "MATCHED" | "MISSED" | null => {
return null;
};
const toElixirNode = (node: MindMapTreeNode): any => {
const toElixirNode = (node: MindMapTreeNode, ancestors: string[] = []): any => {
nodeCounter += 1;
const elixirId = `n${nodeCounter}`;
const result: any = {
id: `n${nodeCounter}`,
id: elixirId,
topic: node.title || "(未命名)",
children: (node.children || []).map(toElixirNode),
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 = ""; // 占位,溯源通过 dangerouslySetInnerHTML 外的 tags 展示
result.hyperLink = "";
result.tags = [node.sourceType === "REPORT" ? "报告" : node.sourceType === "FRAGMENT" ? "残片" : "应用"];
result.sourceType = node.sourceType;
result.sourceId = node.sourceId;
@@ -104,6 +129,7 @@ const render = () => {
instance.destroy();
instance = null;
}
nodePathMap = {};
instance = new MindElixir({
el: container.value,
direction: MindElixir.SIDE,
@@ -122,6 +148,13 @@ const render = () => {
}
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) });
}