feat(回忆复习):回忆输入改用思维导图 + 布局优化
- 回忆输入区从文本域改为可编辑思维导图(Tab 加子/Enter 加同级) - 回忆历史点击后自动将内容还原到导图编辑区 - 增加回忆页宽屏模式(wide route meta),适配导图横向展示 - MindMapViewer 增加 height prop 支持自定义高度 - 回忆、对比、标准导图各区域全部使用 60vh 导图展示
This commit is contained in:
+5
-1
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user