imetting_frontend/src/components/MindMap.jsx

338 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useState, useEffect, useRef } from 'react';
import { Transformer } from 'markmap-lib';
import { Markmap } from 'markmap-view';
import apiClient from '../utils/apiClient';
import { API_ENDPOINTS } from '../config/api';
import { Brain, Image, Loader } from 'lucide-react';
import html2canvas from 'html2canvas';
const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
const [markdown, setMarkdown] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [hasSummary, setHasSummary] = useState(false);
const svgRef = useRef(null);
const markmapRef = useRef(null);
useEffect(() => {
const fetchSummary = async () => {
try {
setLoading(true);
const endpoint = API_ENDPOINTS.MEETINGS.DETAIL(meetingId);
const response = await apiClient.get(endpoint);
const summary = response.data?.summary;
if (summary) {
setMarkdown(summary);
setHasSummary(true);
} else {
setMarkdown('# 暂无会议总结\n\n请先生成AI总结才能查看思维导图。');
setHasSummary(false);
}
} catch (err) {
console.error('Failed to fetch summary for mind map:', err);
setError('无法加载会议总结内容。');
setMarkdown('# 加载失败\n\n无法加载会议总结内容请稍后重试。');
setHasSummary(false);
} finally {
setLoading(false);
}
};
if (meetingId) {
fetchSummary();
}
}, [meetingId]);
// 提取关键短语的函数
const extractKeyPhrases = (text) => {
// 移除markdown格式
const cleanText = text.replace(/\*\*([^*]+)\*\*/g, '$1');
// 按标点符号分割
const phrases = cleanText.split(/[,。;、:]/);
const keyPhrases = [];
phrases.forEach(phrase => {
const trimmed = phrase.trim();
// 只保留包含重要关键词的短语,且长度适中
if (trimmed.length > 4 && trimmed.length < 40) {
const hasKeywords = /(?:项目|收入|问题|产品|团队|开发|验收|成本|功能|市场|合作|资源|计划|目标|业务|投入|效率|协作|管理|分析|讨论|决策|优化|整合)/.test(trimmed);
if (hasKeywords) {
keyPhrases.push(trimmed);
}
}
});
// 如果没有找到关键短语,至少保留一个总结性的短语
if (keyPhrases.length === 0 && phrases.length > 0) {
const firstPhrase = phrases[0].trim();
if (firstPhrase.length > 0 && firstPhrase.length < 50) {
keyPhrases.push(firstPhrase);
}
}
// 最多返回3个关键短语
return keyPhrases.slice(0, 3);
};
// 预处理markdown确保格式适合生成思维导图
const preprocessMarkdownForMindMap = (markdown) => {
if (!markdown || markdown.trim() === '') return '# 暂无内容';
let processed = markdown.trim();
// 移除分隔线
processed = processed.replace(/^---+$/gm, '');
// 如果没有主标题,添加一个
if (!processed.startsWith('# ')) {
processed = `# 会议总结\n\n${processed}`;
}
const lines = processed.split('\n');
const processedLines = [];
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
if (line === '') {
i++;
continue;
}
// 处理标题行 - 保持标题格式不变
if (line.match(/^#+\s+/)) {
// 清理标题格式,移除粗体和多余符号,但保持标题符号
let cleanTitle = line.replace(/\*\*([^*]+)\*\*/g, '$1'); // 移除粗体
processedLines.push(cleanTitle);
// 获取标题级别
const titleLevel = (line.match(/^#+/) || [''])[0].length;
}
// 处理现有列表项(有序和无序) - 保持其原始结构
else if (line.match(/^\s*([-*+]|\d+\.)\s+/)) {
// 只移除加粗格式,保留原始行,包括缩进和列表标记
const cleanedLine = line.replace(/\*\*([^*]+)\*\*/g, '$1');
processedLines.push(cleanedLine);
}
// 将区块引用转换为列表项
else if (line.startsWith('>')) {
const content = line.replace(/^>+\s*/, '');
processedLines.push(`- ${content}`);
}
// 保持表格原样让markmap自己处理
else if (line.includes('|')) {
processedLines.push(line);
}
// 处理其他普通段落 - 保留原样
else if (line.length > 0 && !line.match(/\*\*总字数:\d+字\*\*/)) {
processedLines.push(line);
}
i++;
}
// 清理结果
const result = processedLines
.filter(line => line.trim().length > 0)
.join('\n');
return result;
};
useEffect(() => {
if (loading || !markdown || !svgRef.current) return;
try {
const processedMarkdown = preprocessMarkdownForMindMap(markdown);
console.log('=== 思维导图数据调试 ===');
console.log('原始markdown内容:');
console.log(markdown);
console.log('预处理后的markdown:');
console.log(processedMarkdown);
const transformer = new Transformer();
const { root } = transformer.transform(processedMarkdown);
if (markmapRef.current) {
markmapRef.current.setData(root);
} else {
markmapRef.current = Markmap.create(svgRef.current, null, root);
}
markmapRef.current.fit();
// 延迟一下再次调用fit确保内容完全渲染
setTimeout(() => {
if (markmapRef.current) {
markmapRef.current.fit();
}
}, 500);
} catch (error) {
console.error('思维导图渲染失败:', error);
setError('思维导图渲染失败');
}
}, [markdown, loading]);
const handleExportImage = async () => {
if (!svgRef.current || !hasSummary) {
alert('思维导图尚未渲染或无总结内容,无法导出。');
return;
}
try {
// 获取SVG元素
const svgElement = svgRef.current;
// 获取SVG的viewBox或实际内容边界
const svgBBox = svgElement.querySelector('g')?.getBBox() || { width: 800, height: 600 };
const svgRect = svgElement.getBoundingClientRect();
// 使用更保守的尺寸计算,确保内容完整
const contentWidth = Math.max(svgBBox.width, svgRect.width, 800);
const contentHeight = Math.max(svgBBox.height, svgRect.height, 400);
// 添加足够的边距
const containerWidth = contentWidth + 200; // 左右各100px边距
const containerHeight = contentHeight + 200; // 上下各100px边距
// 创建导出容器
const exportContainer = document.createElement('div');
exportContainer.style.cssText = `
position: fixed;
top: -10000px;
left: -10000px;
width: ${containerWidth}px;
background: white;
padding: 40px;
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
`;
// 克隆SVG元素
const svgClone = svgElement.cloneNode(true);
svgClone.style.width = `${contentWidth}px`;
svgClone.style.height = `${contentHeight}px`;
svgClone.style.display = 'block';
exportContainer.innerHTML = `
<div style="margin-bottom: 40px;">
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
${meetingTitle || '会议思维导图'}
</h1>
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
📋 会议信息
</h2>
${meeting ? `
<p style="margin: 12px 0; font-size: 16px;"><strong>会议时间:</strong>${formatDateTime ? formatDateTime(meeting.meeting_time) : new Date(meeting.meeting_time).toLocaleString('zh-CN')}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>创建人:</strong>${meeting.creator_username}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人数:</strong>${meeting.attendees.length}人</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人员:</strong>${meeting.attendees.map(attendee =>
typeof attendee === 'string' ? attendee : attendee.caption
).join('、')}</p>
` : `
<p style="margin: 12px 0; font-size: 16px; color: #6b7280;">会议信息加载中...</p>
`}
</div>
<div>
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
🧠 思维导图
</h2>
<div id="mindmap-wrapper" style="
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 30px;
background: #fafafa;
display: flex;
justify-content: center;
align-items: center;
width: ${contentWidth + 60}px;
height: ${contentHeight + 60}px;
overflow: visible;
">
</div>
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
导出时间:${new Date().toLocaleString('zh-CN')}
</div>
</div>
`;
// 添加SVG到容器
const mapContainer = exportContainer.querySelector('#mindmap-wrapper');
mapContainer.appendChild(svgClone);
document.body.appendChild(exportContainer);
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 500));
// 使用html2canvas生成图片
const canvas = await html2canvas(exportContainer, {
width: containerWidth + 80, // 加上padding
height: exportContainer.offsetHeight,
scale: 2, // 高分辨率
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0,
logging: false
});
// 创建下载链接
const link = document.createElement('a');
link.download = `${meetingTitle || '思维导图'}_脑图_${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL('image/png', 1.0);
// 触发下载
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理DOM
document.body.removeChild(exportContainer);
} catch (error) {
console.error('图片导出失败:', error);
alert('图片导出失败,请重试。');
}
};
if (loading) {
return (
<div className="mindmap-loading">
<Loader className="animate-spin" />
<p>正在加载思维导图...</p>
</div>
);
}
return (
<div className="mindmap-container">
<div className="mindmap-header">
<h3><Brain size={18} /> 思维导图</h3>
{hasSummary && (
<button onClick={handleExportImage} className="export-pdf-btn-main" disabled={loading || !!error}>
<Image size={16} />
<span>导出图片</span>
</button>
)}
</div>
<div className="markmap-render-area">
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
};
export default MindMap;