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 && (