加入脑图功能

main
mula.liu 2025-09-16 17:00:09 +08:00
parent 8ebaddc866
commit 6b329adce8
10 changed files with 1891 additions and 268 deletions

BIN
dist.zip

Binary file not shown.

View File

@ -11,9 +11,14 @@
},
"dependencies": {
"@uiw/react-md-editor": "^4.0.8",
"antd": "^5.27.3",
"axios": "^1.6.2",
"canvg": "^4.0.3",
"jspdf": "^3.0.2",
"lucide-react": "^0.294.0",
"markmap-common": "^0.18.9",
"markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",

View File

@ -0,0 +1,242 @@
import React from 'react';
import { FileText, Download, Brain } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
const MeetingSummary = ({
meeting,
summaryResult,
summaryHistory,
isCreator,
onOpenSummaryModal,
formatDateTime
}) => {
const exportToPDF = async () => {
try {
//
let summaryContent = summaryResult?.content ||
meeting?.summary ||
(summaryHistory.length > 0 ? summaryHistory[0].content : null);
if (!summaryContent) {
alert('暂无会议总结内容请先生成AI总结。');
return;
}
// Markdown
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
const ReactMarkdown = (await import('react-markdown')).default;
const { createRoot } = await import('react-dom/client');
const root = createRoot(tempDiv);
// MarkdownHTML
await new Promise((resolve) => {
root.render(
React.createElement(ReactMarkdown, {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeRaw, rehypeSanitize],
children: summaryContent
})
);
setTimeout(resolve, 100);
});
const renderedHTML = tempDiv.innerHTML;
//
const meetingTime = formatDateTime(meeting.meeting_time);
const attendeesList = meeting.attendees.map(attendee =>
typeof attendee === 'string' ? attendee : attendee.caption
).join('、');
// 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);
// 使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>`;
// 使BlobURL
const blob = new Blob([htmlContent], { type: 'text/html; charset=UTF-8' });
const url = URL.createObjectURL(blob);
// iframesrcblob URL
printFrame.src = url;
// iframe
printFrame.onload = () => {
setTimeout(() => {
//
printFrame.contentWindow.focus();
printFrame.contentWindow.print();
//
setTimeout(() => {
URL.revokeObjectURL(url);
root.unmount();
document.body.removeChild(tempDiv);
document.body.removeChild(printFrame);
}, 2000);
}, 500);
};
} catch (error) {
console.error('PDF导出失败:', error);
alert('PDF导出失败请重试。');
}
};
return (
<div className="meeting-summary">
<div className="summary-header">
<h2><FileText size={20} /> 摘要内容</h2>
{meeting?.summary && (
<button
className="export-pdf-btn-main"
onClick={exportToPDF}
title="导出PDF"
>
<Download size={16} />
<span>导出PDF</span>
</button>
)}
</div>
<div className="summary-content">
{meeting?.summary ? (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{meeting.summary}
</ReactMarkdown>
</div>
) : (
<div className="no-summary">
<div className="no-summary-content">
<FileText size={48} />
<h3>暂无会议总结</h3>
<p>该会议尚未生成总结内容</p>
{isCreator && (
<button
className="generate-summary-cta"
onClick={onOpenSummaryModal}
>
<Brain size={16} />
<span>生成AI总结</span>
</button>
)}
</div>
</div>
)}
</div>
</div>
);
};
export default MeetingSummary;

View File

