import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { Tabs } from 'antd'; import { Lock, Eye, EyeOff, AlertCircle, Copy, Check, Share2, Play, Pause } from 'lucide-react'; import MindMap from '../components/MindMap'; import MarkdownRenderer from '../components/MarkdownRenderer'; import './MeetingPreview.css'; const { TabPane } = Tabs; const MeetingPreview = () => { const { meeting_id } = useParams(); const [meetingData, setMeetingData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [errorType, setErrorType] = useState(''); // 'not_found', 'no_summary', 'network' const [copied, setCopied] = useState(false); const [shared, setShared] = useState(false); // 转录相关状态 const [transcript, setTranscript] = useState([]); const [audioUrl, setAudioUrl] = useState(null); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const audioRef = React.useRef(null); // 密码验证相关状态 const [isPasswordProtected, setIsPasswordProtected] = useState(false); const [passwordVerified, setPasswordVerified] = useState(false); const [passwordInput, setPasswordInput] = useState(''); const [showPassword, setShowPassword] = useState(false); const [passwordError, setPasswordError] = useState(''); const [verifyingPassword, setVerifyingPassword] = useState(false); useEffect(() => { // 检查是否已经验证过密码(使用 sessionStorage) const verifiedKey = `meeting_${meeting_id}_verified`; const isVerified = sessionStorage.getItem(verifiedKey) === 'true'; if (isVerified) { setPasswordVerified(true); } fetchMeetingPreviewData(); }, [meeting_id]); const fetchMeetingPreviewData = async () => { try { setLoading(true); // Use relative path to work in both dev and production const response = await fetch(`/api/meetings/${meeting_id}/preview-data`); const result = await response.json(); if (result.code === "200") { // 检查是否需要密码保护 - 优先检查 sessionStorage const verifiedKey = `meeting_${meeting_id}_verified`; const isVerified = sessionStorage.getItem(verifiedKey) === 'true'; if (result.data.has_password && !isVerified && !passwordVerified) { setIsPasswordProtected(true); setMeetingData(null); } else { setMeetingData(result.data); setIsPasswordProtected(false); // 获取转录数据和音频 fetchTranscriptAndAudio(); } setError(null); setErrorType(''); } else if (result.code === "404") { setError(result.message || '会议不存在'); setErrorType('not_found'); } else if (result.code === "400") { setError(result.message || '该会议总结尚未生成'); setErrorType('no_summary'); } else { setError(result.message || '获取会议数据失败'); setErrorType('network'); } } catch (err) { console.error('获取会议预览数据失败:', err); setError('网络错误,无法获取会议数据'); setErrorType('network'); } finally { setLoading(false); } }; const fetchTranscriptAndAudio = async () => { try { // 获取转录数据 const transcriptResponse = await fetch(`/api/meetings/${meeting_id}/transcript`); if (transcriptResponse.ok) { const transcriptResult = await transcriptResponse.json(); if (transcriptResult.code === '200' && transcriptResult.data) { // 转换后端数据格式为前端需要的格式 const formattedSegments = transcriptResult.data.map(seg => ({ segment_id: seg.segment_id, speaker_label: seg.speaker_tag || `发言人 ${seg.speaker_id}`, start_time: seg.start_time_ms / 1000.0, // 毫秒转秒 end_time: seg.end_time_ms / 1000.0, // 毫秒转秒 text: seg.text_content })); setTranscript(formattedSegments); } } // 获取音频URL const audioResponse = await fetch(`/api/meetings/${meeting_id}/audio`); if (audioResponse.ok) { const audioResult = await audioResponse.json(); if (audioResult.code === '200' && audioResult.data) { // 使用stream端点作为audio URL const audioUrl = `/api/meetings/${meeting_id}/audio/stream`; setAudioUrl(audioUrl); } } } catch (err) { console.error('获取转录或音频失败:', err); } }; const handlePasswordVerify = async () => { if (!passwordInput.trim()) { setPasswordError('请输入访问密码'); return; } setVerifyingPassword(true); setPasswordError(''); try { const response = await fetch(`/api/meetings/${meeting_id}/verify-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ password: passwordInput }), }); const result = await response.json(); if (result.code === "200" && result.data.verified) { // 验证成功 setPasswordVerified(true); setIsPasswordProtected(false); setPasswordInput(''); setPasswordError(''); // 保存验证状态到 sessionStorage const verifiedKey = `meeting_${meeting_id}_verified`; sessionStorage.setItem(verifiedKey, 'true'); // 重新获取会议数据 fetchMeetingPreviewData(); } else { setPasswordError('密码错误,请重试'); } } catch (err) { console.error('验证密码失败:', err); setPasswordError('验证失败,请重试'); } finally { setVerifyingPassword(false); } }; const handleKeyPress = (e) => { if (e.key === 'Enter') { handlePasswordVerify(); } }; // 复制总结文本 const handleCopySummary = async () => { try { // 移除Markdown格式,只保留纯文本 const plainText = meetingData.summary .replace(/#+\s/g, '') // 移除标题符号 .replace(/\*\*/g, '') // 移除粗体 .replace(/\*/g, '') // 移除斜体 .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // 移除链接保留文字 .replace(/`/g, ''); // 移除代码标记 // 检查 Clipboard API 是否可用 if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(plainText); setCopied(true); setTimeout(() => setCopied(false), 2000); } else { // 降级方案:使用旧方法 const textArea = document.createElement('textarea'); textArea.value = plainText; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; document.body.appendChild(textArea); textArea.select(); try { document.execCommand('copy'); setCopied(true); setTimeout(() => setCopied(false), 2000); } finally { document.body.removeChild(textArea); } } } catch (err) { console.error('复制失败:', err); } }; // 分享功能 const handleShare = async () => { const shareUrl = window.location.href; const shareTitle = `${meetingData.title} - 会议总结`; const shareText = `查看会议总结:${meetingData.title}`; try { // 优先使用 Web Share API(移动端) if (navigator.share && typeof navigator.share === 'function') { await navigator.share({ title: shareTitle, text: shareText, url: shareUrl }); setShared(true); setTimeout(() => setShared(false), 2000); return; } } catch (err) { // 用户取消分享,不算错误 if (err.name === 'AbortError') { return; } console.warn('Web Share API 失败,使用降级方案:', err); } // 降级方案:复制链接 try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(shareUrl); setShared(true); setTimeout(() => setShared(false), 2000); } else { // 再次降级:使用 execCommand const textArea = document.createElement('textarea'); textArea.value = shareUrl; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; document.body.appendChild(textArea); textArea.select(); try { const successful = document.execCommand('copy'); if (successful) { setShared(true); setTimeout(() => setShared(false), 2000); } } finally { document.body.removeChild(textArea); } } } catch (err) { console.error('复制链接失败:', err); alert('分享链接:' + shareUrl); } }; const formatDateTime = (dateTime) => { if (!dateTime) return ''; const date = new Date(dateTime); return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); }; // 音频播放器控制函数 const togglePlayPause = () => { if (audioRef.current) { if (isPlaying) { audioRef.current.pause(); } else { audioRef.current.play(); } setIsPlaying(!isPlaying); } }; const handleTimeUpdate = () => { if (audioRef.current) { setCurrentTime(audioRef.current.currentTime); } }; const handleLoadedMetadata = () => { if (audioRef.current) { setDuration(audioRef.current.duration); } }; const seekTo = (time) => { if (audioRef.current) { audioRef.current.currentTime = time; setCurrentTime(time); // 滚动到对应的转录段落 scrollToTranscriptSegment(time); } }; // 滚动到对应时间的转录段落 const scrollToTranscriptSegment = (time) => { if (transcript.length === 0) return; // 找到当前时间对应的转录段落索引 const segmentIndex = transcript.findIndex( seg => time >= seg.start_time && time < seg.end_time ); if (segmentIndex !== -1) { // 找到对应的DOM元素并滚动到可见区域 const segmentElements = document.querySelectorAll('.transcript-segment'); if (segmentElements[segmentIndex]) { segmentElements[segmentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); } } }; // 处理进度条点击和触摸 const handleProgressInteraction = (clientX, element) => { const rect = element.getBoundingClientRect(); const x = clientX - rect.left; const percentage = Math.max(0, Math.min(1, x / rect.width)); seekTo(percentage * duration); }; const handleProgressClick = (e) => { handleProgressInteraction(e.clientX, e.currentTarget); }; const handleProgressTouch = (e) => { if (e.touches.length > 0) { handleProgressInteraction(e.touches[0].clientX, e.currentTarget); } }; // 滑块拖动处理 const handleThumbMouseDown = (e) => { e.preventDefault(); e.stopPropagation(); const handleMouseMove = (moveEvent) => { const track = e.currentTarget.closest('.preview-slider-track'); if (track) { handleProgressInteraction(moveEvent.clientX, track); } }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; const handleThumbTouchStart = (e) => { e.stopPropagation(); const handleTouchMove = (moveEvent) => { if (moveEvent.touches.length > 0) { const track = e.currentTarget.closest('.preview-slider-track'); if (track) { handleProgressInteraction(moveEvent.touches[0].clientX, track); } } }; const handleTouchEnd = () => { document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); }; document.addEventListener('touchmove', handleTouchMove); document.addEventListener('touchend', handleTouchEnd); }; const formatTime = (seconds) => { if (!seconds || isNaN(seconds)) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; if (loading) { return (

正在加载会议总结...

); } // 如果需要密码验证,显示密码输入界面 if (isPasswordProtected && !passwordVerified) { return (

