feat(回忆复习):回忆输入改用思维导图 + 布局优化

- 回忆输入区从文本域改为可编辑思维导图(Tab 加子/Enter 加同级)
- 回忆历史点击后自动将内容还原到导图编辑区
- 增加回忆页宽屏模式(wide route meta),适配导图横向展示
- MindMapViewer 增加 height prop 支持自定义高度
- 回忆、对比、标准导图各区域全部使用 60vh 导图展示
This commit is contained in:
2026-07-04 10:17:16 +08:00
parent 3adbeb183c
commit cd68811dfb
4 changed files with 161 additions and 189 deletions
+5 -1
View File
@@ -14,7 +14,7 @@ const route = useRoute();
<el-header class="shell-header">
<MyHead />
</el-header>
<el-main class="shell-main">
<el-main class="shell-main" :class="{ wide: route.meta.wide }">
<RouterView v-slot="{ Component }">
<Transition name="fade-up" mode="out-in">
<component :is="Component" :key="route.fullPath" />
@@ -54,6 +54,10 @@ const route = useRoute();
padding: 18px 20px 24px;
}
.shell-main.wide {
max-width: 1720px;
}
.shell-footer {
height: auto;
padding: 0 20px 18px;
+3 -2
View File
@@ -23,6 +23,8 @@ const props = defineProps<{
editable?: boolean;
/** 是否按 MATCHED/MISSED 标记着色 */
colorByCompare?: boolean;
/** 画布高度,如 "520px"、"60vh",默认 480px */
height?: string;
}>();
const emit = defineEmits<{
@@ -143,13 +145,12 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="mind-map-viewer" ref="container"></div>
<div class="mind-map-viewer" ref="container" :style="{ height: height || '480px' }"></div>
</template>
<style scoped>
.mind-map-viewer {
width: 100%;
height: 480px;
border: 1px solid var(--border-soft, #e0e0e0);
border-radius: 8px;
overflow: hidden;
+96 -129
View File
@@ -19,11 +19,14 @@ const taskNum = computed(() => route.params.taskNum as string);
const loading = ref(false);
const standard = ref<StandardMindMap | null>(null);
const recallOutline = ref("");
const comparing = ref(false);
const lastResult = ref<any>(null);
const hasCompared = ref(false);
// 回忆输入(思维导图形式)
const recallTree = ref<MindMapTreeNode | null>(null);
const recallViewer = ref<InstanceType<typeof MindMapViewer> | null>(null);
// 标准导图展开/折叠(默认折叠,防止剧透)
const standardExpanded = ref(false);
@@ -65,6 +68,34 @@ const ratioPercent = computed(() => {
return `${(lastResult.value.recallRatio * 100).toFixed(1)}%`;
});
/** 回忆导图的初始根节点(只给根主题,内容凭记忆补全) */
const buildEmptyRecallTree = (): MindMapTreeNode => ({
title: standard.value?.title || `任务 ${taskNum.value}`,
children: [],
});
/** 将缩进大纲文本解析为树(兼容 "- " 前缀与 ├└│─ 等装饰符) */
const parseOutlineToTree = (text: string): MindMapTreeNode | null => {
const lines = (text || "").split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length === 0) return null;
const cleanTitle = (l: string) => l.replace(/^[\s│├└┌┐┘┬┴┼─\-*•]+/, "").trim();
const indentOf = (l: string) => {
const match = l.match(/^[\s│├└┌─]*/);
const prefix = match ? match[0] : "";
return Math.max(1, Math.floor(prefix.replace(/[^\s]/g, " ").length / 2));
};
const root: MindMapTreeNode = { title: cleanTitle(lines[0]), children: [] };
const stack: { node: MindMapTreeNode; level: number }[] = [{ node: root, level: 0 }];
for (let i = 1; i < lines.length; i++) {
const level = indentOf(lines[i]);
const node: MindMapTreeNode = { title: cleanTitle(lines[i]), children: [] };
while (stack.length > 1 && stack[stack.length - 1].level >= level) stack.pop();
stack[stack.length - 1].node.children!.push(node);
stack.push({ node, level });
}
return root;
};
// 加载数据
const loadData = async () => {
loading.value = true;
@@ -75,6 +106,9 @@ const loadData = async () => {
standard.value = null;
} finally {
loading.value = false;
if (!recallTree.value) {
recallTree.value = buildEmptyRecallTree();
}
}
};
@@ -99,13 +133,14 @@ const handleRegenerate = async () => {
// 提交回忆对比
const handleRecallCompare = async () => {
if (!recallOutline.value.trim()) {
ElMessage.warning("请输入回忆内容大纲");
const outline = recallViewer.value?.toOutline() || "";
if (outline.split(/\r?\n/).filter((l) => l.trim()).length <= 1) {
ElMessage.warning("请先在回忆导图中添加节点(选中节点后按 Tab 加子节点)");
return;
}
comparing.value = true;
try {
const res = await recallCompare(taskNum.value, recallOutline.value);
const res = await recallCompare(taskNum.value, outline);
standard.value = res?.data || null;
hasCompared.value = true;
// 解析对比结果
@@ -130,6 +165,11 @@ const handleRecallCompare = async () => {
}
};
// 清空重写
const resetRecall = () => {
recallTree.value = buildEmptyRecallTree();
};
// 编辑标准导图
const startEditStandard = () => {
editOutline.value = standard.value?.outline || "";
@@ -185,8 +225,9 @@ const loadHistory = async () => {
const selectHistoryRecord = (record: RecallRecord) => {
try {
lastResult.value = JSON.parse(record.compareResult);
// 重新设置 recallOutline 为历史内容
recallOutline.value = record.recallContent;
// 将历史回忆内容还原到导图输入区
const tree = parseOutlineToTree(record.recallContent);
if (tree) recallTree.value = tree;
hasCompared.value = true;
} catch {
ElMessage.error("解析对比记录失败");
@@ -198,32 +239,6 @@ const goToReviewDetail = (sourceType: string, sourceId: number) => {
router.push(`/review/detail/${sourceType.toLowerCase()}/${sourceId}`);
};
// 渲染对比结果树
const renderCompareTree = (nodes: any[], level: number = 0): string => {
if (!nodes || nodes.length === 0) return "";
return nodes
.map((node: any) => {
const notes = node.notes || "";
const isMatched = notes.startsWith("MATCHED|");
const isMissed = notes.startsWith("MISSED|");
const marker = isMatched ? "✅ " : isMissed ? "❌ " : " ";
const title = node.title || "(未命名)";
const indent = " ".repeat(level);
const childrenHtml = renderCompareTree(node.children, level + 1);
return `${indent}${marker}${title}\n${childrenHtml}`;
})
.join("");
};
const resultText = computed(() => {
if (!lastResult.value?.matchedTree) return "";
const tree = lastResult.value.matchedTree;
// 根节点标题
let text = `${tree.title || "思维导图"}\n\n`;
text += renderCompareTree(tree.children || tree.nodes || [], 1);
return text;
});
// 获取遗漏项列表
const missedItems = computed(() => {
if (!lastResult.value?.matchedTree) return [];
@@ -284,7 +299,7 @@ onMounted(() => {
</div>
</div>
<!-- 忆历史 -->
<!-- 忆历史 -->
<article class="surface-card" v-if="showHistory && historyRecords.length > 0">
<h3>回忆历史</h3>
<div class="history-list">
@@ -299,55 +314,44 @@ onMounted(() => {
<span class="history-detail">{{ record.matchedCount }} {{ record.missedCount }} {{ record.extraCount }}</span>
</div>
</div>
<el-divider />
</article>
<!-- 两大块回忆输入区 + 标准导图 -->
<div class="recall-layout">
<!-- 左侧回忆输入 -->
<!-- 回忆输入可编辑思维导图 -->
<article class="surface-card recall-panel">
<div class="panel-header">
<h3>🧠 你的回忆</h3>
<p class="panel-hint">
凭记忆写下该任务的知识点大纲缩进表示层级写完后点击"提交对比"
</p>
<el-input
v-model="recallOutline"
type="textarea"
:rows="12"
placeholder="Java 并发编程
├─ 线程基础
│ ├─ 创建线程
│ └─ 线程状态
├─ 锁机制
│ ├─ Synchronized
│ └─ ReentrantLock"
/>
<div class="recall-actions">
<div class="panel-actions">
<el-button type="primary" :loading="comparing" @click="handleRecallCompare">
提交对比
</el-button>
<el-button v-if="hasCompared" @click="recallOutline = ''">
<el-button v-if="hasCompared" @click="resetRecall">
清空重写
</el-button>
</div>
</div>
<p class="panel-hint">
凭记忆在导图上补全该任务的知识点选中节点后 Tab 加子节点Enter 加同级节点双击修改文字
</p>
<MindMapViewer ref="recallViewer" :tree="recallTree" :editable="true" height="60vh" />
</article>
<!-- 对比结果 -->
<div v-if="hasCompared && compareTree" class="compare-result">
<h4>📊 对比结果</h4>
<article class="surface-card" v-if="hasCompared && compareTree">
<h3>📊 对比结果</h3>
<p class="panel-hint">绿色=回忆命中红色=遗漏点击带标签的节点可回看原文</p>
<MindMapViewer
:tree="compareTree"
:color-by-compare="true"
height="60vh"
@node-click="goToNodeSource"
/>
</div>
</article>
<!-- 右侧标准导图 -->
<!-- 标准导图 -->
<article class="surface-card standard-panel">
<div class="standard-header">
<div class="panel-header">
<h3>📖 标准思维导图</h3>
<div class="standard-actions">
<div class="panel-actions">
<el-button size="small" @click="standardExpanded = !standardExpanded">
{{ standardExpanded ? "折叠" : "展开" }}
</el-button>
@@ -380,7 +384,7 @@ onMounted(() => {
<div v-else-if="editingStandard" class="edit-area">
<p class="panel-hint">直接在导图上编辑Tab 加子节点 / Enter 加同级 / 双击改文字保存后生效</p>
<MindMapViewer ref="editViewer" :tree="standardTree" :editable="true" />
<MindMapViewer ref="editViewer" :tree="standardTree" :editable="true" height="60vh" />
<div class="edit-actions">
<el-button @click="cancelStandardEdit">取消</el-button>
<el-button type="primary" :loading="savingStandard" @click="saveStandardEditFromViewer">
@@ -394,16 +398,15 @@ onMounted(() => {
<span>来源{{ standard.generator }}</span>
<span>参考{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span>
</div>
<MindMapViewer v-if="standardTree" :tree="standardTree" @node-click="goToNodeSource" />
<MindMapViewer v-if="standardTree" :tree="standardTree" height="60vh" @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>
</div>
<div v-else class="collapsed-hint">
<p>标准导图已生成展开后可查看或编辑建议先凭记忆写回忆大纲再对照</p>
<p>标准导图已生成展开后可查看或编辑建议先凭记忆补全回忆导图再对照</p>
</div>
</article>
</div>
<!-- 遗漏项 + 额外项详情 -->
<div v-if="hasCompared && lastResult" class="detail-section">
@@ -444,8 +447,6 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
@@ -468,6 +469,12 @@ onMounted(() => {
border: 1px solid var(--border-soft, #e0e0e0);
}
.surface-card h3 {
margin: 0 0 8px;
color: var(--green-900);
font-size: 16px;
}
.stats-card {
display: flex;
gap: 24px;
@@ -496,24 +503,24 @@ onMounted(() => {
.missed { color: #c62828; }
.extra { color: #1565c0; }
.recall-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
flex-wrap: wrap;
}
@media (max-width: 900px) {
.recall-layout {
grid-template-columns: 1fr;
}
.panel-header h3 {
margin: 0;
}
.recall-panel h3,
.standard-panel h3 {
margin: 0 0 8px;
color: var(--green-900);
font-size: 16px;
.panel-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.panel-hint {
@@ -523,31 +530,6 @@ onMounted(() => {
line-height: 1.5;
}
.recall-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.standard-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.standard-header h3 {
margin: 0;
}
.standard-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.standard-meta {
display: flex;
gap: 12px;
@@ -589,28 +571,6 @@ onMounted(() => {
justify-content: center;
}
.compare-result {
margin-top: 20px;
}
.compare-result h4 {
margin: 0 0 10px;
color: var(--green-800);
font-size: 15px;
}
.compare-tree {
white-space: pre-wrap;
font-size: 13px;
line-height: 1.7;
background: #fafafa;
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-soft, #e0e0e0);
max-height: 500px;
overflow-y: auto;
}
.edit-area {
display: flex;
flex-direction: column;
@@ -624,9 +584,16 @@ onMounted(() => {
}
.detail-section {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
}
@media (max-width: 900px) {
.detail-section {
grid-template-columns: 1fr;
}
}
.detail-section h3 {
+1 -1
View File
@@ -35,7 +35,7 @@ const routes: RouteRecordRaw[] = [
{
path: "/review/recall/:taskNum",
component: () => import("@/components/ReviewRecall.vue"),
meta: { requiresAuth: true, title: "回忆复习" },
meta: { requiresAuth: true, title: "回忆复习", wide: true },
},
{
path: "/start-task/:taskNum",