2025-09-16 09:00:09 +00:00
|
|
|
|
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';
|
2025-09-16 11:16:21 +00:00
|
|
|
|
import { Brain, Image, Loader } from 'lucide-react';
|
|
|
|
|
|
import html2canvas from 'html2canvas';
|
2025-09-16 09:00:09 +00:00
|
|
|
|
|
2025-09-16 11:16:21 +00:00
|
|
|
|
const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
|
2025-09-16 09:00:09 +00:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-09-16 11:16:21 +00:00
|
|
|
|
// 提取关键短语的函数
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-25 03:47:21 +00:00
|
|
|
|
// 处理标题行 - 保持标题格式不变
|
2025-09-16 11:16:21 +00:00
|
|
|
|
if (line.match(/^#+\s+/)) {
|
2025-09-25 03:47:21 +00:00
|
|
|
|
// 清理标题格式,移除粗体和多余符号,但保持标题符号
|
2025-09-16 11:16:21 +00:00
|
|
|
|
let cleanTitle = line.replace(/\*\*([^*]+)\*\*/g, '$1'); // 移除粗体
|
|
|
|
|
|
processedLines.push(cleanTitle);
|
|
|
|
|
|
|
2025-09-25 03:47:21 +00:00
|
|
|
|
// 获取标题级别
|
|
|
|
|
|
const titleLevel = (line.match(/^#+/) || [''])[0].length;
|
2025-09-16 11:16:21 +00:00
|
|
|
|
}
|
2025-09-25 03:47:21 +00:00
|
|
|
|
// 处理现有列表项(有序和无序) - 保持其原始结构
|
|
|
|
|
|
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}`);
|
2025-09-16 11:16:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
// 保持表格原样,让markmap自己处理
|
|
|
|
|
|
else if (line.includes('|')) {
|
|
|
|
|
|
processedLines.push(line);
|
|
|
|
|
|
}
|
2025-09-25 03:47:21 +00:00
|
|
|
|
// 处理其他普通段落 - 保留原样
|
2025-09-16 11:16:21 +00:00
|
|
|
|
else if (line.length > 0 && !line.match(/\*\*总字数:\d+字\*\*/)) {
|
2025-09-25 03:47:21 +00:00
|
|
|
|
processedLines.push(line);
|
2025-09-16 11:16:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
i++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清理结果
|
|
|
|
|
|
const result = processedLines
|
|
|
|
|
|
.filter(line => line.trim().length > 0)
|
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-16 09:00:09 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (loading || !markdown || !svgRef.current) return;
|
|
|
|
|
|
|
2025-09-16 11:16:21 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const processedMarkdown = preprocessMarkdownForMindMap(markdown);
|
2025-09-25 03:47:21 +00:00
|
|
|
|
console.log('=== 思维导图数据调试 ===');
|
|
|
|
|
|
console.log('原始markdown内容:');
|
|
|
|
|
|
console.log(markdown);
|
|
|
|
|
|
console.log('预处理后的markdown:');
|
|
|
|
|
|
console.log(processedMarkdown);
|
2025-09-16 11:16:21 +00:00
|
|
|
|
|
|
|
|
|
|
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('思维导图渲染失败');
|
2025-09-16 09:00:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}, [markdown, loading]);
|
|
|
|
|
|
|
2025-09-16 11:16:21 +00:00
|
|
|
|
const handleExportImage = async () => {
|
|
|
|
|
|
if (!svgRef.current || !hasSummary) {
|
|
|
|
|
|
alert('思维导图尚未渲染或无总结内容,无法导出。');
|
2025-09-16 09:00:09 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 获取SVG元素
|
|
|
|
|
|
const svgElement = svgRef.current;
|
|
|
|
|
|
|
2025-09-16 11:16:21 +00:00
|
|
|
|
// 获取SVG的viewBox或实际内容边界
|
|
|
|
|
|
const svgBBox = svgElement.querySelector('g')?.getBBox() || { width: 800, height: 600 };
|
2025-09-16 09:00:09 +00:00
|
|
|
|
const svgRect = svgElement.getBoundingClientRect();
|
|
|
|
|
|
|
2025-09-16 11:16:21 +00:00
|
|
|
|
// 使用更保守的尺寸计算,确保内容完整
|
|
|
|
|
|
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);
|
2025-09-16 09:00:09 +00:00
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
2025-09-16 11:16:21 +00:00
|
|
|
|
console.error('图片导出失败:', error);
|
|
|
|
|
|
alert('图片导出失败,请重试。');
|
2025-09-16 09:00:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 && (
|
2025-09-16 11:16:21 +00:00
|
|
|
|
<button onClick={handleExportImage} className="export-pdf-btn-main" disabled={loading || !!error}>
|
|
|
|
|
|
<Image size={16} />
|
|
|
|
|
|
<span>导出图片</span>
|
2025-09-16 09:00:09 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="markmap-render-area">
|
|
|
|
|
|
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MindMap;
|