Files
lpt-fe/src/components/ReviewRecall.vue
T

891 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getStandardMindMap,
regenerateStandardMindMap,
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";
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 comparing = ref(false);
const comparingSeconds = ref(0);
let comparingTimer: ReturnType<typeof setInterval> | null = null;
const regenerating = ref(false);
const regeneratingSeconds = ref(0);
let regeneratingTimer: ReturnType<typeof setInterval> | null = null;
const lastResult = ref<any>(null);
const hasCompared = 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);
const recallViewer = ref<InstanceType<typeof MindMapViewer> | null>(null);
// 标准导图展开/折叠(默认折叠,防止剧透)
const standardExpanded = ref(false);
// 编辑标准导图
const editingStandard = ref(false);
const editOutline = ref("");
const savingStandard = ref(false);
const editViewer = ref<InstanceType<typeof MindMapViewer> | null>(null);
// 回忆历史
const historyRecords = ref<RecallRecord[]>([]);
const showHistory = ref(false);
// 解析标准导图 JSON 为树节点
const standardTree = computed<MindMapTreeNode | null>(() => {
if (!standard.value?.content) return null;
try {
return JSON.parse(standard.value.content) as MindMapTreeNode;
} catch {
return null;
}
});
// 对比结果树(带 MATCHED/MISSED 标注)
const compareTree = computed<MindMapTreeNode | null>(() => {
return (lastResult.value?.matchedTree as MindMapTreeNode) || null;
});
// 点击着色节点跳转原文
const goToNodeSource = (node: { sourceType: string; sourceId: number }) => {
if (node.sourceType === "REPORT" || node.sourceType === "FRAGMENT") {
router.push(`/review/detail/${node.sourceType.toLowerCase()}/${node.sourceId}`);
}
};
// 格式化覆盖率
const ratioPercent = computed(() => {
if (!lastResult.value?.recallRatio) return "0%";
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;
try {
const res = await getStandardMindMap(taskNum.value);
standard.value = res?.data || null;
} catch (e: any) {
standard.value = null;
} finally {
loading.value = false;
if (!recallTree.value) {
recallTree.value = buildEmptyRecallTree();
}
// 若从 URL 带了 focusPath,自动设置回忆导图根节点
if (focusPath.value && standard.value?.content) {
try {
const tree = JSON.parse(standard.value.content) as MindMapTreeNode;
const match = findNodeByPath(tree, focusPath.value);
if (match) {
recallTree.value = { title: match.title, children: [] };
}
} catch { /* ignore */ }
}
}
};
const findNodeByPath = (node: MindMapTreeNode, path: string): MindMapTreeNode | null => {
const parts = path.split(" / ");
let current: MindMapTreeNode | null = node;
for (let i = 0; i < parts.length && current; i++) {
if (current.title === parts[i] && i === parts.length - 1) return current;
const child: MindMapTreeNode | undefined = (current.children || []).find((c) => c.title === parts[i]);
current = child || null;
}
return null;
};
// 重新生成(防抖 + 用户编辑时弹窗选模式)
const handleRegenerate = async () => {
// 防抖:正在生成中跳过
if (regenerating.value) return;
let mode: "full" | "incremental" = "full";
// 如果用户编辑过,弹窗选择生成模式
if (standard.value?.generator === "USER" || standard.value?.generator === "USER_MERGE") {
try {
await ElMessageBox.confirm(
"检测到您已编辑过标准思维导图,请选择重新生成方式:",
"重新生成",
{
confirmButtonText: "全量重新生成",
cancelButtonText: "增量更新(保留编辑)",
type: "warning",
distinguishCancelAndClose: true,
}
);
// confirm → 全量(保持 mode = "full"
} catch (e: any) {
if (e === "cancel" || e?.toString()?.includes("cancel")) {
mode = "incremental";
} else {
return; // 点 X 关闭 → 不做操作
}
}
} else {
// 未编辑过的简单确认
try {
await ElMessageBox.confirm("重新生成将从学习数据中重新生成标准思维导图,确定?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "info",
});
} catch {
return;
}
}
// 开始生成
regenerating.value = true;
regeneratingSeconds.value = 0;
regeneratingTimer = setInterval(() => { regeneratingSeconds.value++; }, 1000);
try {
const res = await regenerateStandardMindMap(taskNum.value, mode);
standard.value = res?.data || null;
ElMessage.success(mode === "incremental" ? "标准思维导图已增量更新" : "标准思维导图已重新生成");
} catch (e: any) {
ElMessage.error(e.message || "重新生成失败");
} finally {
regenerating.value = false;
if (regeneratingTimer) { clearInterval(regeneratingTimer); regeneratingTimer = null; }
}
};
// 提交回忆对比
const handleRecallCompare = async () => {
const outline = recallViewer.value?.toOutline() || "";
if (outline.split(/\r?\n/).filter((l) => l.trim()).length <= 1) {
ElMessage.warning("请先在回忆导图中添加节点(选中节点后按 Tab 加子节点)");
return;
}
comparing.value = true;
comparingSeconds.value = 0;
comparingTimer = setInterval(() => { comparingSeconds.value++; }, 1000);
try {
const res = await recallCompare(taskNum.value, outline, focusPath.value || undefined);
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;
if (comparingTimer) { clearInterval(comparingTimer); comparingTimer = null; }
}
};
// 清空重写
const resetRecall = () => {
recallTree.value = buildEmptyRecallTree();
};
// 返回完整标准导图,重新选择复习范围
const resetComparison = () => {
lastResult.value = null;
hasCompared.value = false;
focusPath.value = "";
recallTree.value = buildEmptyRecallTree();
};
// 编辑标准导图
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 saveStandardEditFromViewer = async () => {
const outline = editViewer.value?.toOutline() || "";
if (!outline.trim()) {
ElMessage.warning("导图内容不可为空");
return;
}
editOutline.value = outline;
await saveStandardEdit();
};
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);
// 将历史回忆内容还原到导图输入区
const tree = parseOutlineToTree(record.recallContent);
if (tree) recallTree.value = tree;
hasCompared.value = true;
} catch {
ElMessage.error("解析对比记录失败");
}
};
// 跳转到复习详情
const goToReviewDetail = (sourceType: string, sourceId: number) => {
router.push(`/review/detail/${sourceType.toLowerCase()}/${sourceId}`);
};
// 获取遗漏项列表
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(() => {
const raw = lastResult.value?.extraNodes || [];
// 兼容字符串数组和对象数组两种格式
return raw.map((item: any) => typeof item === "string" ? { title: item } : item);
});
// 节点选择回调(selectable 模式下点击节点)
const handleNodeSelect = (node: { title: string; path: string; childCount: number }) => {
selectedNodeInfo.value = node;
focusConfirmVisible.value = true;
};
// 确认以选中节点为起点
const confirmFocus = () => {
if (selectedNodeInfo.value) {
focusPath.value = selectedNodeInfo.value.path;
recallTree.value = {
title: selectedNodeInfo.value.title,
children: [],
};
}
focusConfirmVisible.value = false;
};
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="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">
<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>
<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>
</div>
</div>
</article>
<!-- 折叠时引导告知用户展开标准导图 -->
<div v-if="!focusPath && !hasCompared && !standardExpanded" class="start-guide surface-card">
<p>展开下方标准思维导图可点击节点选择复习起点然后在此凭记忆补全知识点</p>
</div>
<!-- 回忆输入可编辑思维导图 -->
<article class="surface-card recall-panel">
<div class="panel-header">
<h3>🧠 你的回忆</h3>
<div class="panel-actions">
<el-button type="success" :loading="comparing" @click="handleRecallCompare">
提交对比
</el-button>
<el-button v-if="hasCompared" @click="resetRecall">
清空重写
</el-button>
</div>
<p v-if="comparing" class="ai-waiting">
<template v-if="comparingSeconds < 3">正在提交 AI 任务</template>
<template v-else-if="comparingSeconds < 60">AI 对比分析中已等待 {{ comparingSeconds }} 请稍候</template>
<template v-else-if="comparingSeconds < 180">内容较多AI 正在深入分析已等待 {{ comparingSeconds }} </template>
<template v-else>即将完成已等待 {{ comparingSeconds }} 请耐心等待</template>
</p>
</div>
<p class="panel-hint">
凭记忆在导图上补全该任务的知识点选中节点后 Tab 加子节点Enter 加同级节点双击修改文字
</p>
<MindMapViewer ref="recallViewer" :tree="recallTree" :editable="true" height="60vh" />
</article>
<!-- 标准导图含对比着色 -->
<article class="surface-card standard-panel">
<div class="panel-header">
<h3>
{{ hasCompared ? '📖 标准思维导图(对比结果)' : '📖 标准思维导图' }}
</h3>
<div class="panel-actions">
<el-button
v-if="hasCompared"
size="small"
type="success"
@click="resetComparison"
>
返回完整导图
</el-button>
<el-button size="small" @click="standardExpanded = !standardExpanded">
{{ standardExpanded ? "折叠" : "展开" }}
</el-button>
<el-button
v-if="standardExpanded"
size="small"
type="warning"
:loading="regenerating"
@click="handleRegenerate"
>
重新生成
</el-button>
<el-button
v-if="standardExpanded && !editingStandard"
size="small"
type="primary"
@click="startEditStandard"
>
编辑
</el-button>
</div>
<p v-if="regenerating" class="ai-waiting" style="margin-top: 4px; width: 100%;">
<template v-if="regeneratingSeconds < 3">正在准备生成</template>
<template v-else>AI 正在生成中已等待 {{ regeneratingSeconds }} 请稍候</template>
</p>
</div>
<div v-if="!standard" class="empty-state">
<el-empty description="暂无标准思维导图" :image-size="64">
<el-button type="success" :loading="loading" @click="loadData">
生成
</el-button>
</el-empty>
</div>
<div v-else-if="editingStandard" class="edit-area">
<p class="panel-hint">直接在导图上编辑Tab 加子节点 / Enter 加同级 / 双击改文字保存后生效</p>
<MindMapViewer ref="editViewer" :tree="standardTree" :editable="true" height="60vh" />
<div class="edit-actions">
<el-button @click="cancelStandardEdit">取消</el-button>
<el-button type="success" :loading="savingStandard" @click="saveStandardEditFromViewer">
保存更新
</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>
<p v-if="!focusPath && !hasCompared" class="select-hint">
点击导图节点即可选择复习起点选好后凭记忆在上方补全知识点
</p>
<MindMapViewer
v-if="standardTree"
:tree="compareTree || standardTree"
:color-by-compare="!!compareTree"
:selectable="!editingStandard && !hasCompared"
height="60vh"
@node-select="handleNodeSelect"
@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>
</div>
</article>
<!-- 遗漏项 + 额外项详情 -->
<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;
}
.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);
}
.surface-card h3 {
margin: 0 0 8px;
color: var(--green-900);
font-size: 16px;
}
.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; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.panel-header h3 {
margin: 0;
}
.panel-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.panel-hint {
font-size: 13px;
color: var(--text-secondary, #666);
margin: 0 0 12px;
line-height: 1.5;
}
.ai-waiting {
font-size: 13px;
color: #e6a23c;
margin: 4px 0 0;
display: flex;
align-items: center;
gap: 6px;
}
.ai-waiting::before {
content: "";
width: 14px;
height: 14px;
border: 2px solid #f3d19e;
border-top-color: #e6a23c;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.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;
}
.edit-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.detail-section {
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 {
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);
}
/* 复习范围选择器 */
.review-scope {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 12px 22px;
}
.scope-label {
font-size: 13px;
font-weight: 600;
color: var(--green-800);
white-space: nowrap;
}
.scope-hint {
font-size: 12px;
color: var(--text-secondary, #999);
}
.session-badge {
flex-shrink: 0;
}
.start-guide {
padding: 14px 22px;
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border-left: 4px solid #ffc107;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
color: #856404;
}
.start-guide p {
margin: 0;
}
.select-hint {
font-size: 13px;
color: #856404;
margin: 0 0 8px;
padding: 6px 12px;
background: #fdf6ec;
border-radius: 4px;
border-left: 3px solid #ffc107;
}
</style>