feat: upload review mind map files

This commit is contained in:
2026-06-20 10:33:14 +08:00
parent 8ed1d45ae1
commit dc7469ec3f
3 changed files with 176 additions and 45 deletions
+10
View File
@@ -23,6 +23,7 @@ import {
getTaskReview, getTaskReview,
updateReviewApplication, updateReviewApplication,
upsertReviewMindMap, upsertReviewMindMap,
uploadReviewMindMap,
} from '@/api/review' } from '@/api/review'
const mockedRequest = vi.mocked(request) const mockedRequest = vi.mocked(request)
@@ -111,6 +112,7 @@ describe('review API', () => {
it('review mind map API calls expected endpoints', async () => { it('review mind map API calls expected endpoints', async () => {
mockedRequest.get.mockResolvedValue({ code: 200, data: null }) mockedRequest.get.mockResolvedValue({ code: 200, data: null })
mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } }) mockedRequest.put.mockResolvedValue({ code: 200, data: { id: 1 } })
mockedRequest.post.mockResolvedValue({ code: 200, data: { id: 2 } })
await getReviewMindMap('T001') await getReviewMindMap('T001')
expect(mockedRequest.get).toHaveBeenCalledWith('/review/mind-map/T001') expect(mockedRequest.get).toHaveBeenCalledWith('/review/mind-map/T001')
@@ -125,5 +127,13 @@ describe('review API', () => {
content: 'content', content: 'content',
contentFormat: 'TEXT', 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' } },
)
}) })
}) })
+17
View File
@@ -40,6 +40,15 @@ export interface ReviewMindMap {
title: string; title: string;
content: string; content: string;
contentFormat: "TEXT" | "MERMAID" | "JSON"; 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; createdTime?: string;
lastModifiedTime?: string; lastModifiedTime?: string;
} }
@@ -112,3 +121,11 @@ export const upsertReviewMindMap = (
) => { ) => {
return request.put(`/review/mind-map/${taskNum}`, payload); 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" },
});
};
+149 -45
View File
@@ -10,7 +10,7 @@ import {
getReviewMindMap, getReviewMindMap,
getTaskReview, getTaskReview,
updateReviewApplication, updateReviewApplication,
upsertReviewMindMap, uploadReviewMindMap,
type ReviewApplication, type ReviewApplication,
type ReviewFeedItem, type ReviewFeedItem,
type ReviewMindMap, type ReviewMindMap,
@@ -50,16 +50,7 @@ const applicationForm = ref<{
}); });
const mindMap = ref<ReviewMindMap | null>(null); const mindMap = ref<ReviewMindMap | null>(null);
const mindMapSaving = ref(false); const mindMapUploading = ref(false);
const mindMapForm = ref<{
title: string;
content: string;
contentFormat: ReviewMindMap["contentFormat"];
}>({
title: "任务思维导图",
content: "",
contentFormat: "TEXT",
});
const sessionInfo = ref<{ const sessionInfo = ref<{
taskName: string; taskName: string;
@@ -83,6 +74,40 @@ const statusText: Record<ReviewApplication["status"], string> = {
DONE: "已完成", DONE: "已完成",
}; };
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 => { const formatDuration = (seconds?: number): string => {
if (seconds == null) return "-"; if (seconds == null) return "-";
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
@@ -146,17 +171,6 @@ const loadReviewExtensions = async (tn: string) => {
const mindMapRes = await getReviewMindMap(tn).catch(() => null); const mindMapRes = await getReviewMindMap(tn).catch(() => null);
const loadedMindMap: ReviewMindMap | null = mindMapRes?.data || null; const loadedMindMap: ReviewMindMap | null = mindMapRes?.data || null;
mindMap.value = loadedMindMap; mindMap.value = loadedMindMap;
mindMapForm.value = loadedMindMap
? {
title: loadedMindMap.title || "任务思维导图",
content: loadedMindMap.content || "",
contentFormat: loadedMindMap.contentFormat || "TEXT",
}
: {
title: `${sessionInfo.value?.taskName || "任务"}思维导图`,
content: "",
contentFormat: "TEXT",
};
}; };
const loadDetail = async () => { const loadDetail = async () => {
@@ -298,22 +312,26 @@ const removeApplication = async (item: ReviewApplication) => {
} }
}; };
const saveMindMap = async () => { const handleMindMapFileChange = async (uploadFile: any) => {
if (!taskNum.value) { if (!taskNum.value) {
ElMessage.warning("未找到关联任务"); ElMessage.warning("未找到关联任务");
return; return;
} }
if (!mindMapForm.value.title.trim() || !mindMapForm.value.content.trim()) { const file = uploadFile.raw as File | undefined;
ElMessage.warning("思维导图标题和内容不能为空"); if (!file) {
return; return;
} }
mindMapSaving.value = true; mindMapUploading.value = true;
try { try {
const res = await upsertReviewMindMap(taskNum.value, mindMapForm.value); const res = await uploadReviewMindMap(taskNum.value, file);
mindMap.value = res?.data || null; mindMap.value = res?.data || null;
ElMessage.success("思维导图已保存"); if (mindMap.value?.parseStatus === "FAILED") {
ElMessage.warning("文件已上传,但解析失败");
} else {
ElMessage.success("思维导图已解析");
}
} finally { } finally {
mindMapSaving.value = false; mindMapUploading.value = false;
} }
}; };
@@ -434,27 +452,48 @@ watch(
<article class="surface-card extension-card"> <article class="surface-card extension-card">
<div class="extension-head"> <div class="extension-head">
<h3>思维导图</h3> <h3>思维导图</h3>
<el-tag size="small" type="warning" effect="plain">{{ mindMap?.contentFormat || mindMapForm.contentFormat }}</el-tag> <el-tag size="small" :type="mindMapStatusType" effect="plain">
{{ mindMap?.parseStatus || "未上传" }}
</el-tag>
</div> </div>
<div class="mind-map-form"> <div class="mind-map-form">
<div class="form-row"> <div class="mind-map-upload-row">
<el-input v-model="mindMapForm.title" placeholder="标题" /> <el-upload
<el-select v-model="mindMapForm.contentFormat" placeholder="格式"> :auto-upload="false"
<el-option label="文本" value="TEXT" /> :show-file-list="false"
<el-option label="Mermaid" value="MERMAID" /> accept=".xmind,.md,.markdown,.opml,.mm,.txt"
<el-option label="JSON" value="JSON" /> :on-change="handleMindMapFileChange"
</el-select> >
<el-button type="success" :loading="mindMapUploading">上传导图文件</el-button>
</el-upload>
<el-tag v-if="mindMap?.fileFormat" size="small" effect="plain">
{{ mindMap.fileFormat }}
</el-tag>
</div> </div>
<el-input
v-model="mindMapForm.content" <div v-if="mindMap" class="mind-map-preview">
type="textarea" <div class="mind-map-meta">
:rows="12" <strong>{{ mindMap.title }}</strong>
placeholder="思维导图内容" <span v-if="mindMap.fileName">{{ mindMap.fileName }}</span>
/> </div>
<div class="edit-actions"> <p v-if="mindMap.summary" class="mind-map-summary">{{ mindMap.summary }}</p>
<el-button type="success" :loading="mindMapSaving" @click="saveMindMap">保存</el-button> <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> </div>
<el-empty v-else description="暂无思维导图" :image-size="64" />
</div> </div>
</article> </article>
</section> </section>
@@ -636,6 +675,71 @@ watch(
gap: 10px; 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);
}
.form-row { .form-row {
display: flex; display: flex;
align-items: center; align-items: center;