import React, { useState, useEffect, useRef } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import axios from 'axios'; import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; 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 './MeetingDetails.css'; const MeetingDetails = ({ user }) => { const { meeting_id } = useParams(); const navigate = useNavigate(); const [meeting, setMeeting] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(1); const [transcript, setTranscript] = useState([]); const [showTranscript, setShowTranscript] = useState(true); const [audioUrl, setAudioUrl] = useState(null); const [audioFileName, setAudioFileName] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showSpeakerEdit, setShowSpeakerEdit] = useState(false); const [editingSpeakers, setEditingSpeakers] = useState({}); const [speakerList, setSpeakerList] = useState([]); const [showTranscriptEdit, setShowTranscriptEdit] = useState(false); const [editingTranscriptIndex, setEditingTranscriptIndex] = useState(-1); const [editingTranscripts, setEditingTranscripts] = useState({}); const [currentSubtitle, setCurrentSubtitle] = useState(''); const [currentSpeaker, setCurrentSpeaker] = useState(''); const [showSummaryModal, setShowSummaryModal] = useState(false); const [summaryLoading, setSummaryLoading] = useState(false); const [summaryResult, setSummaryResult] = useState(null); const [userPrompt, setUserPrompt] = useState(''); const [summaryHistory, setSummaryHistory] = useState([]); const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1); const audioRef = useRef(null); const transcriptRefs = useRef([]); useEffect(() => { fetchMeetingDetails(); }, [meeting_id]); const fetchMeetingDetails = async () => { try { setLoading(true); // Fallback URL construction in case config fails const baseUrl = "" const detailEndpoint = API_ENDPOINTS?.MEETINGS?.DETAIL?.(meeting_id) || `/api/meetings/${meeting_id}`; const audioEndpoint = API_ENDPOINTS?.MEETINGS?.AUDIO?.(meeting_id) || `/api/meetings/${meeting_id}/audio`; const transcriptEndpoint = API_ENDPOINTS?.MEETINGS?.TRANSCRIPT?.(meeting_id) || `/api/meetings/${meeting_id}/transcript`; const response = await axios.get(`${baseUrl}${detailEndpoint}`); setMeeting(response.data); // Fetch audio file if available try { const audioResponse = await axios.get(`${baseUrl}${audioEndpoint}`); // Construct URL using uploads path and relative path from database setAudioUrl(`${baseUrl}${audioResponse.data.file_path}`); setAudioFileName(audioResponse.data.file_name); } catch (audioError) { console.warn('No audio file available:', audioError); setAudioUrl(null); setAudioFileName(null); } // Fetch transcript segments from database try { const transcriptResponse = await axios.get(`${baseUrl}${transcriptEndpoint}`); setTranscript(transcriptResponse.data); console.log('First transcript item:', transcriptResponse.data[0]); // 现在使用speaker_id字段进行分组 const allSpeakerIds = transcriptResponse.data .map(item => item.speaker_id) .filter(speakerId => speakerId !== null && speakerId !== undefined); console.log('Extracted speaker IDs:', allSpeakerIds); const uniqueSpeakers = [...new Set(allSpeakerIds)] .map(speakerId => { const segment = transcriptResponse.data.find(item => item.speaker_id === speakerId); return { speaker_id: speakerId, speaker_tag: segment ? (segment.speaker_tag || `发言人 ${speakerId}`) : `发言人 ${speakerId}` }; }) .sort((a, b) => a.speaker_id - b.speaker_id); // 按speaker_id数值排序 console.log('Final unique speakers:', uniqueSpeakers); setSpeakerList(uniqueSpeakers); // 初始化编辑状态 const initialEditingState = {}; uniqueSpeakers.forEach(speaker => { initialEditingState[speaker.speaker_id] = speaker.speaker_tag; }); setEditingSpeakers(initialEditingState); } catch (transcriptError) { console.warn('No transcript data available:', transcriptError); setTranscript([]); setSpeakerList([]); } } catch (err) { console.error('Error fetching meeting details:', err); setError('无法加载会议详情,请稍后重试。'); } finally { setLoading(false); } }; 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 mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; const handlePlayPause = () => { if (audioRef.current) { if (isPlaying) { audioRef.current.pause(); } else { audioRef.current.play(); } setIsPlaying(!isPlaying); } }; const handleTimeUpdate = () => { if (audioRef.current) { const currentTime = audioRef.current.currentTime; setCurrentTime(currentTime); // 更新字幕显示 updateSubtitle(currentTime); } }; const updateSubtitle = (currentTime) => { const currentTimeMs = currentTime * 1000; const currentSegment = transcript.find(item => currentTimeMs >= item.start_time_ms && currentTimeMs <= item.end_time_ms ); if (currentSegment) { setCurrentSubtitle(currentSegment.text_content); // 确保使用 speaker_tag 来保持一致性 setCurrentSpeaker(currentSegment.speaker_tag || `发言人 ${currentSegment.speaker_id}`); // 找到当前segment在transcript数组中的索引 const currentIndex = transcript.findIndex(item => item.segment_id === currentSegment.segment_id); setCurrentHighlightIndex(currentIndex); // 滚动到对应的转录条目 if (currentIndex !== -1 && transcriptRefs.current[currentIndex]) { transcriptRefs.current[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); } } else { setCurrentSubtitle(''); setCurrentSpeaker(''); setCurrentHighlightIndex(-1); } }; const handleLoadedMetadata = () => { if (audioRef.current) { setDuration(audioRef.current.duration); } }; const handleSeek = (e) => { if (!audioRef.current || !duration) return; const rect = e.currentTarget.getBoundingClientRect(); const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const seekTime = percent * duration; audioRef.current.currentTime = seekTime; setCurrentTime(seekTime); }; const handleProgressMouseDown = (e) => { e.preventDefault(); const handleMouseMove = (moveEvent) => { handleSeek(moveEvent); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); handleSeek(e); }; const handleVolumeChange = (e) => { const newVolume = parseFloat(e.target.value); setVolume(newVolume); if (audioRef.current) { audioRef.current.volume = newVolume; } }; const jumpToTime = (timestamp) => { if (audioRef.current) { audioRef.current.currentTime = timestamp; setCurrentTime(timestamp); audioRef.current.play(); setIsPlaying(true); } }; const handleDeleteMeeting = async () => { try { await axios.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id))); navigate('/dashboard'); } catch (err) { console.error('Error deleting meeting:', err); setError('删除会议失败,请重试'); } }; const handleSpeakerTagUpdate = async (speakerId, newTag) => { try { const baseUrl = ""; await axios.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags`, { speaker_id: speakerId, new_tag: newTag }); // 更新本地状态 setTranscript(prev => prev.map(item => item.speaker_id === speakerId ? { ...item, speaker_tag: newTag } : item )); setSpeakerList(prev => prev.map(speaker => speaker.speaker_id === speakerId ? { ...speaker, speaker_tag: newTag } : speaker )); } catch (err) { console.error('Error updating speaker tag:', err); setError('更新发言人标签失败,请重试'); } }; const handleBatchSpeakerUpdate = async () => { try { const baseUrl = ""; const updates = Object.entries(editingSpeakers).map(([speakerId, newTag]) => ({ speaker_id: parseInt(speakerId), // 确保传递整数类型 new_tag: newTag })); await axios.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags/batch`, { updates: updates }); // 更新本地状态 setTranscript(prev => prev.map(item => { const newTag = editingSpeakers[item.speaker_id]; return newTag ? { ...item, speaker_tag: newTag } : item; })); setSpeakerList(prev => prev.map(speaker => ({ ...speaker, speaker_tag: editingSpeakers[speaker.speaker_id] || speaker.speaker_tag }))); setShowSpeakerEdit(false); } catch (err) { console.error('Error batch updating speaker tags:', err); setError('批量更新发言人标签失败,请重试'); } }; const handleEditingSpeakerChange = (speakerId, newTag) => { setEditingSpeakers(prev => ({ ...prev, [speakerId]: newTag })); }; const handleSpeakerEditOpen = () => { console.log('Opening speaker edit modal'); console.log('Current transcript:', transcript); console.log('Current speakerList:', speakerList); console.log('Current editingSpeakers:', editingSpeakers); setShowSpeakerEdit(true); }; const handleTranscriptEdit = (index) => { setEditingTranscriptIndex(index); // 获取前一条、当前条、后一条的数据 const editItems = []; if (index > 0) editItems.push({ ...transcript[index - 1], originalIndex: index - 1 }); editItems.push({ ...transcript[index], originalIndex: index }); if (index < transcript.length - 1) editItems.push({ ...transcript[index + 1], originalIndex: index + 1 }); // 初始化编辑状态 const initialEditState = {}; editItems.forEach(item => { initialEditState[item.originalIndex] = item.text_content; }); setEditingTranscripts(initialEditState); setShowTranscriptEdit(true); }; const handleTranscriptTextChange = (index, newText) => { setEditingTranscripts(prev => ({ ...prev, [index]: newText })); }; const handleSaveTranscriptEdits = async () => { try { const baseUrl = ""; const updates = Object.entries(editingTranscripts).map(([index, text_content]) => ({ segment_id: transcript[index].segment_id, text_content: text_content })); await axios.put(`${baseUrl}/api/meetings/${meeting_id}/transcript/batch`, { updates: updates }); // 更新本地状态 setTranscript(prev => prev.map((item, idx) => { const newText = editingTranscripts[idx]; return newText !== undefined ? { ...item, text_content: newText } : item; })); setShowTranscriptEdit(false); setEditingTranscripts({}); setEditingTranscriptIndex(-1); } catch (err) { console.error('Error updating transcript:', err); setError('更新转录内容失败,请重试'); } }; const getEditingItems = () => { if (editingTranscriptIndex === -1) return []; const items = []; const index = editingTranscriptIndex; if (index > 0) items.push({ ...transcript[index - 1], originalIndex: index - 1, position: 'prev' }); items.push({ ...transcript[index], originalIndex: index, position: 'current' }); if (index < transcript.length - 1) items.push({ ...transcript[index + 1], originalIndex: index + 1, position: 'next' }); return items; }; // AI总结相关函数 const generateSummary = async () => { if (summaryLoading) return; setSummaryLoading(true); try { const baseUrl = ""; const response = await axios.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary`, { user_prompt: userPrompt }); setSummaryResult(response.data); // 刷新总结历史 await fetchSummaryHistory(); } catch (err) { console.error('Error generating summary:', err); setError('生成AI总结失败,请重试'); } finally { setSummaryLoading(false); } }; const fetchSummaryHistory = async () => { try { const baseUrl = ""; const response = await axios.get(`${baseUrl}/api/meetings/${meeting_id}/summaries`); setSummaryHistory(response.data.summaries); } catch (err) { console.error('Error fetching summary history:', err); } }; const openSummaryModal = async () => { setShowSummaryModal(true); setUserPrompt(''); setSummaryResult(null); await fetchSummaryHistory(); }; const exportToPDF = async () => { try { // 检查是否有总结内容 let summaryContent = summaryResult?.content || meeting?.summary || (summaryHistory.length > 0 ? summaryHistory[0].content : null); if (!summaryContent) { alert('暂无会议总结内容,请先生成AI总结。'); return; } // 创建一个临时的React容器用于渲染Markdown const tempDiv = document.createElement('div'); tempDiv.style.position = 'fixed'; tempDiv.style.top = '-9999px'; tempDiv.style.width = '800px'; tempDiv.style.padding = '20px'; tempDiv.style.backgroundColor = 'white'; // 导入markdown-to-html转换所需的模块 const ReactMarkdown = (await import('react-markdown')).default; const { createRoot } = await import('react-dom/client'); document.body.appendChild(tempDiv); 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; // 创建一个隐藏的HTML容器用于生成PDF const printContainer = document.createElement('div'); printContainer.style.position = 'fixed'; printContainer.style.top = '-9999px'; printContainer.style.width = '210mm'; printContainer.style.padding = '20mm'; printContainer.style.backgroundColor = 'white'; printContainer.style.fontFamily = 'Arial, sans-serif'; printContainer.style.fontSize = '14px'; printContainer.style.lineHeight = '1.6'; printContainer.style.color = '#333'; // 创建PDF内容的HTML,使用渲染后的Markdown内容 const meetingTime = formatDateTime(meeting.meeting_time); const attendeesList = meeting.attendees.map(attendee => typeof attendee === 'string' ? attendee : attendee.caption ).join('、'); printContainer.innerHTML = `

${meeting.title || '会议总结'}

会议信息

会议时间:${meetingTime}

创建人:${meeting.creator_username}

参会人数:${meeting.attendees.length}人

参会人员:${attendeesList}

会议摘要

${renderedHTML}

导出时间:${new Date().toLocaleString('zh-CN')}

`; document.body.appendChild(printContainer); // 使用浏览器的打印功能生成PDF const originalContent = document.body.innerHTML; const originalTitle = document.title; // 临时替换页面内容 document.body.innerHTML = printContainer.innerHTML; document.title = `${meeting.title || '会议总结'}_${new Date().toISOString().split('T')[0]}`; // 添加打印样式 const printStyles = document.createElement('style'); printStyles.innerHTML = ` @media print { body { margin: 0; padding: 20px; font-family: 'Microsoft YaHei', Arial, sans-serif; } h1 { page-break-before: avoid; } h2 { page-break-before: avoid; } h3 { margin-top: 1.5rem; margin-bottom: 0.75rem; color: #1e293b; } h4 { margin-top: 1rem; margin-bottom: 0.5rem; color: #1e293b; } p { margin-bottom: 0.75rem; color: #475569; line-height: 1.6; } ul, ol { margin: 0.75rem 0; padding-left: 1.5rem; } li { margin-bottom: 0.25rem; color: #475569; } strong { color: #1e293b; font-weight: 600; } code { background: #f1f5f9; padding: 2px 4px; border-radius: 3px; color: #dc2626; } .page-break { page-break-before: always; } } `; document.head.appendChild(printStyles); // 打开打印对话框 window.print(); // 清理:恢复原始内容 setTimeout(() => { document.body.innerHTML = originalContent; document.title = originalTitle; document.head.removeChild(printStyles); document.body.removeChild(printContainer); document.body.removeChild(tempDiv); // 重新初始化React组件(这是一个简化的处理) window.location.reload(); }, 1000); } catch (error) { console.error('PDF导出失败:', error); alert('PDF导出失败,请重试。建议使用浏览器的打印功能并选择"保存为PDF"。'); } }; const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id); if (loading) { return

加载中...

; } if (error) { return

{error}

返回首页
; } if (!meeting) { return

未找到会议信息。

返回首页
; } return (
返回首页 {isCreator && (
编辑会议
)}

{meeting.title}

会议日期: {formatDateTime(meeting.meeting_time).split(' ')[0]}
会议时间: {formatDateTime(meeting.meeting_time).split(' ')[1]}
创建人: {meeting.creator_username}
参会人数: {meeting.attendees.length}

参会人员

{meeting.attendees.map((attendee, index) => (
{typeof attendee === 'string' ? attendee : attendee.caption}
))}
{/* Audio Player Section */}

会议录音

{audioUrl ? (
{audioFileName && (
{audioFileName}
)}
{formatTime(currentTime)}
{formatTime(duration)}
{/* 分割线 */}
{/* 动态字幕显示 */}
{currentSubtitle ? (
{currentSpeaker}
{currentSubtitle}
) : (
播放音频时将在此处显示实时字幕
)}
) : (

该会议没有录音文件

录音功能可能未开启或录音文件丢失

)}

会议摘要

{meeting?.summary && ( )}
{meeting?.summary ? (
{meeting.summary}
) : (

暂无会议总结

该会议尚未生成总结内容

{isCreator && ( )}
)}
{/* Transcript Sidebar */}

对话转录

{isCreator && ( <> )}
{transcript.map((item, index) => (
transcriptRefs.current[index] = el} className={`transcript-item ${currentHighlightIndex === index ? 'active' : ''}`} >
jumpToTime(item.start_time_ms / 1000)} title="跳转到此时间点播放" > {item.speaker_tag}
jumpToTime(item.start_time_ms / 1000)} title="跳转到此时间点播放" > {formatTime(item.start_time_ms / 1000)} {isCreator && ( )}
jumpToTime(item.start_time_ms / 1000)} title="跳转到此时间点播放" > {item.text_content}
))}
{/* Delete Confirmation Modal */} {showDeleteConfirm && (
setShowDeleteConfirm(false)}>
e.stopPropagation()}>

确认删除

确定要删除会议 "{meeting.title}" 吗?此操作无法撤销。

)} {/* Speaker Tags Edit Modal */} {showSpeakerEdit && (
setShowSpeakerEdit(false)}>
e.stopPropagation()}>

编辑发言人标签

根据AI识别的发言人ID,为每个发言人设置自定义标签:

{speakerList.length > 0 ? ( speakerList.map((speaker) => { const segmentCount = transcript.filter(item => item.speaker_id === speaker.speaker_id).length; return (
({segmentCount} 条发言)
handleEditingSpeakerChange(speaker.speaker_id, e.target.value)} className="speaker-tag-input" placeholder="输入发言人姓名或标签" />
); }) ) : (

暂无发言人数据,请检查转录内容是否正确加载。

)}
)} {/* Transcript Edit Modal */} {showTranscriptEdit && (
setShowTranscriptEdit(false)}>
e.stopPropagation()}>

编辑转录内容

修改选中转录条目及其上下文内容:

{getEditingItems().map((item) => (
{item.speaker_tag} {formatTime(item.start_time_ms / 1000)} {item.position === 'current' && ( 当前编辑 )}