From 84fe452e809e7148c4eb31eb259c7ce5385aadbe Mon Sep 17 00:00:00 2001 From: cat_shark <1716967236@qq.com> Date: Sun, 5 Jul 2026 18:10:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=A4=8D=E4=B9=A0):=20=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E4=BA=A4=E4=BA=92=20+=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=20session=20=E7=BB=B4=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MindMapViewer 新增 selectable 模式,点击节点 emit node-select 事件 - ReviewRecall.vue 移除会话选择器,改为节点选择交互 - 支持 URL ?focusPath= 参数自动预设复习起点 - 节点选择弹窗确认后回忆导图根节点=选中节点标题 - ReviewDetail 和 Welcome 中调用 findNode API 匹配节点后跳转 - API: recallCompare 使用 focusPath 替代 sessionNum - API: 新增 findNode 端点 --- src/api/standardMindMap.ts | 19 +++++-- src/api/studySessions.ts | 5 -- src/components/MindMapViewer.vue | 41 ++++++++++++-- src/components/ReviewDetail.vue | 22 +++++++- src/components/ReviewRecall.vue | 93 ++++++++++++++++++-------------- src/components/Welcome.vue | 24 +++++++++ 6 files changed, 150 insertions(+), 54 deletions(-) diff --git a/src/api/standardMindMap.ts b/src/api/standardMindMap.ts index 7fd2add..1c997b7 100644 --- a/src/api/standardMindMap.ts +++ b/src/api/standardMindMap.ts @@ -23,7 +23,7 @@ export interface RecallRecord { id: number; taskNum: string; standardMapId: number; - sessionNum?: string; + focusPath?: string; recallContent: string; compareResult: string; recallRatio: number; @@ -33,6 +33,12 @@ export interface RecallRecord { createdTime: string; } +export interface FindNodeResult { + path: string; + nodeTitle: string; + score: number; +} + /** 获取/自动生成标准思维导图 */ export const getStandardMindMap = (taskNum: string) => { 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 }); }; -/** 用户提交回忆大纲,与标准导图对比(可选指定会话维度) */ -export const recallCompare = (taskNum: string, recallOutline: string, sessionNum?: string) => { +/** 用户提交回忆大纲,与标准导图对比(可选指定起始节点路径) */ +export const recallCompare = (taskNum: string, recallOutline: string, focusPath?: string) => { const body: Record = { recallOutline }; - if (sessionNum) body.sessionNum = sessionNum; + if (focusPath) body.focusPath = focusPath; 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) => { return request.get(`/review/standard-mind-map/${taskNum}/recall-records`); diff --git a/src/api/studySessions.ts b/src/api/studySessions.ts index b36bf6f..981a3a8 100644 --- a/src/api/studySessions.ts +++ b/src/api/studySessions.ts @@ -44,11 +44,6 @@ export const getActiveSession = (excludeTaskNum?: string) => { 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) => { const params: Record = { page, size }; diff --git a/src/components/MindMapViewer.vue b/src/components/MindMapViewer.vue index c1ae45e..2d31465 100644 --- a/src/components/MindMapViewer.vue +++ b/src/components/MindMapViewer.vue @@ -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(null); let instance: MindElixirInstance | null = null; let nodeCounter = 0; +/** 节点路径映射:elixir node id → { title, path, childCount } */ +let nodePathMap: Record = {}; + +/** 递归构建节点路径映射 */ +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) }); } diff --git a/src/components/ReviewDetail.vue b/src/components/ReviewDetail.vue index 22b5f8b..5a2d4ca 100644 --- a/src/components/ReviewDetail.vue +++ b/src/components/ReviewDetail.vue @@ -12,12 +12,32 @@ import { } from "@/api/review"; import { getSessionDetail } from "@/api/studySessions"; import { updateFragments } from "@/api/reportFragments"; +import { findNode } from "@/api/standardMindMap"; import { ElMessage } from "element-plus"; import MarkdownRenderer from "@/components/MarkdownRenderer.vue"; const route = useRoute(); 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 contentId = computed(() => Number(route.params.id)); @@ -327,7 +347,7 @@ watch( > 上传导图文件 - + 回忆复习 diff --git a/src/components/ReviewRecall.vue b/src/components/ReviewRecall.vue index 015bc46..b8490f3 100644 --- a/src/components/ReviewRecall.vue +++ b/src/components/ReviewRecall.vue @@ -7,12 +7,12 @@ import { recallCompare, updateStandardMindMap, listRecallRecords, + findNode, type StandardMindMap, type RecallRecord, } from "@/api/standardMindMap"; import { ElMessage, ElMessageBox } from "element-plus"; import MindMapViewer, { type MindMapTreeNode } from "@/components/MindMapViewer.vue"; -import { getTaskSessions } from "@/api/studySessions"; const route = useRoute(); const router = useRouter(); @@ -30,10 +30,10 @@ let regeneratingTimer: ReturnType | null = null; const lastResult = ref(null); const hasCompared = ref(false); -// 复习维度选择 -const sessions = ref<{ sessionNum: string; startTime: string; expectation: string; reportCount: number; fragmentCount: number }[]>([]); -const selectedSession = ref(""); -const loadingSessions = ref(false); +// 起点节点选择 +const focusPath = ref((route.query.focusPath as string) || ""); +const focusConfirmVisible = ref(false); +const selectedNodeInfo = ref<{ title: string; path: string; childCount: number } | null>(null); // 回忆输入(思维导图形式) const recallTree = ref(null); @@ -192,7 +192,7 @@ const handleRecallCompare = async () => { comparingSeconds.value = 0; comparingTimer = setInterval(() => { comparingSeconds.value++; }, 1000); 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; hasCompared.value = true; // 解析对比结果 @@ -318,29 +318,26 @@ const extraNodes = computed(() => { return raw.map((item: any) => typeof item === "string" ? { title: item } : item); }); -// 加载会话列表(复习维度选择器用) -const loadSessions = async () => { - loadingSessions.value = true; - try { - const res = await getTaskSessions(taskNum.value); - sessions.value = res?.data || []; - } catch { - sessions.value = []; - } finally { - loadingSessions.value = false; - } +// 节点选择回调(selectable 模式下点击节点) +const handleNodeSelect = (node: { title: string; path: string; childCount: number }) => { + selectedNodeInfo.value = node; + focusConfirmVisible.value = true; }; -// 格式化会话标签 -const formatSessionLabel = (s: { sessionNum: string; startTime: string; expectation: string; reportCount: number; fragmentCount: number }) => { - const date = s.startTime ? s.startTime.replace("T", " ").substring(0, 16) : s.sessionNum; - const hint = s.expectation ? s.expectation.substring(0, 30) : ""; - return `${date} ${hint ? "— " + hint : ""}`; +// 确认以选中节点为起点 +const confirmFocus = () => { + if (selectedNodeInfo.value) { + focusPath.value = selectedNodeInfo.value.path; + recallTree.value = { + title: selectedNodeInfo.value.title, + children: [], + }; + } + focusConfirmVisible.value = false; }; onMounted(() => { loadData(); - loadSessions(); }); @@ -355,24 +352,25 @@ onMounted(() => { - -
- 复习范围 - - - - - - 已过滤:仅匹配该会话知识点 - - 不过滤,对比全部标准导图 + +
+ 复习起点 + {{ focusPath }} + 重选节点
+ + +

+ 以 「{{ selectedNodeInfo.title }}」 为起点, + 复习该知识点下的 {{ selectedNodeInfo.childCount }} 个子知识点? +

+ +
+
@@ -404,7 +402,7 @@ onMounted(() => { @click="selectHistoryRecord(record)" > {{ record.createdTime?.replace("T", " ")?.substring(0, 16) }} - 会话 + 节点 全部 覆盖率 {{ (record.recallRatio * 100).toFixed(1) }}% ✅{{ record.matchedCount }} ❌{{ record.missedCount }} ➕{{ record.extraCount }} @@ -493,11 +491,16 @@ onMounted(() => { 来源:{{ standard.generator }} 参考:{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片
+

+ 点击导图节点选择复习起点,选好后开始回忆填空 +

{{ standard.outline || standard.content }}
@@ -816,4 +819,14 @@ onMounted(() => { .session-badge { 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; +} diff --git a/src/components/Welcome.vue b/src/components/Welcome.vue index 44cb915..8dbcc97 100644 --- a/src/components/Welcome.vue +++ b/src/components/Welcome.vue @@ -99,6 +99,29 @@ const openRecallDetail = () => { 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 = () => { router.push("/study"); }; @@ -197,6 +220,7 @@ onMounted(() => { 回忆好了,展开对照 进入详情页 + 前往回忆复习 关闭