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
+15 -4
View File
@@ -23,7 +23,7 @@ export interface RecallRecord {
id: number; id: number;
taskNum: string; taskNum: string;
standardMapId: number; standardMapId: number;
sessionNum?: string; focusPath?: string;
recallContent: string; recallContent: string;
compareResult: string; compareResult: string;
recallRatio: number; recallRatio: number;
@@ -33,6 +33,12 @@ export interface RecallRecord {
createdTime: string; createdTime: string;
} }
export interface FindNodeResult {
path: string;
nodeTitle: string;
score: number;
}
/** 获取/自动生成标准思维导图 */ /** 获取/自动生成标准思维导图 */
export const getStandardMindMap = (taskNum: string) => { export const getStandardMindMap = (taskNum: string) => {
return request.get(`/review/standard-mind-map/${taskNum}`); return request.get(`/review/standard-mind-map/${taskNum}`);
@@ -48,13 +54,18 @@ export const updateStandardMindMap = (taskNum: string, outline: string) => {
return request.put(`/review/standard-mind-map/${taskNum}`, { outline }); return request.put(`/review/standard-mind-map/${taskNum}`, { outline });
}; };
/** 用户提交回忆大纲,与标准导图对比(可选指定会话维度 */ /** 用户提交回忆大纲,与标准导图对比(可选指定起始节点路径 */
export const recallCompare = (taskNum: string, recallOutline: string, sessionNum?: string) => { export const recallCompare = (taskNum: string, recallOutline: string, focusPath?: string) => {
const body: Record<string, any> = { recallOutline }; const body: Record<string, any> = { recallOutline };
if (sessionNum) body.sessionNum = sessionNum; if (focusPath) body.focusPath = focusPath;
return request.post(`/review/standard-mind-map/${taskNum}/recall`, body); return request.post(`/review/standard-mind-map/${taskNum}/recall`, body);
}; };
/** 在标准导图中查找与内容最匹配的节点 */
export const findNode = (taskNum: string, content: string) => {
return request.post(`/review/standard-mind-map/${taskNum}/find-node`, { content });
};
/** 获取该任务的所有回忆对比记录 */ /** 获取该任务的所有回忆对比记录 */
export const listRecallRecords = (taskNum: string) => { export const listRecallRecords = (taskNum: string) => {
return request.get(`/review/standard-mind-map/${taskNum}/recall-records`); return request.get(`/review/standard-mind-map/${taskNum}/recall-records`);
-5
View File
@@ -44,11 +44,6 @@ export const getActiveSession = (excludeTaskNum?: string) => {
return request.get('/study-sessions/active', params); return request.get('/study-sessions/active', params);
}; };
/** 获取任务的所有会话简要信息(复习维度选择用) */
export const getTaskSessions = (taskNum: string) => {
return request.get(`/study-sessions/tasks/${taskNum}/sessions`);
};
/** 分页查询任务的历史残片 */ /** 分页查询任务的历史残片 */
export const getTaskFragments = (taskNum: string, page: number, size: number, keyword?: string) => { export const getTaskFragments = (taskNum: string, page: number, size: number, keyword?: string) => {
const params: Record<string, any> = { page, size }; const params: Record<string, any> = { page, size };
+37 -4
View File
@@ -25,6 +25,10 @@ const props = defineProps<{
colorByCompare?: boolean; colorByCompare?: boolean;
/** 画布高度,如 "520px"、"60vh",默认 480px */ /** 画布高度,如 "520px"、"60vh",默认 480px */
height?: string; height?: string;
/** 节点可选择模式(点击节点触发 node-select 事件) */
selectable?: boolean;
/** 当前选中的节点路径(用于高亮,仅在 selectable 模式下生效) */
selectedPath?: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -32,11 +36,27 @@ const emit = defineEmits<{
(e: "change", outline: string): void; (e: "change", outline: string): void;
/** 点击带溯源信息的节点 */ /** 点击带溯源信息的节点 */
(e: "node-click", node: { sourceType: string; sourceId: number }): 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); const container = ref<HTMLElement | null>(null);
let instance: MindElixirInstance | null = null; let instance: MindElixirInstance | null = null;
let nodeCounter = 0; 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_MATCHED = { background: "#c8e6c9", color: "#1b5e20" };
const COLOR_MISSED = { background: "#ffcdd2", color: "#b71c1c" }; const COLOR_MISSED = { background: "#ffcdd2", color: "#b71c1c" };
@@ -48,15 +68,20 @@ const compareStatus = (notes?: string): "MATCHED" | "MISSED" | null => {
return null; return null;
}; };
const toElixirNode = (node: MindMapTreeNode): any => { const toElixirNode = (node: MindMapTreeNode, ancestors: string[] = []): any => {
nodeCounter += 1; nodeCounter += 1;
const elixirId = `n${nodeCounter}`;
const result: any = { const result: any = {
id: `n${nodeCounter}`, id: elixirId,
topic: node.title || "(未命名)", 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) { if (node.sourceType && node.sourceId != null) {
result.hyperLink = ""; // 占位,溯源通过 dangerouslySetInnerHTML 外的 tags 展示 result.hyperLink = "";
result.tags = [node.sourceType === "REPORT" ? "报告" : node.sourceType === "FRAGMENT" ? "残片" : "应用"]; result.tags = [node.sourceType === "REPORT" ? "报告" : node.sourceType === "FRAGMENT" ? "残片" : "应用"];
result.sourceType = node.sourceType; result.sourceType = node.sourceType;
result.sourceId = node.sourceId; result.sourceId = node.sourceId;
@@ -104,6 +129,7 @@ const render = () => {
instance.destroy(); instance.destroy();
instance = null; instance = null;
} }
nodePathMap = {};
instance = new MindElixir({ instance = new MindElixir({
el: container.value, el: container.value,
direction: MindElixir.SIDE, direction: MindElixir.SIDE,
@@ -122,6 +148,13 @@ const render = () => {
} }
instance.bus.addListener("selectNewNode", (node: any) => { 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) { if (node?.sourceType && node?.sourceId != null) {
emit("node-click", { sourceType: String(node.sourceType), sourceId: Number(node.sourceId) }); emit("node-click", { sourceType: String(node.sourceType), sourceId: Number(node.sourceId) });
} }
+21 -1
View File
@@ -12,12 +12,32 @@ import {
} from "@/api/review"; } from "@/api/review";
import { getSessionDetail } from "@/api/studySessions"; import { getSessionDetail } from "@/api/studySessions";
import { updateFragments } from "@/api/reportFragments"; import { updateFragments } from "@/api/reportFragments";
import { findNode } from "@/api/standardMindMap";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import MarkdownRenderer from "@/components/MarkdownRenderer.vue"; import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
/** 调用 findNode API 匹配标准导图中的最近节点,跳转到回忆页 */
const goToRecall = async () => {
if (!content.value || !content.value.trim()) {
router.push(`/review/recall/${taskNum.value}`);
return;
}
try {
const res = await findNode(taskNum.value, content.value.substring(0, 500));
const data = res?.data;
if (data?.path) {
router.push(`/review/recall/${taskNum.value}?focusPath=${encodeURIComponent(data.path)}`);
} else {
router.push(`/review/recall/${taskNum.value}`);
}
} catch {
router.push(`/review/recall/${taskNum.value}`);
}
};
const contentType = computed(() => route.params.type as string); const contentType = computed(() => route.params.type as string);
const contentId = computed(() => Number(route.params.id)); const contentId = computed(() => Number(route.params.id));
@@ -327,7 +347,7 @@ watch(
> >
<el-button type="success" :loading="mindMapUploading">上传导图文件</el-button> <el-button type="success" :loading="mindMapUploading">上传导图文件</el-button>
</el-upload> </el-upload>
<el-button type="success" size="small" @click="router.push(`/review/recall/${taskNum}`)"> <el-button type="success" size="small" @click="goToRecall">
回忆复习 回忆复习
</el-button> </el-button>
<el-tag v-if="mindMap?.fileFormat" size="small" effect="plain"> <el-tag v-if="mindMap?.fileFormat" size="small" effect="plain">
+53 -40
View File
@@ -7,12 +7,12 @@ import {
recallCompare, recallCompare,
updateStandardMindMap, updateStandardMindMap,
listRecallRecords, listRecallRecords,
findNode,
type StandardMindMap, type StandardMindMap,
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"; import MindMapViewer, { type MindMapTreeNode } from "@/components/MindMapViewer.vue";
import { getTaskSessions } from "@/api/studySessions";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -30,10 +30,10 @@ let regeneratingTimer: ReturnType<typeof setInterval> | null = null;
const lastResult = ref<any>(null); const lastResult = ref<any>(null);
const hasCompared = ref(false); const hasCompared = ref(false);
// 复习维度选择 // 起点节点选择
const sessions = ref<{ sessionNum: string; startTime: string; expectation: string; reportCount: number; fragmentCount: number }[]>([]); const focusPath = ref((route.query.focusPath as string) || "");
const selectedSession = ref(""); const focusConfirmVisible = ref(false);
const loadingSessions = ref(false); const selectedNodeInfo = ref<{ title: string; path: string; childCount: number } | null>(null);
// 回忆输入(思维导图形式) // 回忆输入(思维导图形式)
const recallTree = ref<MindMapTreeNode | null>(null); const recallTree = ref<MindMapTreeNode | null>(null);
@@ -192,7 +192,7 @@ const handleRecallCompare = async () => {
comparingSeconds.value = 0; comparingSeconds.value = 0;
comparingTimer = setInterval(() => { comparingSeconds.value++; }, 1000); comparingTimer = setInterval(() => { comparingSeconds.value++; }, 1000);
try { try {
const res = await recallCompare(taskNum.value, outline, selectedSession.value || undefined); const res = await recallCompare(taskNum.value, outline, focusPath.value || undefined);
standard.value = res?.data || null; standard.value = res?.data || null;
hasCompared.value = true; hasCompared.value = true;
// 解析对比结果 // 解析对比结果
@@ -318,29 +318,26 @@ const extraNodes = computed(() => {
return raw.map((item: any) => typeof item === "string" ? { title: item } : item); return raw.map((item: any) => typeof item === "string" ? { title: item } : item);
}); });
// 加载会话列表(复习维度选择器用 // 节点选择回调(selectable 模式下点击节点
const loadSessions = async () => { const handleNodeSelect = (node: { title: string; path: string; childCount: number }) => {
loadingSessions.value = true; selectedNodeInfo.value = node;
try { focusConfirmVisible.value = true;
const res = await getTaskSessions(taskNum.value);
sessions.value = res?.data || [];
} catch {
sessions.value = [];
} finally {
loadingSessions.value = false;
}
}; };
// 格式化会话标签 // 确认以选中节点为起点
const formatSessionLabel = (s: { sessionNum: string; startTime: string; expectation: string; reportCount: number; fragmentCount: number }) => { const confirmFocus = () => {
const date = s.startTime ? s.startTime.replace("T", " ").substring(0, 16) : s.sessionNum; if (selectedNodeInfo.value) {
const hint = s.expectation ? s.expectation.substring(0, 30) : ""; focusPath.value = selectedNodeInfo.value.path;
return `${date} ${hint ? "— " + hint : ""}`; recallTree.value = {
title: selectedNodeInfo.value.title,
children: [],
};
}
focusConfirmVisible.value = false;
}; };
onMounted(() => { onMounted(() => {
loadData(); loadData();
loadSessions();
}); });
</script> </script>
@@ -355,24 +352,25 @@ onMounted(() => {
</el-button> </el-button>
</div> </div>
<!-- 复习维度选择器 --> <!-- 起点节点指示 -->
<div class="review-scope surface-card" v-if="sessions.length > 0 || loadingSessions"> <div class="review-scope surface-card" v-if="focusPath">
<span class="scope-label">复习范围</span> <span class="scope-label">复习起点</span>
<el-select v-model="selectedSession" placeholder="选择复习范围" clearable size="small" :loading="loadingSessions"> <el-tag size="small" type="success" effect="light">{{ focusPath }}</el-tag>
<el-option label="全部任务" value="" /> <el-button size="small" text @click="focusPath = ''">重选节点</el-button>
<el-option
v-for="s in sessions"
:key="s.sessionNum"
:label="formatSessionLabel(s)"
:value="s.sessionNum"
/>
</el-select>
<el-tag v-if="selectedSession" size="small" type="success" effect="light">
已过滤仅匹配该会话知识点
</el-tag>
<span v-else-if="!selectedSession" class="scope-hint">不过滤对比全部标准导图</span>
</div> </div>
<!-- 节点选择确认弹窗 -->
<el-dialog v-model="focusConfirmVisible" title="确认复习起点" width="400px">
<p v-if="selectedNodeInfo">
<strong>{{ selectedNodeInfo.title }}</strong> 为起点
复习该知识点下的 <strong>{{ selectedNodeInfo.childCount }}</strong> 个子知识点
</p>
<template #footer>
<el-button @click="focusConfirmVisible = false">取消</el-button>
<el-button type="success" @click="confirmFocus">确定</el-button>
</template>
</el-dialog>
<!-- 统计卡片 --> <!-- 统计卡片 -->
<div class="stats-card surface-card" v-if="hasCompared && lastResult"> <div class="stats-card surface-card" v-if="hasCompared && lastResult">
<div class="stat-item"> <div class="stat-item">
@@ -404,7 +402,7 @@ onMounted(() => {
@click="selectHistoryRecord(record)" @click="selectHistoryRecord(record)"
> >
<span class="history-time">{{ record.createdTime?.replace("T", " ")?.substring(0, 16) }}</span> <span class="history-time">{{ record.createdTime?.replace("T", " ")?.substring(0, 16) }}</span>
<el-tag v-if="record.sessionNum" size="small" type="success" effect="plain" class="session-badge">会话</el-tag> <el-tag v-if="record.focusPath" size="small" type="success" effect="plain" class="session-badge" :title="record.focusPath">节点</el-tag>
<el-tag v-else size="small" type="info" effect="plain" class="session-badge">全部</el-tag> <el-tag v-else size="small" type="info" effect="plain" class="session-badge">全部</el-tag>
<span class="history-ratio">覆盖率 {{ (record.recallRatio * 100).toFixed(1) }}%</span> <span class="history-ratio">覆盖率 {{ (record.recallRatio * 100).toFixed(1) }}%</span>
<span class="history-detail">{{ record.matchedCount }} {{ record.missedCount }} {{ record.extraCount }}</span> <span class="history-detail">{{ record.matchedCount }} {{ record.missedCount }} {{ record.extraCount }}</span>
@@ -493,11 +491,16 @@ onMounted(() => {
<span>来源{{ standard.generator }}</span> <span>来源{{ standard.generator }}</span>
<span>参考{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span> <span>参考{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span>
</div> </div>
<p v-if="!hasCompared && !focusPath" class="select-hint">
点击导图节点选择复习起点选好后开始回忆填空
</p>
<MindMapViewer <MindMapViewer
v-if="standardTree" v-if="standardTree"
:tree="compareTree || standardTree" :tree="compareTree || standardTree"
:color-by-compare="!!compareTree" :color-by-compare="!!compareTree"
:selectable="!editingStandard && !focusPath && !hasCompared"
height="60vh" height="60vh"
@node-select="handleNodeSelect"
@node-click="goToNodeSource" @node-click="goToNodeSource"
/> />
<pre v-else class="standard-outline">{{ standard.outline || standard.content }}</pre> <pre v-else class="standard-outline">{{ standard.outline || standard.content }}</pre>
@@ -816,4 +819,14 @@ onMounted(() => {
.session-badge { .session-badge {
flex-shrink: 0; flex-shrink: 0;
} }
.select-hint {
font-size: 13px;
color: #e6a23c;
margin: 0 0 8px;
padding: 6px 12px;
background: #fdf6ec;
border-radius: 4px;
border-left: 3px solid #e6a23c;
}
</style> </style>
+24
View File
@@ -99,6 +99,29 @@ const openRecallDetail = () => {
router.push(`/review/detail/${group.navigateType}/${group.navigateId}`); router.push(`/review/detail/${group.navigateType}/${group.navigateId}`);
}; };
/** 从回忆卡片跳转到回忆复习页(先匹配标准导图节点) */
const goToRecallFromCard = async () => {
const group = recallCard.value.group;
if (!group) return;
recallCard.value.visible = false;
const tn = group.taskNum;
const content = group.content || "";
if (tn && content) {
try {
const { findNode } = await import("@/api/standardMindMap");
const res = await findNode(tn, content.substring(0, 500));
const data = res?.data;
if (data?.path) {
router.push(`/review/recall/${tn}?focusPath=${encodeURIComponent(data.path)}`);
return;
}
} catch { /* fall through */ }
router.push(`/review/recall/${tn}`);
} else {
router.push("/review");
}
};
const startStudy = () => { const startStudy = () => {
router.push("/study"); router.push("/study");
}; };
@@ -197,6 +220,7 @@ onMounted(() => {
回忆好了展开对照 回忆好了展开对照
</el-button> </el-button>
<el-button v-else type="success" @click="openRecallDetail">进入详情页</el-button> <el-button v-else type="success" @click="openRecallDetail">进入详情页</el-button>
<el-button type="success" plain @click="goToRecallFromCard">前往回忆复习</el-button>
<el-button @click="recallCard.visible = false">关闭</el-button> <el-button @click="recallCard.visible = false">关闭</el-button>
</template> </template>
</el-dialog> </el-dialog>