加入脑图功能
parent
8ebaddc866
commit
6b329adce8
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 渲染Markdown内容并获取HTML
|
||||
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);
|
||||
|
||||
// 使用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>`;
|
||||
|
||||
// 使用Blob创建URL以确保正确的编码
|
||||
const blob = new Blob([htmlContent], { type: 'text/html; charset=UTF-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 设置iframe的src为blob 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;
|
||||
|
|
@ -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>`;
|
||||
|
||||
// 使用Blob创建URL以确保正确的编码
|
||||
const blob = new Blob([htmlContent], { type: 'text/html; charset=UTF-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 设置iframe的src为blob 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;
|
||||
15
src/main.jsx
15
src/main.jsx
|
|
@ -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>,
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
// 渲染Markdown内容并获取HTML
|
||||
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;
|
||||
|
||||
// 使用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>`;
|
||||
|
||||
// 使用Blob创建URL以确保正确的编码
|
||||
const blob = new Blob([htmlContent], { type: 'text/html; charset=UTF-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 设置iframe的src为blob 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue