加入脑图功能

main
mula.liu 2025-09-16 19:16:21 +08:00
parent 6b329adce8
commit c7846a9f03
7 changed files with 7492 additions and 694 deletions

BIN
dist.zip 100644

Binary file not shown.

6854
package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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);
// MarkdownHTML
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);
// 使BlobURL
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));
// 使BlobURL
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
});
// iframesrcblob 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>

View File

@ -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
// SVGviewBox
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
// 使BlobURL
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;
`;
// iframesrcblob 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>

View File

@ -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>

604
yarn.lock

File diff suppressed because it is too large Load Diff