891 lines
25 KiB
Vue
891 lines
25 KiB
Vue
<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>
|