加入脑图功能
parent
6b329adce8
commit
c7846a9f03
File diff suppressed because it is too large
Load Diff
|
|
@ -14,6 +14,7 @@
|
|||
"antd": "^5.27.3",
|
||||
"axios": "^1.6.2",
|
||||
"canvg": "^4.0.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.2",
|
||||
"lucide-react": "^0.294.0",
|
||||
"markmap-common": "^0.18.9",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
import { FileText, Download, Brain } from 'lucide-react';
|
||||
import React, { useRef } from 'react';
|
||||
import { FileText, Download, Brain, Image } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
const MeetingSummary = ({
|
||||
meeting,
|
||||
|
|
@ -13,8 +14,9 @@ const MeetingSummary = ({
|
|||
onOpenSummaryModal,
|
||||
formatDateTime
|
||||
}) => {
|
||||
const exportRef = useRef(null);
|
||||
|
||||
const exportToPDF = async () => {
|
||||
const exportToImage = async () => {
|
||||
try {
|
||||
// 检查是否有总结内容
|
||||
let summaryContent = summaryResult?.content ||
|
||||
|
|
@ -26,7 +28,64 @@ const MeetingSummary = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 先渲染Markdown内容
|
||||
if (!exportRef.current) {
|
||||
alert('内容尚未渲染完成,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建完整的导出内容
|
||||
const exportContainer = document.createElement('div');
|
||||
exportContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
width: 800px;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
// 创建会议信息
|
||||
const meetingTime = formatDateTime(meeting.meeting_time);
|
||||
const attendeesList = meeting.attendees.map(attendee =>
|
||||
typeof attendee === 'string' ? attendee : attendee.caption
|
||||
).join('、');
|
||||
|
||||
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;">
|
||||
${meeting.title || '会议总结'}
|
||||
</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>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>会议时间:</strong>${meetingTime}</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>${attendeesList}</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="summary-content" style="font-size: 15px; line-height: 1.8;">
|
||||
</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>
|
||||
`;
|
||||
|
||||
document.body.appendChild(exportContainer);
|
||||
|
||||
// 渲染Markdown内容
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.display = 'none';
|
||||
document.body.appendChild(tempDiv);
|
||||
|
|
@ -36,7 +95,6 @@ const MeetingSummary = ({
|
|||
|
||||
const root = createRoot(tempDiv);
|
||||
|
||||
// 渲染Markdown内容并获取HTML
|
||||
await new Promise((resolve) => {
|
||||
root.render(
|
||||
React.createElement(ReactMarkdown, {
|
||||
|
|
@ -45,164 +103,114 @@ const MeetingSummary = ({
|
|||
children: summaryContent
|
||||
})
|
||||
);
|
||||
setTimeout(resolve, 100);
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
|
||||
// 获取渲染后的HTML并添加样式
|
||||
const renderedHTML = tempDiv.innerHTML;
|
||||
const summaryContentDiv = exportContainer.querySelector('#summary-content');
|
||||
summaryContentDiv.innerHTML = renderedHTML;
|
||||
|
||||
// 创建会议信息
|
||||
const meetingTime = formatDateTime(meeting.meeting_time);
|
||||
const attendeesList = meeting.attendees.map(attendee =>
|
||||
typeof attendee === 'string' ? attendee : attendee.caption
|
||||
).join('、');
|
||||
// 为渲染后的内容添加样式
|
||||
const styles = `
|
||||
<style>
|
||||
#summary-content h1 { color: #1e293b; font-size: 22px; margin: 25px 0 15px; font-weight: 600; }
|
||||
#summary-content h2 { color: #374151; font-size: 20px; margin: 20px 0 12px; font-weight: 600; }
|
||||
#summary-content h3 { color: #475569; font-size: 18px; margin: 18px 0 10px; font-weight: 600; }
|
||||
#summary-content p { margin: 12px 0; color: #475569; }
|
||||
#summary-content ul, #summary-content ol { margin: 12px 0; padding-left: 25px; }
|
||||
#summary-content li { margin: 8px 0; color: #475569; }
|
||||
#summary-content strong { color: #1e293b; font-weight: 600; }
|
||||
#summary-content blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: #f8fafc;
|
||||
margin: 15px 0;
|
||||
padding: 15px 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
#summary-content code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
#summary-content pre {
|
||||
background: #f8fafc;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#summary-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#summary-content th, #summary-content td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
#summary-content th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// 创建一个隐藏的iframe用于打印
|
||||
const printFrame = document.createElement('iframe');
|
||||
printFrame.style.position = 'fixed';
|
||||
printFrame.style.width = '0';
|
||||
printFrame.style.height = '0';
|
||||
printFrame.style.border = 'none';
|
||||
printFrame.style.left = '-9999px';
|
||||
document.body.appendChild(printFrame);
|
||||
exportContainer.insertAdjacentHTML('afterbegin', styles);
|
||||
|
||||
// 使用Blob和URL来确保编码正确
|
||||
const htmlContent = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>${meeting.title || '会议总结'}</title>
|
||||
<style>
|
||||
@charset "UTF-8";
|
||||
@page { size: A4; margin: 20mm; }
|
||||
body {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #2563eb;
|
||||
font-size: 24px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #374151;
|
||||
font-size: 18px;
|
||||
margin: 25px 0 15px;
|
||||
}
|
||||
h3 {
|
||||
color: #1e293b;
|
||||
font-size: 16px;
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
p {
|
||||
margin: 10px 0;
|
||||
color: #475569;
|
||||
}
|
||||
ul, ol {
|
||||
margin: 10px 0;
|
||||
padding-left: 25px;
|
||||
}
|
||||
li {
|
||||
margin: 5px 0;
|
||||
color: #475569;
|
||||
}
|
||||
strong {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.info-section {
|
||||
background: #f9fafb;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.info-section h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.content-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.footer-section {
|
||||
margin-top: 50px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
@media print {
|
||||
body { padding: 0; }
|
||||
h1 { page-break-before: avoid; }
|
||||
h2 { page-break-before: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${meeting.title || '会议总结'}</h1>
|
||||
<div class="info-section">
|
||||
<h2>会议信息</h2>
|
||||
<p><strong>会议时间:</strong>${meetingTime}</p>
|
||||
<p><strong>创建人:</strong>${meeting.creator_username}</p>
|
||||
<p><strong>参会人数:</strong>${meeting.attendees.length}人</p>
|
||||
<p><strong>参会人员:</strong>${attendeesList}</p>
|
||||
</div>
|
||||
<div class="content-section">
|
||||
<h2>会议摘要</h2>
|
||||
${renderedHTML}
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<p>导出时间:${new Date().toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
// 等待图片和样式加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 使用Blob创建URL以确保正确的编码
|
||||
const blob = new Blob([htmlContent], { type: 'text/html; charset=UTF-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// 使用html2canvas生成图片
|
||||
const canvas = await html2canvas(exportContainer, {
|
||||
width: 880,
|
||||
height: exportContainer.scrollHeight + 80,
|
||||
scale: 2, // 高分辨率
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
// 设置iframe的src为blob URL
|
||||
printFrame.src = url;
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.download = `${meeting.title || '会议总结'}_总结_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// 等待iframe加载完成
|
||||
printFrame.onload = () => {
|
||||
setTimeout(() => {
|
||||
// 执行打印
|
||||
printFrame.contentWindow.focus();
|
||||
printFrame.contentWindow.print();
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 清理资源
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
root.unmount();
|
||||
document.body.removeChild(tempDiv);
|
||||
document.body.removeChild(printFrame);
|
||||
}, 2000);
|
||||
}, 500);
|
||||
};
|
||||
// 清理DOM
|
||||
root.unmount();
|
||||
document.body.removeChild(tempDiv);
|
||||
document.body.removeChild(exportContainer);
|
||||
|
||||
} catch (error) {
|
||||
console.error('PDF导出失败:', error);
|
||||
alert('PDF导出失败,请重试。');
|
||||
console.error('图片导出失败:', error);
|
||||
alert('图片导出失败,请重试。');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="meeting-summary">
|
||||
<div className="meeting-summary" ref={exportRef}>
|
||||
<div className="summary-header">
|
||||
<h2><FileText size={20} /> 摘要内容</h2>
|
||||
{meeting?.summary && (
|
||||
<button
|
||||
className="export-pdf-btn-main"
|
||||
onClick={exportToPDF}
|
||||
title="导出PDF"
|
||||
onClick={exportToImage}
|
||||
title="导出图片"
|
||||
>
|
||||
<Download size={16} />
|
||||
<span>导出PDF</span>
|
||||
<Image size={16} />
|
||||
<span>导出图片</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { Transformer } from 'markmap-lib';
|
|||
import { Markmap } from 'markmap-view';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
import { Brain, Download, Loader } from 'lucide-react';
|
||||
import { Brain, Image, Loader } from 'lucide-react';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
const MindMap = ({ meetingId, meetingTitle }) => {
|
||||
const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
|
||||
const [markdown, setMarkdown] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
|
@ -44,170 +45,309 @@ const MindMap = ({ meetingId, meetingTitle }) => {
|
|||
}
|
||||
}, [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'); // 移除粗体
|
||||
cleanTitle = cleanTitle.replace(/[*_]/g, ''); // 移除其他markdown符号
|
||||
processedLines.push(cleanTitle);
|
||||
|
||||
// 查看下一个非空行
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() === '') {
|
||||
j++;
|
||||
}
|
||||
|
||||
// 如果下一行不是标题、列表或表格,将段落内容转换为列表项
|
||||
if (j < lines.length) {
|
||||
const nextLine = lines[j].trim();
|
||||
if (!nextLine.match(/^#+\s+/) && !nextLine.match(/^[-*+]\s+/) && !nextLine.includes('|') && nextLine.length > 0) {
|
||||
// 收集段落内容直到下一个标题、列表或表格
|
||||
const paragraphLines = [];
|
||||
while (j < lines.length) {
|
||||
const currentLine = lines[j].trim();
|
||||
if (currentLine === '') {
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (currentLine.match(/^#+\s+/) || currentLine.match(/^[-*+]\s+/) || currentLine.includes('|')) {
|
||||
break;
|
||||
}
|
||||
paragraphLines.push(currentLine);
|
||||
j++;
|
||||
}
|
||||
|
||||
// 将段落内容转换为列表项,只保留重点内容
|
||||
if (paragraphLines.length > 0) {
|
||||
const paragraphText = paragraphLines.join(' ');
|
||||
|
||||
// 提取加粗内容作为重点
|
||||
const boldMatches = paragraphText.match(/\*\*([^*]+)\*\*/g);
|
||||
if (boldMatches && boldMatches.length > 0) {
|
||||
// 只保留加粗的重点内容
|
||||
boldMatches.forEach(match => {
|
||||
const cleanText = match.replace(/\*\*/g, '').trim();
|
||||
if (cleanText.length > 0 && cleanText.length < 50) { // 避免过长的内容
|
||||
processedLines.push(`- ${cleanText}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果没有加粗内容,提取关键词汇或短句
|
||||
const keyPhrases = extractKeyPhrases(paragraphText);
|
||||
keyPhrases.forEach(phrase => {
|
||||
if (phrase.length > 0) {
|
||||
processedLines.push(`- ${phrase}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
i = j - 1; // j will be incremented at the end of the loop
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理列表项
|
||||
else if (line.match(/^[-*+]\s+/)) {
|
||||
const cleanListItem = line.replace(/\*\*([^*]+)\*\*/g, '$1'); // 移除粗体
|
||||
processedLines.push(cleanListItem);
|
||||
}
|
||||
// 保持表格原样,让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;
|
||||
|
||||
const transformer = new Transformer();
|
||||
const { root } = transformer.transform(markdown);
|
||||
try {
|
||||
const processedMarkdown = preprocessMarkdownForMindMap(markdown);
|
||||
console.log('原始markdown内容:', markdown);
|
||||
console.log('预处理后的markdown:', processedMarkdown);
|
||||
|
||||
if (markmapRef.current) {
|
||||
markmapRef.current.setData(root);
|
||||
} else {
|
||||
markmapRef.current = Markmap.create(svgRef.current, null, root);
|
||||
const transformer = new Transformer();
|
||||
const { root } = transformer.transform(processedMarkdown);
|
||||
|
||||
console.log('转换后的思维导图数据:', root);
|
||||
|
||||
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('思维导图渲染失败');
|
||||
}
|
||||
|
||||
markmapRef.current.fit();
|
||||
|
||||
}, [markdown, loading]);
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
if (!svgRef.current) {
|
||||
alert('思维导图尚未渲染,无法导出。');
|
||||
const handleExportImage = async () => {
|
||||
if (!svgRef.current || !hasSummary) {
|
||||
alert('思维导图尚未渲染或无总结内容,无法导出。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取SVG元素
|
||||
const svgElement = svgRef.current;
|
||||
const svgHTML = svgElement.outerHTML;
|
||||
|
||||
if (!svgHTML.trim()) {
|
||||
alert('思维导图内容为空,无法导出。');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取SVG的尺寸
|
||||
// 获取SVG的viewBox或实际内容边界
|
||||
const svgBBox = svgElement.querySelector('g')?.getBBox() || { width: 800, height: 600 };
|
||||
const svgRect = svgElement.getBoundingClientRect();
|
||||
const svgWidth = svgRect.width || 800;
|
||||
const svgHeight = svgRect.height || 600;
|
||||
|
||||
// 创建一个隐藏的iframe用于打印
|
||||
const printFrame = document.createElement('iframe');
|
||||
printFrame.style.position = 'fixed';
|
||||
printFrame.style.width = '0';
|
||||
printFrame.style.height = '0';
|
||||
printFrame.style.border = 'none';
|
||||
printFrame.style.left = '-9999px';
|
||||
document.body.appendChild(printFrame);
|
||||
// 使用更保守的尺寸计算,确保内容完整
|
||||
const contentWidth = Math.max(svgBBox.width, svgRect.width, 800);
|
||||
const contentHeight = Math.max(svgBBox.height, svgRect.height, 400);
|
||||
|
||||
// 创建HTML内容
|
||||
const htmlContent = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>${meetingTitle || '会议思维导图'}</title>
|
||||
<style>
|
||||
@charset "UTF-8";
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 15mm;
|
||||
}
|
||||
body {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei", sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 90vh;
|
||||
}
|
||||
h1 {
|
||||
color: #2563eb;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.mindmap-container {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
}
|
||||
svg {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
/* 脑图节点样式 */
|
||||
.markmap-node circle {
|
||||
fill: #3b82f6;
|
||||
stroke: #1d4ed8;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.markmap-node text {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
fill: #1e293b;
|
||||
}
|
||||
.markmap-link {
|
||||
stroke: #6b7280;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
.footer-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
@media print {
|
||||
body { padding: 0; }
|
||||
h1 { page-break-before: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${meetingTitle || '会议思维导图'}</h1>
|
||||
<div class="mindmap-container">
|
||||
${svgHTML}
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<p>导出时间:${new Date().toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
// 添加足够的边距
|
||||
const containerWidth = contentWidth + 200; // 左右各100px边距
|
||||
const containerHeight = contentHeight + 200; // 上下各100px边距
|
||||
|
||||
// 使用Blob创建URL以确保正确的编码
|
||||
const blob = new Blob([htmlContent], { type: 'text/html; charset=UTF-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// 创建导出容器
|
||||
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;
|
||||
`;
|
||||
|
||||
// 设置iframe的src为blob URL
|
||||
printFrame.src = url;
|
||||
// 克隆SVG元素
|
||||
const svgClone = svgElement.cloneNode(true);
|
||||
svgClone.style.width = `${contentWidth}px`;
|
||||
svgClone.style.height = `${contentHeight}px`;
|
||||
svgClone.style.display = 'block';
|
||||
|
||||
// 等待iframe加载完成
|
||||
printFrame.onload = () => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// 执行打印
|
||||
printFrame.contentWindow.focus();
|
||||
printFrame.contentWindow.print();
|
||||
} catch(e) {
|
||||
console.error("Print failed:", e);
|
||||
alert("导出PDF失败,您的浏览器可能阻止了打印操作。");
|
||||
}
|
||||
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>
|
||||
|
||||
// 清理资源
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(printFrame);
|
||||
}, 2000);
|
||||
}, 500);
|
||||
};
|
||||
<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('PDF导出失败:', error);
|
||||
alert('PDF导出失败,请重试。');
|
||||
console.error('图片导出失败:', error);
|
||||
alert('图片导出失败,请重试。');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -225,9 +365,9 @@ ${svgHTML}
|
|||
<div className="mindmap-header">
|
||||
<h3><Brain size={18} /> 思维导图</h3>
|
||||
{hasSummary && (
|
||||
<button onClick={handleExportPDF} className="export-pdf-btn-main" disabled={loading || !!error}>
|
||||
<Download size={16} />
|
||||
<span>导出PDF</span>
|
||||
<button onClick={handleExportImage} className="export-pdf-btn-main" disabled={loading || !!error}>
|
||||
<Image size={16} />
|
||||
<span>导出图片</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -959,7 +959,12 @@ const MeetingDetails = ({ user }) => {
|
|||
/>
|
||||
</TabPane>
|
||||
<TabPane tab={<span><Brain size={16} /> 会议脑图</span>} key="2">
|
||||
<MindMap meetingId={meeting_id} meetingTitle={meeting.title} />
|
||||
<MindMap
|
||||
meetingId={meeting_id}
|
||||
meetingTitle={meeting.title}
|
||||
meeting={meeting}
|
||||
formatDateTime={formatDateTime}
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Reference in New Issue