imetting/frontend/src/pages/MeetingPreview.jsx

701 lines
23 KiB
React
Raw Normal View History

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 (
<div className="preview-container">
<div className="preview-loading">
<div className="loading-spinner"></div>
<p>正在加载会议总结...</p>
</div>
</div>
);
}
// 如果需要密码验证,显示密码输入界面
if (isPasswordProtected && !passwordVerified) {
return (
<div className="preview-container">
<div className="password-protection-modal">
<div className="password-modal-content">
<div className="password-icon-large">
<Lock size={48} />
</div>
<h2>此会议需要访问密码</h2>
<p>请输入密码以查看会议总结</p>
<div className="password-input-group">
<input
type={showPassword ? "text" : "password"}
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="请输入4位访问密码"
className={`password-input ${passwordError ? 'error' : ''}`}
disabled={verifyingPassword}
maxLength={4}
autoFocus
/>
<button
className="password-toggle-btn"
onClick={() => setShowPassword(!showPassword)}
type="button"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
{passwordError && (
<div className="password-error-message">
<AlertCircle size={16} />
<span>{passwordError}</span>
</div>
)}
<button
className="password-verify-btn"
onClick={handlePasswordVerify}
disabled={verifyingPassword || !passwordInput.trim()}
>
{verifyingPassword ? '验证中...' : '验证密码'}
</button>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="preview-container">
<div className="preview-error">
<div className="error-icon"></div>
<h2>加载失败</h2>
<p>{error}</p>
<button
className="error-retry-btn"
onClick={fetchMeetingPreviewData}
>
重新加载
</button>
</div>
</div>
);
}
if (!meetingData) {
return (
<div className="preview-container">
<div className="preview-error">
<h2>未找到会议数据</h2>
</div>
</div>
);
}
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 (
<div className="preview-container">
<div className="preview-content">
<h1 className="preview-title">
{meetingData.title || '会议总结'}
</h1>
<div className="meeting-info-section">
<h2 className="section-title">📋 {meetingData.prompt_name || '会议'} 概览</h2>
<div className="info-item">
<strong>时间</strong>
{formatDateTime(meetingData.meeting_time)}
</div>
<div className="info-item">
<strong>创建人</strong>
{meetingData.creator_username}
</div>
<div className="info-item">
<strong>{isCalculatedFromTranscript ? '计算人数:' : '人数:'}</strong>
{attendeesCount}
</div>
</div>
<div className="summary-section">
<h2 className="section-title">📝 {meetingData.prompt_name || '会议'} 总结</h2>
{/* 操作按钮行 */}
<div className="action-buttons">
<button
className="action-btn copy-btn"
onClick={handleCopySummary}
title={copied ? '已复制' : '复制总结'}
>
{copied ? (
<>
<Check size={18} />
<span>已复制</span>
</>
) : (
<>
<Copy size={18} />
<span>复制总结</span>
</>
)}
</button>
<button
className="action-btn share-btn"
onClick={handleShare}
title={shared ? '已复制链接' : '分享链接'}
>
{shared ? (
<>
<Check size={18} />
<span>已复制链接</span>
</>
) : (
<>
<Share2 size={18} />
<span>分享链接</span>
</>
)}
</button>
</div>
<Tabs defaultActiveKey="summary" className="preview-tabs">
<TabPane tab="摘要" key="summary">
<MarkdownRenderer
content={meetingData.summary}
className="summary-content"
/>
</TabPane>
<TabPane tab="脑图" key="mindmap">
<div className="mindmap-wrapper">
<MindMap
content={meetingData.summary}
title={meetingData.title}
initialScale={1.8}
/>
</div>
</TabPane>
<TabPane tab="转录" key="transcript">
{audioUrl || transcript.length > 0 ? (
<div className="transcript-wrapper">
{/* 音频播放器 */}
{audioUrl && (
<div className="preview-audio-player">
<audio
ref={audioRef}
src={audioUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
onError={(e) => {
console.error('音频加载错误:', e);
console.error('错误详情:', audioRef.current?.error);
if (audioRef.current?.error) {
const error = audioRef.current.error;
let errorMsg = '音频加载失败';
switch (error.code) {
case 1: errorMsg = '音频加载被中止'; break;
case 2: errorMsg = '网络错误'; break;
case 3: errorMsg = '音频解码失败'; break;
case 4: errorMsg = '音频格式不支持'; break;
}
console.error(`${errorMsg} (错误代码: ${error.code})`);
}
}}
preload="metadata"
/>
<div className="preview-player-controls">
<button
className="preview-play-btn"
onClick={togglePlayPause}
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
<div className="preview-progress-wrapper">
<div className="preview-time-slider">
<div
className="preview-slider-track"
onClick={handleProgressClick}
onTouchStart={handleProgressTouch}
onTouchMove={handleProgressTouch}
>
<div
className="preview-slider-fill"
style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }}
>
<div
className="preview-slider-thumb"
onMouseDown={handleThumbMouseDown}
onTouchStart={handleThumbTouchStart}
>
<span className="preview-current-time">
{formatTime(currentTime)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* 转录列表 */}
{transcript.length > 0 ? (
<div className="transcript-list">
{transcript.map((segment, index) => {
const isActive = currentTime >= segment.start_time && currentTime < segment.end_time;
return (
<div
key={index}
className={`transcript-segment ${isActive ? 'active' : ''}`}
onClick={() => seekTo(segment.start_time)}
>
<div className="segment-header">
<span className="speaker-name">{segment.speaker_label || '未知'}</span>
<span className="segment-time">
{formatTime(segment.start_time)} - {formatTime(segment.end_time)}
</span>
</div>
<div className="segment-text">{segment.text}</div>
</div>
);
})}
</div>
) : (
<div className="empty-transcript">暂无转录数据</div>
)}
</div>
) : (
<div className="empty-transcript">暂无转录和音频数据</div>
)}
</TabPane>
</Tabs>
</div>
<div className="preview-footer">
导出时间{new Date().toLocaleString('zh-CN')}
</div>
</div>
</div>
);
};
export default MeetingPreview;