feat(复习):回忆复习页面与入口
- 新增 ReviewRecall.vue 回忆复习页面(双栏布局) - 左栏:回忆大纲输入区、对比结果展示(✅❌着色树) - 右栏:标准导图(默认折叠)、编辑、重新生成 - 遗漏项列表,可点击回看原文 - 回忆历史记录查询 - api/standardMindMap.ts:标准导图与回忆对比 API - router:新增 /review/recall/:taskNum 路由 - Review.vue 行操作中增加回忆复习按钮 - ReviewDetail.vue 思维导图区增加回忆复习入口
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user