Compare commits
2 Commits
e66bdb79dc
...
e65b800e01
| Author | SHA1 | Date | |
|---|---|---|---|
| e65b800e01 | |||
| 1ea15db504 |
@@ -14,12 +14,9 @@ import {
|
||||
getFragmentDetail,
|
||||
getReportDetail,
|
||||
getReviewFeed,
|
||||
getReviewMindMap,
|
||||
getReviewTaskStats,
|
||||
getReviewTaskStatsByTask,
|
||||
getTaskReview,
|
||||
upsertReviewMindMap,
|
||||
uploadReviewMindMap,
|
||||
} from '@/api/review'
|
||||
|
||||
const mockedRequest = vi.mocked(request)
|
||||
@@ -79,31 +76,4 @@ describe('review API', () => {
|
||||
expect(mockedRequest.get).toHaveBeenCalledWith('/review/task/T001')
|
||||
})
|
||||
|
||||
it('review mind map API calls expected endpoints', async () => {
|
||||
mockedRequest.get.mockResolvedValue({ code: 200, data: null })
|
||||
mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } })
|
||||
mockedRequest.post.mockResolvedValue({ code: 200, data: { id: 2 } })
|
||||
|
||||
await getReviewMindMap('T001')
|
||||
expect(mockedRequest.get).toHaveBeenCalledWith('/review/mind-map/T001')
|
||||
|
||||
await upsertReviewMindMap('T001', {
|
||||
title: 'Map',
|
||||
content: 'content',
|
||||
contentFormat: 'TEXT',
|
||||
})
|
||||
expect(mockedRequest.put).toHaveBeenCalledWith('/review/mind-map/T001', {
|
||||
title: 'Map',
|
||||
content: 'content',
|
||||
contentFormat: 'TEXT',
|
||||
})
|
||||
|
||||
const file = new File(['# Java'], 'java.md', { type: 'text/markdown' })
|
||||
await uploadReviewMindMap('T001', file)
|
||||
expect(mockedRequest.post).toHaveBeenCalledWith(
|
||||
'/review/mind-map/T001/upload',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,25 +23,6 @@ export interface ReviewTaskStats {
|
||||
avgEffectivenessRatio: number;
|
||||
}
|
||||
|
||||
export interface ReviewMindMap {
|
||||
id: number;
|
||||
taskNum: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentFormat: "TEXT" | "MERMAID" | "JSON";
|
||||
sourceType?: "MANUAL" | "FILE";
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
fileFormat?: "XMIND" | "MARKDOWN" | "OPML" | "FREEMIND" | "TEXT";
|
||||
parsedContent?: string;
|
||||
parseStatus?: "SUCCESS" | "FAILED";
|
||||
parseError?: string;
|
||||
summary?: string;
|
||||
lastParsedTime?: string;
|
||||
createdTime?: string;
|
||||
lastModifiedTime?: string;
|
||||
}
|
||||
|
||||
export const getReviewFeed = (limit: number = 30, mode: "recent" | "random" | "smart" = "recent") => {
|
||||
return request.get("/review/feed", { limit, mode });
|
||||
};
|
||||
@@ -65,26 +46,3 @@ export const getFragmentDetail = (id: number) => {
|
||||
export const getTaskReview = (taskNum: string) => {
|
||||
return request.get(`/review/task/${taskNum}`);
|
||||
};
|
||||
|
||||
export const getReviewMindMap = (taskNum: string) => {
|
||||
return request.get(`/review/mind-map/${taskNum}`);
|
||||
};
|
||||
|
||||
export const upsertReviewMindMap = (
|
||||
taskNum: string,
|
||||
payload: {
|
||||
title: string;
|
||||
content: string;
|
||||
contentFormat?: ReviewMindMap["contentFormat"];
|
||||
},
|
||||
) => {
|
||||
return request.put(`/review/mind-map/${taskNum}`, payload);
|
||||
};
|
||||
|
||||
export const uploadReviewMindMap = (taskNum: string, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request.post(`/review/mind-map/${taskNum}/upload`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -99,11 +99,26 @@ const toElixirNode = (node: MindMapTreeNode, ancestors: string[] = []): any => {
|
||||
|
||||
const toElixirData = (tree: MindMapTreeNode): MindElixirData => {
|
||||
nodeCounter = 0;
|
||||
const rootTitle = tree.title || "思维导图";
|
||||
// register root node in path map so clicking it also works
|
||||
if (props.selectable) {
|
||||
nodePathMap["root"] = {
|
||||
title: rootTitle,
|
||||
path: rootTitle,
|
||||
childCount: (tree.children || []).reduce(
|
||||
(sum, c) => sum + 1 + countAllDescendants(c),
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
nodeData: {
|
||||
id: "root",
|
||||
topic: tree.title || "思维导图",
|
||||
children: (tree.children || []).map(c => toElixirNode(c)),
|
||||
topic: rootTitle,
|
||||
// pass root title as ancestor so child paths include it (e.g. "根 / 子节点")
|
||||
children: (tree.children || []).map((c) =>
|
||||
toElixirNode(c, [rootTitle]),
|
||||
),
|
||||
},
|
||||
} as MindElixirData;
|
||||
};
|
||||
@@ -147,18 +162,31 @@ const render = () => {
|
||||
});
|
||||
}
|
||||
|
||||
instance.bus.addListener("selectNewNode", (node: any) => {
|
||||
if (props.selectable && node?.id) {
|
||||
const info = nodePathMap[node.id];
|
||||
// mind-elixir v5: selectNode(el) is called on every node click, but the
|
||||
// selectNewNode event is never fired (it requires selectNode(el, true),
|
||||
// which the default click handler never passes). Monkey-patch selectNode
|
||||
// to hook into node clicks for both selectable and source-navigation modes.
|
||||
const origSelectNode = instance.selectNode.bind(instance);
|
||||
instance.selectNode = function (el: any, fireEvent?: boolean) {
|
||||
origSelectNode(el, fireEvent);
|
||||
if (!el?.nodeObj) return;
|
||||
const nodeData = el.nodeObj;
|
||||
// selectable mode → emit node-select with title/path/childCount
|
||||
if (props.selectable && nodeData?.id) {
|
||||
const info = nodePathMap[nodeData.id];
|
||||
if (info) {
|
||||
emit("node-select", info);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (node?.sourceType && node?.sourceId != null) {
|
||||
emit("node-click", { sourceType: String(node.sourceType), sourceId: Number(node.sourceId) });
|
||||
}
|
||||
// source navigation → emit node-click with sourceType/sourceId
|
||||
if (nodeData?.sourceType && nodeData?.sourceId != null) {
|
||||
emit("node-click", {
|
||||
sourceType: String(nodeData.sourceType),
|
||||
sourceId: Number(nodeData.sourceId),
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -4,11 +4,8 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
getFragmentDetail,
|
||||
getReportDetail,
|
||||
getReviewMindMap,
|
||||
getTaskReview,
|
||||
uploadReviewMindMap,
|
||||
type ReviewFeedItem,
|
||||
type ReviewMindMap,
|
||||
} from "@/api/review";
|
||||
import { getSessionDetail } from "@/api/studySessions";
|
||||
import { updateFragments } from "@/api/reportFragments";
|
||||
@@ -50,9 +47,6 @@ const isEditing = ref(false);
|
||||
const editContent = ref("");
|
||||
const saving = ref(false);
|
||||
|
||||
const mindMap = ref<ReviewMindMap | null>(null);
|
||||
const mindMapUploading = ref(false);
|
||||
|
||||
const sessionInfo = ref<{
|
||||
taskName: string;
|
||||
sessionNum: string;
|
||||
@@ -69,40 +63,6 @@ const taskSessions = ref<{
|
||||
fragments: ReviewFeedItem[];
|
||||
}[]>([]);
|
||||
|
||||
type MindMapNode = {
|
||||
title?: string;
|
||||
notes?: string;
|
||||
children?: MindMapNode[];
|
||||
};
|
||||
|
||||
const flattenedMindMapNodes = computed(() => {
|
||||
const parsedContent = mindMap.value?.parsedContent;
|
||||
if (!parsedContent) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(parsedContent) as MindMapNode & { nodes?: MindMapNode[] };
|
||||
const rows: { title: string; level: number }[] = [];
|
||||
const walk = (node: MindMapNode, level: number) => {
|
||||
if (node.title) {
|
||||
rows.push({ title: node.title, level });
|
||||
}
|
||||
(node.children || []).forEach(child => walk(child, level + 1));
|
||||
};
|
||||
rows.push({ title: parsed.title || mindMap.value?.title || "思维导图", level: 0 });
|
||||
(parsed.nodes || parsed.children || []).forEach(node => walk(node, 1));
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const mindMapStatusType = computed(() => {
|
||||
if (mindMap.value?.parseStatus === "FAILED") return "danger";
|
||||
if (mindMap.value?.parseStatus === "SUCCESS") return "success";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (seconds == null) return "-";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -149,12 +109,6 @@ const loadTaskHistory = async (tn: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadReviewExtensions = async (tn: string) => {
|
||||
const mindMapRes = await getReviewMindMap(tn).catch(() => null);
|
||||
const loadedMindMap: ReviewMindMap | null = mindMapRes?.data || null;
|
||||
mindMap.value = loadedMindMap;
|
||||
};
|
||||
|
||||
const loadDetail = async () => {
|
||||
loading.value = true;
|
||||
isEditing.value = false;
|
||||
@@ -164,7 +118,6 @@ const loadDetail = async () => {
|
||||
taskNum.value = "";
|
||||
sessionInfo.value = null;
|
||||
taskSessions.value = [];
|
||||
mindMap.value = null;
|
||||
try {
|
||||
let res;
|
||||
if (contentType.value === "report") {
|
||||
@@ -187,7 +140,6 @@ const loadDetail = async () => {
|
||||
|
||||
if (info?.taskNum) {
|
||||
await loadTaskHistory(info.taskNum);
|
||||
await loadReviewExtensions(info.taskNum);
|
||||
}
|
||||
} catch {
|
||||
// 非关键数据
|
||||
@@ -241,29 +193,6 @@ const saveEdit = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMindMapFileChange = async (uploadFile: any) => {
|
||||
if (!taskNum.value) {
|
||||
ElMessage.warning("未找到关联任务");
|
||||
return;
|
||||
}
|
||||
const file = uploadFile.raw as File | undefined;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
mindMapUploading.value = true;
|
||||
try {
|
||||
const res = await uploadReviewMindMap(taskNum.value, file);
|
||||
mindMap.value = res?.data || null;
|
||||
if (mindMap.value?.parseStatus === "FAILED") {
|
||||
ElMessage.warning("文件已上传,但解析失败");
|
||||
} else {
|
||||
ElMessage.success("思维导图已解析");
|
||||
}
|
||||
} finally {
|
||||
mindMapUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [contentType.value, contentId.value],
|
||||
() => {
|
||||
@@ -299,6 +228,14 @@ watch(
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="taskNum"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="goToRecall"
|
||||
>
|
||||
回忆复习
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="sessionInfo" class="session-stats">
|
||||
<span>实际:{{ formatDuration(sessionInfo.actualTime) }}</span>
|
||||
@@ -328,59 +265,6 @@ watch(
|
||||
</template>
|
||||
</article>
|
||||
|
||||
<section class="review-extension-grid" v-if="taskNum">
|
||||
<article class="surface-card extension-card">
|
||||
<div class="extension-head">
|
||||
<h3>思维导图</h3>
|
||||
<el-tag size="small" :type="mindMapStatusType" effect="plain">
|
||||
{{ mindMap?.parseStatus || "未上传" }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="mind-map-form">
|
||||
<div class="mind-map-upload-row">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xmind,.md,.markdown,.opml,.mm,.txt"
|
||||
:on-change="handleMindMapFileChange"
|
||||
>
|
||||
<el-button type="success" :loading="mindMapUploading">上传导图文件</el-button>
|
||||
</el-upload>
|
||||
<el-button type="success" size="small" @click="goToRecall">
|
||||
回忆复习
|
||||
</el-button>
|
||||
<el-tag v-if="mindMap?.fileFormat" size="small" effect="plain">
|
||||
{{ mindMap.fileFormat }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="mindMap" class="mind-map-preview">
|
||||
<div class="mind-map-meta">
|
||||
<strong>{{ mindMap.title }}</strong>
|
||||
<span v-if="mindMap.fileName">{{ mindMap.fileName }}</span>
|
||||
</div>
|
||||
<p v-if="mindMap.summary" class="mind-map-summary">{{ mindMap.summary }}</p>
|
||||
<p v-if="mindMap.parseStatus === 'FAILED'" class="mind-map-error">
|
||||
{{ mindMap.parseError || "解析失败" }}
|
||||
</p>
|
||||
<div v-if="flattenedMindMapNodes.length > 0" class="mind-map-outline">
|
||||
<div
|
||||
v-for="(node, idx) in flattenedMindMapNodes"
|
||||
:key="`${idx}-${node.title}`"
|
||||
class="mind-map-node"
|
||||
:style="{ paddingLeft: `${node.level * 16}px` }"
|
||||
>
|
||||
{{ node.title }}
|
||||
</div>
|
||||
</div>
|
||||
<pre v-else-if="mindMap.content" class="mind-map-content">{{ mindMap.content }}</pre>
|
||||
</div>
|
||||
<el-empty v-else description="暂无思维导图" :image-size="64" />
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- 该任务下的所有学习记录 -->
|
||||
<article class="surface-card history-card" v-if="taskSessions.length > 0">
|
||||
<h3>该任务下的所有学习记录</h3>
|
||||
@@ -486,101 +370,6 @@ watch(
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.review-extension-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.extension-card {
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.extension-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.extension-head h3 {
|
||||
margin: 0;
|
||||
color: var(--green-900);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mind-map-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mind-map-upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mind-map-preview {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: #fbfefc;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.mind-map-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mind-map-meta strong {
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mind-map-meta span,
|
||||
.mind-map-summary {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mind-map-summary,
|
||||
.mind-map-error {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.mind-map-error {
|
||||
color: #c62828;
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mind-map-outline {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mind-map-node {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mind-map-content {
|
||||
margin: 10px 0 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 历史记录卡片 */
|
||||
.history-card {
|
||||
padding: 20px 22px;
|
||||
@@ -692,10 +481,6 @@ watch(
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.review-extension-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -223,6 +223,14 @@ 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 || "";
|
||||
@@ -410,6 +418,11 @@ onMounted(() => {
|
||||
</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">
|
||||
@@ -438,8 +451,18 @@ onMounted(() => {
|
||||
<!-- 标准导图(含对比着色) -->
|
||||
<article class="surface-card standard-panel">
|
||||
<div class="panel-header">
|
||||
<h3>📖 标准思维导图</h3>
|
||||
<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>
|
||||
@@ -491,8 +514,8 @@ onMounted(() => {
|
||||
<span>来源:{{ standard.generator }}</span>
|
||||
<span>参考:{{ standard.sourceReportCount }} 份报告 + {{ standard.sourceFragmentCount }} 份残片</span>
|
||||
</div>
|
||||
<p v-if="!hasCompared && !focusPath" class="select-hint">
|
||||
点击导图节点选择复习起点,选好后开始回忆填空
|
||||
<p v-if="!focusPath && !hasCompared" class="select-hint">
|
||||
点击导图节点即可选择复习起点,选好后凭记忆在上方补全知识点。
|
||||
</p>
|
||||
<MindMapViewer
|
||||
v-if="standardTree"
|
||||
@@ -820,13 +843,27 @@ onMounted(() => {
|
||||
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: #e6a23c;
|
||||
color: #856404;
|
||||
margin: 0 0 8px;
|
||||
padding: 6px 12px;
|
||||
background: #fdf6ec;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #e6a23c;
|
||||
border-left: 3px solid #ffc107;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user