diff --git a/.DS_Store b/.DS_Store
index 55de3c7..77f4a83 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/dist.zip b/dist.zip
index 7aaf261..0903ee5 100644
Binary files a/dist.zip and b/dist.zip differ
diff --git a/src/components/MeetingTimeline.jsx b/src/components/MeetingTimeline.jsx
index 19865eb..3aa4885 100644
--- a/src/components/MeetingTimeline.jsx
+++ b/src/components/MeetingTimeline.jsx
@@ -8,48 +8,13 @@ import rehypeSanitize from 'rehype-sanitize';
import TagDisplay from './TagDisplay';
import ConfirmDialog from './ConfirmDialog';
import Dropdown from './Dropdown';
+import tools from '../utils/tools';
import './MeetingTimeline.css';
const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore = false, onLoadMore, loadingMore = false }) => {
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
const navigate = useNavigate();
- const formatDateTime = (dateTimeString) => {
- if (!dateTimeString) return '时间待定';
- const date = new Date(dateTimeString);
- return date.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' });
- };
-
- const formatDate = (dateString) => {
- const date = new Date(dateString);
- return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' });
- };
-
- const truncateSummary = (summary, maxLines = 3, maxLength = 100) => {
- if (!summary) return '暂无摘要';
-
- // Split by lines and check line count
- const lines = summary.split('\n');
- const hasMoreLines = lines.length > maxLines;
-
- // Also check character length
- const hasMoreChars = summary.length > maxLength;
-
- if (hasMoreLines || hasMoreChars) {
- // Take first few lines or characters, whichever is shorter
- const truncatedByLines = lines.slice(0, maxLines).join('\n');
- const truncatedByChars = summary.substring(0, maxLength);
-
- const result = truncatedByLines.length <= truncatedByChars.length
- ? truncatedByLines
- : truncatedByChars;
-
- return result + '...';
- }
-
- return summary;
- };
-
const shouldShowMoreButton = (summary, maxLines = 3, maxLength = 100) => {
if (!summary) return false;
const lines = summary.split('\n');
@@ -92,7 +57,7 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore
{sortedDates.map(date => (
- {formatDate(date)}
+ {tools.formatDateLong(date)}
{meetingsByDate[date].map(meeting => {
@@ -147,7 +112,7 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore
- {formatDateTime(meeting.meeting_time)}
+ {tools.formatTime(meeting.meeting_time)}
@@ -182,7 +147,7 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
- {truncateSummary(meeting.summary)}
+ {tools.truncateSummary(meeting.summary)}
{shouldShowMoreButton(meeting.summary) && (
diff --git a/src/pages/EditMeeting.jsx b/src/pages/EditMeeting.jsx
index 3caea07..88cb928 100644
--- a/src/pages/EditMeeting.jsx
+++ b/src/pages/EditMeeting.jsx
@@ -22,16 +22,11 @@ const EditMeeting = ({ user }) => {
const [availableUsers, setAvailableUsers] = useState([]);
const [userSearch, setUserSearch] = useState('');
const [showUserDropdown, setShowUserDropdown] = useState(false);
- const [audioFile, setAudioFile] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
- const [isUploading, setIsUploading] = useState(false);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [error, setError] = useState('');
const [meeting, setMeeting] = useState(null);
- const [showUploadArea, setShowUploadArea] = useState(false);
- const [showUploadConfirm, setShowUploadConfirm] = useState(false);
- const [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 默认100MB
const [maxImageSize, setMaxImageSize] = useState(10 * 1024 * 1024); // 默认10MB
useEffect(() => {
@@ -42,9 +37,7 @@ const EditMeeting = ({ user }) => {
const loadFileSizeConfig = async () => {
try {
- const fileSize = await configService.getMaxFileSize();
const imageSize = await configService.getMaxImageSize();
- setMaxFileSize(fileSize);
setMaxImageSize(imageSize);
} catch (error) {
console.warn('Failed to load file size config:', error);
@@ -114,29 +107,6 @@ const EditMeeting = ({ user }) => {
}));
};
- const handleFileChange = (e) => {
- const file = e.target.files[0];
- if (file) {
- // Check file type - include both MIME types and extensions
- const allowedMimeTypes = ['audio/mp3', 'audio/wav', 'audio/m4a', 'audio/mpeg', 'audio/mp4', 'audio/x-m4a'];
- const fileExtension = file.name.toLowerCase().split('.').pop();
- const allowedExtensions = ['mp3', 'wav', 'm4a', 'mpeg'];
-
- if (!allowedMimeTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) {
- setError('请上传支持的音频格式 (MP3, WAV, M4A)');
- return;
- }
- // Check file size using dynamic config
- if (file.size > maxFileSize) {
- const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
- setError(`音频文件大小不能超过${maxSizeMB}MB`);
- return;
- }
- setAudioFile(file);
- setError('');
- }
- };
-
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.title.trim()) {
@@ -165,42 +135,6 @@ const EditMeeting = ({ user }) => {
}
};
- const handleUploadAudio = async () => {
- if (!audioFile) {
- setError('请先选择音频文件');
- return;
- }
-
- setIsUploading(true);
- setError('');
-
- try {
- const formDataUpload = new FormData();
- formDataUpload.append('audio_file', audioFile);
- formDataUpload.append('meeting_id', meeting_id);
- formDataUpload.append('force_replace', 'true'); // Always force replace in edit mode
-
- const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
- });
-
- setAudioFile(null);
- setShowUploadArea(false);
- setShowUploadConfirm(false);
- // Reset file input
- const fileInput = document.getElementById('audio-file');
- if (fileInput) fileInput.value = '';
-
- } catch (err) {
- console.error('Upload error:', err);
- setError(err.response?.data?.message || '上传音频文件失败,请重试');
- } finally {
- setIsUploading(false);
- }
- };
-
const handleImageUpload = async (file) => {
if (!file) return null;
@@ -387,105 +321,6 @@ const EditMeeting = ({ user }) => {
-
-
- {!showUploadArea ? (
-
- ) : (
-
-
-
-
-
-
- {audioFile && (
-
- 已选择: {audioFile.name}
-
-
- )}
- {audioFile && (
-
- )}
-
- )}
-
- {/* Error message for audio upload - shown right after upload area */}
- {error && showUploadArea && (
-
{error}
- )}
-
- {/* Upload Confirmation Modal - moved here to be right after upload area */}
- {showUploadConfirm && (
-
setShowUploadConfirm(false)}>
-
e.stopPropagation()}>
-
确认重新上传
-
重传音频文件将清空已有的会话转录,是否继续?
-
-
-
-
-
-
- )}
-
-
-
- {formatTime(kb.created_at)}
+ {tools.formatTime(kb.created_at)}
{kb.source_meeting_count || 0} 个数据源
@@ -710,7 +509,7 @@ const KnowledgeBasePage = ({ user }) => {
)}
- {formatShortDate(kb.created_at)}
+ {tools.formatShortDate(kb.created_at)}
{kb.source_meeting_count || 0} 个数据源
@@ -754,7 +553,7 @@ const KnowledgeBasePage = ({ user }) => {
)}
- {formatDate(selectedKb.created_at)}
+ {tools.formatDate(selectedKb.created_at)}
{selectedKb.source_meetings && selectedKb.source_meetings.length > 0 && (
@@ -955,7 +754,7 @@ const KnowledgeBasePage = ({ user }) => {
创建人: {meeting.creator_username}
)}
{meeting.created_at && (
- 创建时间: {formatMeetingDate(meeting.created_at)}
+ 创建时间: {tools.formatMeetingDate(meeting.created_at)}
)}
diff --git a/src/pages/MeetingDetails.css b/src/pages/MeetingDetails.css
index cef64a0..8ba07dc 100644
--- a/src/pages/MeetingDetails.css
+++ b/src/pages/MeetingDetails.css
@@ -340,9 +340,58 @@
color: white;
}
+.section-header-with-menu {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+}
+
.audio-section h2 {
color: white;
- margin-bottom: 1.5rem;
+ margin-bottom: 0;
+}
+
+/* Audio Menu Button */
+.audio-menu-button {
+ background: none;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 8px;
+ outline: none;
+ border-radius: 6px;
+ transition: background-color 0.2s ease;
+}
+
+.audio-menu-button:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.audio-menu-button:focus {
+ outline: none;
+}
+
+.audio-menu-button:active {
+ background: rgba(255, 255, 255, 0.2);
+ outline: none;
+}
+
+.selected-file-info {
+ margin: 1rem 0;
+ padding: 0.75rem;
+ background: #f1f5f9;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ color: #475569;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.selected-file-info .file-size {
+ font-size: 0.75rem;
+ color: #64748b;
}
.audio-player {
diff --git a/src/pages/MeetingDetails.jsx b/src/pages/MeetingDetails.jsx
index 66e7a5b..35dd37a 100644
--- a/src/pages/MeetingDetails.jsx
+++ b/src/pages/MeetingDetails.jsx
@@ -1,7 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient';
-import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode } from 'lucide-react';
+import configService from '../utils/configService';
+import tools from '../utils/tools';
+import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode, MoreVertical, Upload } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
@@ -13,8 +15,9 @@ import ConfirmDialog from '../components/ConfirmDialog';
import Toast from '../components/Toast';
import PageLoading from '../components/PageLoading';
import QRCodeModal from '../components/QRCodeModal';
+import Dropdown from '../components/Dropdown';
+import exportService from '../services/exportService';
import { Tabs } from 'antd';
-import html2canvas from 'html2canvas';
import './MeetingDetails.css';
const { TabPane } = Tabs;
@@ -59,6 +62,11 @@ const MeetingDetails = ({ user }) => {
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
const [toasts, setToasts] = useState([]);
const [showQRModal, setShowQRModal] = useState(false);
+ const [audioFile, setAudioFile] = useState(null);
+ const [isUploading, setIsUploading] = useState(false);
+ const [showUploadConfirm, setShowUploadConfirm] = useState(false);
+ const [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 默认100MB
+ const [uploadError, setUploadError] = useState('');
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
@@ -74,7 +82,8 @@ const MeetingDetails = ({ user }) => {
useEffect(() => {
fetchMeetingDetails();
-
+ loadFileSizeConfig();
+
// Cleanup interval on unmount
return () => {
if (statusCheckInterval) {
@@ -281,29 +290,116 @@ const MeetingDetails = ({ user }) => {
}
};
- const formatDateTime = (dateTimeString) => {
- if (!dateTimeString) return '时间待定';
- const date = new Date(dateTimeString);
- return date.toLocaleString('zh-CN', {
- year: 'numeric', month: 'long', day: 'numeric',
- hour: '2-digit', minute: '2-digit'
- });
- };
-
- const formatTime = (seconds) => {
- const hours = Math.floor(seconds / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- const secs = Math.floor(seconds % 60);
-
- if (hours > 0) {
- // 超过60分钟显示小时:分钟:秒格式
- return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
- } else {
- // 60分钟内显示分钟:秒格式
- return `${mins}:${secs.toString().padStart(2, '0')}`;
+ const loadFileSizeConfig = async () => {
+ try {
+ const fileSize = await configService.getMaxFileSize();
+ setMaxFileSize(fileSize);
+ } catch (error) {
+ console.warn('Failed to load file size config:', error);
}
};
+ const handleFileChange = (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ // Check file type
+ const allowedMimeTypes = ['audio/mp3', 'audio/wav', 'audio/m4a', 'audio/mpeg', 'audio/mp4', 'audio/x-m4a'];
+ const fileExtension = file.name.toLowerCase().split('.').pop();
+ const allowedExtensions = ['mp3', 'wav', 'm4a', 'mpeg'];
+
+ if (!allowedMimeTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) {
+ setUploadError('请上传支持的音频格式 (MP3, WAV, M4A)');
+ showToast('请上传支持的音频格式 (MP3, WAV, M4A)', 'error');
+ return;
+ }
+ // Check file size
+ if (file.size > maxFileSize) {
+ const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
+ setUploadError(`音频文件大小不能超过${maxSizeMB}MB`);
+ showToast(`音频文件大小不能超过${maxSizeMB}MB`, 'error');
+ return;
+ }
+ setAudioFile(file);
+ setUploadError('');
+ setShowUploadConfirm(true);
+ }
+ };
+
+ const handleUploadAudio = async () => {
+ if (!audioFile) {
+ setUploadError('请先选择音频文件');
+ showToast('请先选择音频文件', 'error');
+ return;
+ }
+
+ setIsUploading(true);
+ setUploadError('');
+
+ try {
+ const formDataUpload = new FormData();
+ formDataUpload.append('audio_file', audioFile);
+ formDataUpload.append('meeting_id', meeting_id);
+ formDataUpload.append('force_replace', 'true');
+
+ const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+
+ setAudioFile(null);
+ setShowUploadConfirm(false);
+ setShowAudioDropdown(false);
+ showToast('音频上传成功,正在进行智能转录...', 'success');
+
+ // Reset file input
+ const fileInput = document.getElementById('audio-file-upload');
+ if (fileInput) fileInput.value = '';
+
+ // Refresh meeting details to get new audio and transcription status
+ await fetchMeetingDetails();
+
+ } catch (err) {
+ console.error('Upload error:', err);
+ setUploadError(err.response?.data?.message || '上传音频文件失败,请重试');
+ showToast(err.response?.data?.message || '上传音频文件失败,请重试', 'error');
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const handleStartTranscription = async () => {
+ try {
+ if (!audioUrl) {
+ showToast('没有可用的音频文件', 'error');
+ return;
+ }
+
+ if (transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status)) {
+ showToast('转录任务正在进行中', 'info');
+ return;
+ }
+
+ // 调用后端API启动转录
+ const response = await apiClient.post(buildApiUrl(`/api/meetings/${meeting_id}/transcription/start`));
+
+ if (response.data.task_id) {
+ showToast('智能转录已启动', 'success');
+ setShowAudioDropdown(false);
+ // 开始轮询转录状态
+ startStatusPolling(response.data.task_id);
+ }
+
+ } catch (err) {
+ console.error('Start transcription error:', err);
+ showToast(err.response?.data?.message || '启动转录失败,请重试', 'error');
+ }
+ };
+
+
+
+
+
const handlePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
@@ -749,165 +845,23 @@ const MeetingDetails = ({ user }) => {
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 meetingTime = tools.formatDateTime(meeting.meeting_time);
const attendeesList = meeting.attendees.map(attendee =>
typeof attendee === 'string' ? attendee : attendee.caption
).join('、');
- exportContainer.innerHTML = `
-
-
- ${meeting.title || '会议总结'}
-
-
-
-
- 📋 会议信息
-
-
会议时间:${meetingTime}
-
创建人:${meeting.creator_username}
-
参会人数:${meeting.attendees.length}人
-
参会人员:${attendeesList}
-
-
-
-
-
- 导出时间:${new Date().toLocaleString('zh-CN')}
-
-
- `;
-
- document.body.appendChild(exportContainer);
-
- // 渲染Markdown内容
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- const ReactMarkdownModule = (await import('react-markdown')).default;
- const { createRoot } = await import('react-dom/client');
-
- const root = createRoot(tempDiv);
-
- await new Promise((resolve) => {
- root.render(
- React.createElement(ReactMarkdownModule, {
- remarkPlugins: [remarkGfm],
- rehypePlugins: [rehypeRaw, rehypeSanitize],
- children: meeting.summary
- })
- );
- setTimeout(resolve, 200);
+ await exportService.exportMeetingSummaryToImage({
+ title: meeting.title || '会议总结',
+ summary: meeting.summary,
+ metadata: {
+ meetingTime,
+ creator: meeting.creator_username,
+ attendeeCount: meeting.attendees.length,
+ attendees: attendeesList
+ }
});
- // 获取渲染后的HTML并添加样式
- const renderedHTML = tempDiv.innerHTML;
- const summaryContentDiv = exportContainer.querySelector('#summary-content');
- summaryContentDiv.innerHTML = renderedHTML;
-
- // 为渲染后的内容添加样式
- const styles = `
-
- `;
-
- exportContainer.insertAdjacentHTML('afterbegin', styles);
-
- // 等待图片和样式加载
- await new Promise(resolve => setTimeout(resolve, 500));
-
- // 使用html2canvas生成图片
- const canvas = await html2canvas(exportContainer, {
- width: 880,
- height: exportContainer.scrollHeight + 80,
- scale: 2,
- useCORS: true,
- allowTaint: true,
- backgroundColor: '#ffffff',
- scrollX: 0,
- scrollY: 0
- });
-
- // 创建下载链接
- const link = document.createElement('a');
- link.download = `${meeting.title || '会议总结'}_总结_${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
- root.unmount();
- document.body.removeChild(tempDiv);
- document.body.removeChild(exportContainer);
-
+ showToast('总结已成功导出为图片', 'success');
} catch (error) {
console.error('图片导出失败:', error);
showToast('图片导出失败,请重试。', 'error');
@@ -922,38 +876,14 @@ const MeetingDetails = ({ user }) => {
return;
}
- // 查找SVG元素
- const svgElement = document.querySelector('.markmap-render-area svg');
- if (!svgElement) {
- showToast('未找到思维导图,请先切换到脑图标签页。', 'warning');
- return;
- }
-
- // 使用html2canvas导出SVG
- const mindmapContainer = svgElement.parentElement;
-
- const canvas = await html2canvas(mindmapContainer, {
- scale: 2,
- useCORS: true,
- allowTaint: true,
- backgroundColor: '#ffffff',
- scrollX: 0,
- scrollY: 0
+ await exportService.exportMindMapToImage({
+ title: meeting.title || '会议'
});
- // 创建下载链接
- const link = document.createElement('a');
- link.download = `${meeting.title || '会议'}_思维导图_${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);
-
+ showToast('思维导图已成功导出为图片', 'success');
} catch (error) {
console.error('思维导图导出失败:', error);
- showToast('思维导图导出失败,请重试。', 'error');
+ showToast(error.message || '思维导图导出失败,请重试。', 'error');
}
};
@@ -1024,12 +954,12 @@ const MeetingDetails = ({ user }) => {
会议日期:
- {formatDateTime(meeting.meeting_time).split(' ')[0].slice(2)}
+ {tools.formatDateTime(meeting.meeting_time).split(' ')[0].slice(2)}
会议时间:
- {formatDateTime(meeting.meeting_time).split(' ')[1]}
+ {tools.formatDateTime(meeting.meeting_time).split(' ')[1]}
@@ -1058,7 +988,39 @@ const MeetingDetails = ({ user }) => {
{/* Audio Player Section */}
- 会议录音
+
+
会议录音
+ {meeting?.creator_id === user?.user_id && (
+
+
+
+ }
+ items={[
+ {
+ label: '音频上传',
+ icon: ,
+ onClick: () => document.getElementById('audio-file-upload').click()
+ },
+ ...(audioUrl ? [{
+ label: '智能转录',
+ icon: ,
+ onClick: handleStartTranscription,
+ disabled: transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status)
+ }] : [])
+ ]}
+ align="right"
+ />
+ )}
+
+
{audioUrl ? (
{audioFileName && (
@@ -1129,7 +1091,7 @@ const MeetingDetails = ({ user }) => {
- {formatTime(currentTime)}
+ {tools.formatDuration(currentTime)}
{
- {formatTime(duration)}
+ {tools.formatDuration(duration)}
@@ -1193,6 +1155,47 @@ const MeetingDetails = ({ user }) => {
)}
+ {/* Upload Confirmation Modal */}
+ {showUploadConfirm && (
+
setShowUploadConfirm(false)}>
+
e.stopPropagation()}>
+
确认上传音频
+
重新上传音频文件将清空已有的会话转录,是否继续?
+ {audioFile && (
+
+ 已选择: {audioFile.name}
+
+ ({(audioFile.size / (1024 * 1024)).toFixed(2)} MB)
+
+
+ )}
+ {uploadError && (
+
{uploadError}
+ )}
+
+
+
+
+
+
+ )}
+
{
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点播放"
>
- {formatTime(item.start_time_ms / 1000)}
+ {tools.formatDuration(item.start_time_ms / 1000)}
{isCreator && (