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
+53 -40
View File
@@ -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<typeof setInterval> | null = null;
const lastResult = ref<any>(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<MindMapTreeNode | null>(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();
});
</script>
@@ -355,24 +352,25 @@ onMounted(() => {
</el-button>
</div>
<!-- 复习维度选择器 -->
<div class="review-scope surface-card" v-if="sessions.length > 0 || loadingSessions">
<span class="scope-label">复习范围</span>
<el-select v-model="selectedSession" placeholder="选择复习范围" clearable size="small" :loading="loadingSessions">
<el-option label="全部任务" value="" />
<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 class="review-scope surface-card" v-if="focusPath">
<span class="scope-label">复习起点</span>
<el-tag size="small" type="success" effect="light">{{ focusPath }}</el-tag>
<el-button size="small" text @click="focusPath = ''">重选节点</el-button>
</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="stat-item">
@@ -404,7 +402,7 @@ onMounted(() => {
@click="selectHistoryRecord(record)"
>
<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>
<span class="history-ratio">覆盖率 {{ (record.recallRatio * 100).toFixed(1) }}%</span>
<span class="history-detail">{{ record.matchedCount }} {{ record.missedCount }} {{ record.extraCount }}</span>
@@ -493,11 +491,16 @@ onMounted(() => {
<span>来源{{ standard.generator }}</span>
<span>参考{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span>
</div>
<p v-if="!hasCompared && !focusPath" class="select-hint">
点击导图节点选择复习起点选好后开始回忆填空
</p>
<MindMapViewer
v-if="standardTree"
:tree="compareTree || standardTree"
:color-by-compare="!!compareTree"
:selectable="!editingStandard && !focusPath && !hasCompared"
height="60vh"
@node-select="handleNodeSelect"
@node-click="goToNodeSource"
/>
<pre v-else class="standard-outline">{{ standard.outline || standard.content }}</pre>
@@ -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;
}
</style>