@ -0,0 +1,241 @@
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, Download, Loader } from 'lucide-react';
const MindMap = ({ meetingId, meetingTitle }) => {
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]);
useEffect(() => {
if (loading || !markdown || !svgRef.current) return;
const transformer = new Transformer();
const { root } = transformer.transform(markdown);
if (markmapRef.current) {
markmapRef.current.setData(root);
} else {
markmapRef.current = Markmap.create(svgRef.current, null, root);
}
markmapRef.current.fit();
}, [markdown, loading]);
const handleExportPDF = async () => {
if (!svgRef.current) {
alert('思维导图尚未渲染,无法导出。');
return;
}
try {
// SVG
const svgElement = svgRef.current;
const svgHTML = svgElement.outerHTML;
if (!svgHTML.trim()) {
alert('思维导图内容为空,无法导出。');
return;
}
// SVG
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);
// 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>`;
// 使BlobURL
const blob = new Blob([htmlContent], { type: 'text/html; charset=UTF-8' });
const url = URL.createObjectURL(blob);
// iframesrcblob URL
printFrame.src = url;
// iframe
printFrame.onload = () => {
setTimeout(() => {
try {
//
printFrame.contentWindow.focus();
printFrame.contentWindow.print();
} catch(e) {
console.error("Print failed:", e);
alert("导出PDF失败您的浏览器可能阻止了打印操作。");
}
//
setTimeout(() => {
URL.revokeObjectURL(url);
document.body.removeChild(printFrame);
}, 2000);
}, 500);
};
} catch (error) {
console.error('PDF导出失败:', error);
alert('PDF导出失败请重试。');
}
};
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={handleExportPDF} className="export-pdf-btn-main" disabled={loading || !!error}>
<Download size={16} />
<span>导出PDF</span>
</button>
)}
</div>
<div className="markmap-render-area">
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
};
export default MindMap;

View File

@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { StyleProvider } from '@ant-design/cssinjs';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
<StyleProvider hashPriority="high">
<App />
</StyleProvider>
</StrictMode>,
)
);

View File

@ -59,7 +59,7 @@
overflow: hidden;
}
/* Tabs */
/* Old Tabs styles - can be removed or kept for reference */
.tabs {
display: flex;
border-bottom: 1px solid #e2e8f0;
@ -109,6 +109,41 @@
min-height: 60vh;
}
/* New AntD Tabs Styles */
.admin-tabs .ant-tabs-nav {
padding: 0 2rem;
margin-bottom: 0 !important;
border-bottom: 1px solid #e2e8f0;
}
.admin-tabs .ant-tabs-tab {
font-size: 1rem;
color: #475569;
padding: 16px 4px;
margin: 0 16px;
}
.admin-tabs .ant-tabs-tab .ant-tabs-tab-btn {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.admin-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #667eea;
}
.admin-tabs .ant-tabs-ink-bar {
background: #667eea;
height: 3px;
}
.admin-tabs .ant-tabs-content-holder {
padding: 2rem;
min-height: 60vh;
}
/* Responsive Design */
@media (max-width: 768px) {
.admin-content {
@ -124,7 +159,11 @@
font-size: 0.9rem;
}
.tab-content {
.tab-content, .admin-tabs .ant-tabs-content-holder {
padding: 1rem;
}
}
.admin-tabs .ant-tabs-nav {
padding: 0 1rem;
}
}

View File

