feat(复习):回忆复习页面与入口

- 新增 ReviewRecall.vue 回忆复习页面(双栏布局)
  - 左栏:回忆大纲输入区、对比结果展示(着色树)
  - 右栏:标准导图(默认折叠)、编辑、重新生成
  - 遗漏项列表,可点击回看原文
  - 回忆历史记录查询
- api/standardMindMap.ts:标准导图与回忆对比 API
- router:新增 /review/recall/:taskNum 路由
- Review.vue 行操作中增加回忆复习按钮
- ReviewDetail.vue 思维导图区增加回忆复习入口
This commit is contained in:
2026-07-03 08:54:16 +08:00
parent dc7469ec3f
commit b24483b03b
11 changed files with 1205 additions and 260 deletions
+663
View File
@@ -0,0 +1,663 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getStandardMindMap,
regenerateStandardMindMap,
recallCompare,
updateStandardMindMap,
listRecallRecords,
type StandardMindMap,
type RecallRecord,
} from "@/api/standardMindMap";
import { ElMessage, ElMessageBox } from "element-plus";
const route = useRoute();
const router = useRouter();
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 standardExpanded = ref(false);
// 编辑标准导图
const editingStandard = ref(false);
const editOutline = ref("");
const savingStandard = ref(false);
// 回忆历史
const historyRecords = ref<RecallRecord[]>([]);
const showHistory = ref(false);
// 格式化覆盖率
const ratioPercent = computed(() => {
if (!lastResult.value?.recallRatio) return "0%";
return `${(lastResult.value.recallRatio * 100).toFixed(1)}%`;
});
// 加载数据
const loadData = async () => {
loading.value = true;
try {
const res = await getStandardMindMap(taskNum.value);
standard.value = res?.data || null;
} catch (e: any) {
standard.value = null;
} finally {
loading.value = false;
}
};
// 重新生成
const handleRegenerate = async () => {
try {
await ElMessageBox.confirm("重新生成将丢弃用户编辑的内容,确定?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
loading.value = true;
const res = await regenerateStandardMindMap(taskNum.value);
standard.value = res?.data || null;
ElMessage.success("标准思维导图已重新生成");
} catch {
// 用户取消或接口错误
} finally {
loading.value = false;
}
};
// 提交回忆对比
const handleRecallCompare = async () => {
if (!recallOutline.value.trim()) {
ElMessage.warning("请输入回忆内容大纲");
return;
}
comparing.value = true;
try {
const res = await recallCompare(taskNum.value, recallOutline.value);
standard.value = res?.data || null;
hasCompared.value = true;
// 解析对比结果
if (standard.value) {
const recordRes = await listRecallRecords(taskNum.value);
const records: RecallRecord[] = recordRes?.data || [];
if (records.length > 0) {
const latest = records[0];
try {
lastResult.value = JSON.parse(latest.compareResult);
} catch {
lastResult.value = null;
}
historyRecords.value = records;
}
}
ElMessage.success("回忆对比完成");
} catch (e: any) {
ElMessage.error(e.message || "对比失败");
} finally {
comparing.value = false;
}
};
// 编辑标准导图
const startEditStandard = () => {
editOutline.value = standard.value?.outline || "";
editingStandard.value = true;
};
const saveStandardEdit = async () => {
if (!editOutline.value.trim()) {
ElMessage.warning("大纲不可为空");
return;
}
savingStandard.value = true;
try {
const res = await updateStandardMindMap(taskNum.value, editOutline.value);
standard.value = res?.data || null;
editingStandard.value = false;
ElMessage.success("标准思维导图已更新");
} catch (e: any) {
ElMessage.error(e.message || "保存失败");
} finally {
savingStandard.value = false;
}
};
const cancelStandardEdit = () => {
editingStandard.value = false;
editOutline.value = "";
};
// 查看回忆历史
const loadHistory = async () => {
try {
const res = await listRecallRecords(taskNum.value);
historyRecords.value = res?.data || [];
showHistory.value = true;
} catch {
ElMessage.error("加载历史记录失败");
}
};
// 选择历史记录查看结果
const selectHistoryRecord = (record: RecallRecord) => {
try {
lastResult.value = JSON.parse(record.compareResult);
// 重新设置 recallOutline 为历史内容
recallOutline.value = record.recallContent;
hasCompared.value = true;
} catch {
ElMessage.error("解析对比记录失败");
}
};
// 跳转到复习详情
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 [];
const items: { title: string; sourceType: string; sourceId: number }[] = [];
const walk = (node: any) => {
const notes = node.notes || "";
if (notes.startsWith("MISSED|")) {
items.push({
title: node.title || "",
sourceType: node.sourceType || "",
sourceId: node.sourceId,
});
}
(node.children || []).forEach(walk);
};
walk(lastResult.value.matchedTree);
return items;
});
// 额外节点列表
const extraNodes = computed(() => {
return lastResult.value?.extraNodes || [];
});
onMounted(() => {
loadData();
});
</script>
<template>
<section class="recall-page">
<!-- 顶部操作栏 -->
<div class="page-header">
<el-button size="small" @click="router.back()"> 返回</el-button>
<h2>回忆复习</h2>
<el-button size="small" @click="loadHistory" v-if="!showHistory">
历史记录
</el-button>
</div>
<!-- 统计卡片 -->
<div class="stats-card surface-card" v-if="hasCompared && lastResult">
<div class="stat-item">
<span class="stat-label">回忆覆盖率</span>
<strong class="stat-value highlight">{{ ratioPercent }}</strong>
</div>
<div class="stat-item">
<span class="stat-label"> 回忆命中</span>
<strong class="stat-value matched">{{ lastResult.matchedCount }}</strong>
</div>
<div class="stat-item">
<span class="stat-label"> 遗漏</span>
<strong class="stat-value missed">{{ lastResult.missedCount }}</strong>
</div>
<div class="stat-item">
<span class="stat-label"> 额外</span>
<strong class="stat-value extra">{{ lastResult.extraCount }}</strong>
</div>
</div>
<!-- 记忆历史 -->
<article class="surface-card" v-if="showHistory && historyRecords.length > 0">
<h3>回忆历史</h3>
<div class="history-list">
<div
v-for="record in historyRecords"
:key="record.id"
class="history-item"
@click="selectHistoryRecord(record)"
>
<span class="history-time">{{ record.createdTime?.replace("T", " ")?.substring(0, 16) }}</span>
<span class="history-ratio">覆盖率 {{ (record.recallRatio * 100).toFixed(1) }}%</span>
<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">
<h3>🧠 你的回忆</h3>
<p class="panel-hint">
凭记忆写下该任务的知识点大纲缩进表示层级写完后点击"提交对比"
</p>
<el-input
v-model="recallOutline"
type="textarea"
:rows="12"
placeholder="Java 并发编程
├─ 线程基础
│ ├─ 创建线程
│ └─ 线程状态
├─ 锁机制
│ ├─ Synchronized
│ └─ ReentrantLock"
/>
<div class="recall-actions">
<el-button type="primary" :loading="comparing" @click="handleRecallCompare">
提交对比
</el-button>
<el-button v-if="hasCompared" @click="recallOutline = ''">
清空重写
</el-button>
</div>
<!-- 对比结果 -->
<div v-if="hasCompared && resultText" class="compare-result">
<h4>📊 对比结果</h4>
<pre class="compare-tree">{{ resultText }}</pre>
</div>
</article>
<!-- 右侧标准导图 -->
<article class="surface-card standard-panel">
<div class="standard-header">
<h3>📖 标准思维导图</h3>
<div class="standard-actions">
<el-button size="small" @click="standardExpanded = !standardExpanded">
{{ standardExpanded ? "折叠" : "展开" }}
</el-button>
<el-button
v-if="standardExpanded"
size="small"
type="warning"
@click="handleRegenerate"
>
重新生成
</el-button>
<el-button
v-if="standardExpanded && !editingStandard"
size="small"
type="primary"
@click="startEditStandard"
>
编辑
</el-button>
</div>
</div>
<div v-if="!standard" class="empty-state">
<el-empty description="暂无标准思维导图" :image-size="64">
<el-button type="primary" :loading="loading" @click="loadData">
生成
</el-button>
</el-empty>
</div>
<div v-else-if="editingStandard" class="edit-area">
<p class="panel-hint">编辑大纲文本保存后标准导图将被更新</p>
<el-input v-model="editOutline" type="textarea" :rows="12" />
<p v-if="standard.summary" class="generator-info">{{ standard.summary }}</p>
<div class="edit-actions">
<el-button @click="cancelStandardEdit">取消</el-button>
<el-button type="primary" :loading="savingStandard" @click="saveStandardEdit">
保存更新
</el-button>
</div>
</div>
<div v-else-if="standardExpanded" class="standard-content">
<div class="standard-meta">
<span>来源{{ standard.generator }}</span>
<span>参考{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span>
</div>
<pre 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>
</div>
</article>
</div>
<!-- 遗漏项 + 额外项详情 -->
<div v-if="hasCompared && lastResult" class="detail-section">
<!-- 遗漏项 -->
<article class="surface-card" v-if="missedItems.length > 0">
<h3> 遗漏的知识点</h3>
<div class="missed-list">
<div v-for="(item, idx) in missedItems" :key="idx" class="missed-item">
<span class="missed-title">{{ item.title }}</span>
<el-button
v-if="item.sourceType && item.sourceId"
text
type="primary"
size="small"
@click="goToReviewDetail(item.sourceType, item.sourceId)"
>
查看原文
</el-button>
</div>
</div>
</article>
<!-- 额外项 -->
<article class="surface-card" v-if="extraNodes.length > 0">
<h3> 你额外回忆到的知识点</h3>
<div class="extra-list">
<div v-for="(item, idx) in extraNodes" :key="idx" class="extra-item">
{{ item.title }}
</div>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.recall-page {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
}
.page-header h2 {
margin: 0;
flex: 1;
color: var(--green-900);
font-size: 18px;
}
.surface-card {
padding: 20px 22px;
background: #fff;
border-radius: 8px;
border: 1px solid var(--border-soft, #e0e0e0);
}
.stats-card {
display: flex;
gap: 24px;
flex-wrap: wrap;
background: linear-gradient(135deg, #f1f8e9 0%, #e8f5e9 100%);
}
.stat-item {
min-width: 100px;
}
.stat-label {
display: block;
font-size: 12px;
color: var(--text-secondary, #666);
}
.stat-value {
display: block;
font-size: 28px;
margin-top: 4px;
}
.highlight { color: #2e7d32; }
.matched { color: #2e7d32; }
.missed { color: #c62828; }
.extra { color: #1565c0; }
.recall-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
}
@media (max-width: 900px) {
.recall-layout {
grid-template-columns: 1fr;
}
}
.recall-panel h3,
.standard-panel h3 {
margin: 0 0 8px;
color: var(--green-900);
font-size: 16px;
}
.panel-hint {
font-size: 13px;
color: var(--text-secondary, #666);
margin: 0 0 12px;
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;
font-size: 12px;
color: var(--text-secondary, #666);
margin-bottom: 10px;
flex-wrap: wrap;
}
.standard-outline {
white-space: pre-wrap;
font-size: 13px;
line-height: 1.7;
color: var(--text-primary, #333);
background: #fafafa;
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-soft, #e0e0e0);
max-height: 400px;
overflow-y: auto;
word-break: break-word;
}
.generator-info {
font-size: 12px;
color: var(--text-secondary, #666);
margin: 8px 0 0;
}
.collapsed-hint {
color: var(--text-secondary, #666);
font-size: 14px;
text-align: center;
padding: 20px;
}
.empty-state {
display: flex;
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;
gap: 8px;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.detail-section {
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-section h3 {
margin: 0 0 12px;
color: var(--green-900);
font-size: 15px;
}
.missed-list,
.extra-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.missed-item,
.extra-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #fafafa;
font-size: 14px;
}
.missed-item {
border-left: 3px solid #c62828;
}
.extra-item {
border-left: 3px solid #1565c0;
}
.missed-title {
flex: 1;
}
.history-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.history-item:hover {
background: #f1f8e9;
}
.history-time {
color: var(--text-secondary, #666);
min-width: 120px;
}
.history-ratio {
font-weight: 600;
color: var(--green-800);
min-width: 80px;
}
.history-detail {
color: var(--text-secondary, #666);
}
</style>