feat: upload review mind map files
This commit is contained in:
@@ -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' } },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user