此会议需要访问密码

请输入密码以查看会议总结

setPasswordInput(e.target.value)} onKeyPress={handleKeyPress} placeholder="请输入4位访问密码" className={`password-input ${passwordError ? 'error' : ''}`} disabled={verifyingPassword} maxLength={4} autoFocus />
{passwordError && (
{passwordError}
)}
); } if (error) { return (
⚠️

加载失败

{error}

); } if (!meetingData) { return (

未找到会议数据

); } const attendeesList = meetingData.attendees .map(attendee => attendee.caption) .join('、'); // 计算参会人数 let attendeesCount = meetingData.attendees_count || 0; let isCalculatedFromTranscript = false; // 如果参会人数为0,从转录中计算唯一说话人数 if (attendeesCount === 0 && transcript.length > 0) { const uniqueSpeakers = new Set( transcript .map(seg => seg.speaker_label) .filter(label => label && label !== '未知') ); attendeesCount = uniqueSpeakers.size; isCalculatedFromTranscript = true; } return (

{meetingData.title || '会议总结'}

📋 {meetingData.prompt_name || '会议'} 概览

时间: {formatDateTime(meetingData.meeting_time)}
创建人: {meetingData.creator_username}
{isCalculatedFromTranscript ? '计算人数:' : '人数:'} {attendeesCount}人

📝 {meetingData.prompt_name || '会议'} 总结

{/* 操作按钮行 */}
{audioUrl || transcript.length > 0 ? (
{/* 音频播放器 */} {audioUrl && (
)} {/* 转录列表 */} {transcript.length > 0 ? (
{transcript.map((segment, index) => { const isActive = currentTime >= segment.start_time && currentTime < segment.end_time; return (
seekTo(segment.start_time)} >
{segment.speaker_label || '未知'} {formatTime(segment.start_time)} - {formatTime(segment.end_time)}
{segment.text}
); })}
) : (
暂无转录数据
)}
) : (
暂无转录和音频数据
)}
导出时间:{new Date().toLocaleString('zh-CN')}
); }; export default MeetingPreview;