701 lines
23 KiB
React
701 lines
23 KiB
React
|
|
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;
|