@ -1,12 +1,14 @@
import React, { useState } from 'react';
import React from 'react';
import { MessageSquare, Settings, Users } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Tabs } from 'antd';
import UserManagement from '../components/admin/UserManagement';
import SystemConfiguration from '../components/admin/SystemConfiguration';
import './AdminManagement.css';
const { TabPane } = Tabs;
const AdminManagement = () => {
const [activeTab, setActiveTab] = useState('userManagement');
const navigate = useNavigate();
const handleLogoClick = () => {
@ -26,30 +28,25 @@ const AdminManagement = () => {
</header>
<div className="admin-content">
<div className="admin-wrapper">
<div className="tabs">
<button
className={`tab-btn ${activeTab === 'userManagement' ? 'active' : ''}`}
onClick={() => setActiveTab('userManagement')}
<Tabs defaultActiveKey="userManagement" className="admin-tabs">
<TabPane
tab={<span><Users size={16} /> 用户管理</span>}
key="userManagement"
>
<Users size={18} style={{ marginRight: '0.5rem' }} />
用户管理
</button>
<button
className={`tab-btn ${activeTab === 'systemConfiguration' ? 'active' : ''}`}
onClick={() => setActiveTab('systemConfiguration')}
<UserManagement />
</TabPane>
<TabPane
tab={<span><Settings size={16} /> 系统配置</span>}
key="systemConfiguration"
>
<Settings size={18} style={{ marginRight: '0.5rem' }} />
系统配置
</button>
</div>
<div className="tab-content">
{activeTab === 'userManagement' && <UserManagement />}
{activeTab === 'systemConfiguration' && <SystemConfiguration />}
</div>
<SystemConfiguration />
</TabPane>
</Tabs>
</div>
</div>
</div>
);
};
export default AdminManagement;

View File

@ -1817,3 +1817,80 @@
transform: none;
box-shadow: none;
}
/* New styles for Tabs and MindMap */
.summary-tabs-section .ant-tabs-nav {
margin-bottom: 1.5rem !important;
}
.summary-tabs-section .ant-tabs-tab {
font-size: 1rem;
color: #475569;
padding: 12px 16px;
}
.summary-tabs-section .ant-tabs-tab .ant-tabs-tab-btn {
display: flex;
align-items: center;
gap: 8px;
}
.summary-tabs-section .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #667eea;
}
.summary-tabs-section .ant-tabs-ink-bar {
background: #667eea;
height: 3px;
}
.mindmap-container {
padding: 1rem;
}
.mindmap-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.mindmap-header h3 {
margin: 0;
color: #334155;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
}
.markmap-render-area {
height: 600px;
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.markmap-render-area svg {
width: 100%;
height: 100%;
}
.mindmap-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 600px;
color: #64748b;
}
.mindmap-loading .animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@ -7,8 +7,13 @@ import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
import MindMap from '../components/MindMap';
import MeetingSummary from '../components/MeetingSummary';
import { Tabs } from 'antd';
import './MeetingDetails.css';
const { TabPane } = Tabs;
const MeetingDetails = ({ user }) => {
const { meeting_id } = useParams();
const navigate = useNavigate();
@ -24,6 +29,7 @@ const MeetingDetails = ({ user }) => {
const [audioUrl, setAudioUrl] = useState(null);
const [audioFileName, setAudioFileName] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showSummaryError, setShowSummaryError] = useState(false);
const [showSpeakerEdit, setShowSpeakerEdit] = useState(false);
const [editingSpeakers, setEditingSpeakers] = useState({});
const [speakerList, setSpeakerList] = useState([]);
@ -547,8 +553,6 @@ const MeetingDetails = ({ user }) => {
summary: response.data.summary
}));
console.log('会议摘要已更新无loading状态');
} catch (error) {
console.error('刷新会议摘要失败:', error);
}
@ -625,9 +629,14 @@ const MeetingDetails = ({ user }) => {
} catch (err) {
console.error('Error starting summary generation:', err);
setError('启动AI总结失败请重试');
// Check for detailed error message from backend
const detail = err.response?.data?.detail;
const errorMessage = detail || '启动AI总结失败请重试。';
setError(errorMessage); // Set the more specific error
setSummaryTaskMessage(`生成失败:${errorMessage}`); // Also show it in the modal
setSummaryLoading(false);
setSummaryTaskMessage('');
setSummaryTaskProgress(0);
}
};
@ -699,6 +708,12 @@ const MeetingDetails = ({ user }) => {
};
const openSummaryModal = async () => {
// Frontend check before opening the modal
if (!transcriptionStatus || transcriptionStatus.status !== 'completed') {
setShowSummaryError(true);
return; // Prevent modal from opening
}
setShowSummaryModal(true);
setUserPrompt('');
setSummaryResult(null);
@ -712,186 +727,7 @@ const MeetingDetails = ({ user }) => {
await refreshMeetingSummary();
}
};
const exportToPDF = async () => {
try {
//
let summaryContent = summaryResult?.content ||
meeting?.summary ||
(summaryHistory.length > 0 ? summaryHistory[0].content : null);
if (!summaryContent) {
alert('暂无会议总结内容请先生成AI总结。');
return;
}
// Markdown
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
const ReactMarkdown = (await import('react-markdown')).default;
const { createRoot } = await import('react-dom/client');
const root = createRoot(tempDiv);
// MarkdownHTML
await new Promise((resolve) => {
root.render(
React.createElement(ReactMarkdown, {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeRaw, rehypeSanitize],
children: summaryContent
})
);
setTimeout(resolve, 100);
});
const renderedHTML = tempDiv.innerHTML;
//
const meetingTime = formatDateTime(meeting.meeting_time);
const attendeesList = meeting.attendees.map(attendee =>
typeof attendee === 'string' ? attendee : attendee.caption
).join('、');
// 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 printDocument = printFrame.contentWindow.document;
// 使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>`;
// 使BlobURL
const blob = new Blob([htmlContent], { type: 'text/html; charset=UTF-8' });
const url = URL.createObjectURL(blob);
// iframesrcblob URL
printFrame.src = url;
// iframe
printFrame.onload = () => {
setTimeout(() => {
//
printFrame.contentWindow.focus();
printFrame.contentWindow.print();
//
setTimeout(() => {
URL.revokeObjectURL(url);
root.unmount();
document.body.removeChild(tempDiv);
document.body.removeChild(printFrame);
}, 2000);
}, 500);
};
} catch (error) {
console.error('PDF导出失败:', error);
alert('PDF导出失败请重试。');
}
};
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
if (loading) {
@ -1110,49 +946,22 @@ ${renderedHTML}
)}
</section>
<section className="card-section">
<div className="summary-header">
<h2><FileText size={20} /> 会议摘要</h2>
{meeting?.summary && (
<button
className="export-pdf-btn-main"
onClick={exportToPDF}
title="导出PDF"
>
<Download size={16} />
<span>导出PDF</span>
</button>
)}
</div>
<div className="summary-content">
{meeting?.summary ? (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{meeting.summary}
</ReactMarkdown>
</div>
) : (
<div className="no-summary">
<div className="no-summary-content">
<FileText size={48} />
<h3>暂无会议总结</h3>
<p>该会议尚未生成总结内容</p>
{isCreator && (
<button
className="generate-summary-cta"
onClick={openSummaryModal}
>
<Brain size={16} />
<span>生成AI总结</span>
</button>
)}
</div>
</div>
)}
</div>
<section className="card-section summary-tabs-section">
<Tabs defaultActiveKey="1">
<TabPane tab={<span><FileText size={16} /> 会议总结</span>} key="1">
<MeetingSummary
meeting={meeting}
summaryResult={summaryResult}
summaryHistory={summaryHistory}
isCreator={isCreator}
onOpenSummaryModal={openSummaryModal}
formatDateTime={formatDateTime}
/>
</TabPane>
<TabPane tab={<span><Brain size={16} /> 会议脑图</span>} key="2">
<MindMap meetingId={meeting_id} meetingTitle={meeting.title} />
</TabPane>
</Tabs>
</section>
</div>
</div>
@ -1265,7 +1074,25 @@ ${renderedHTML}
</div>
</div>
)}
{/* Summary Error Modal */}
{showSummaryError && (
<div className="delete-modal-overlay" onClick={() => setShowSummaryError(false)}>
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
<h3>操作无法进行</h3>
<p>会议转录尚未完成或处理失败请在转录成功后再生成AI总结</p>
<div className="modal-actions">
<button
className="btn-cancel"
onClick={() => setShowSummaryError(false)}
>
知道了
</button>
</div>
</div>
</div>
)}
{/* Speaker Tags Edit Modal */}
{showSpeakerEdit && (
<div className="speaker-edit-modal-overlay" onClick={() => setShowSpeakerEdit(false)}>
@ -1470,14 +1297,6 @@ ${renderedHTML}
<div className="summary-result-section">
<div className="summary-result-header">
<h4>最新生成的总结</h4>
<button
className="export-pdf-btn"
onClick={exportToPDF}
title="导出PDF"
>
<Download size={14} />
<span>导出PDF</span>
</button>
</div>
<div className="summary-result-content">
<ReactMarkdown

1206
yarn.lock

File diff suppressed because it is too large Load Diff