imetting_frontend/src/pages/MeetingDetails.jsx

1105 lines
41 KiB
React
Raw Normal View History

2025-08-05 01:44:28 +00:00
import React, { useState, useEffect, useRef } from 'react';
2025-08-05 02:58:13 +00:00
import { useParams, Link, useNavigate } from 'react-router-dom';
2025-08-05 01:44:28 +00:00
import axios from 'axios';
2025-08-26 13:59:15 +00:00
import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download } from 'lucide-react';
2025-08-05 01:44:28 +00:00
import ReactMarkdown from 'react-markdown';
2025-08-05 02:58:13 +00:00
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
2025-08-05 01:44:28 +00:00
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
import './MeetingDetails.css';
2025-08-05 02:58:13 +00:00
const MeetingDetails = ({ user }) => {
2025-08-05 01:44:28 +00:00
const { meeting_id } = useParams();
2025-08-05 02:58:13 +00:00
const navigate = useNavigate();
2025-08-05 01:44:28 +00:00
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);
2025-08-05 02:58:13 +00:00
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
2025-08-25 08:11:29 +00:00
const [showSpeakerEdit, setShowSpeakerEdit] = useState(false);
const [editingSpeakers, setEditingSpeakers] = useState({});
const [speakerList, setSpeakerList] = useState([]);
2025-08-26 13:59:15 +00:00
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);
2025-08-05 01:44:28 +00:00
const audioRef = useRef(null);
2025-08-26 13:59:15 +00:00
const transcriptRefs = useRef([]);
2025-08-05 01:44:28 +00:00
useEffect(() => {
fetchMeetingDetails();
}, [meeting_id]);
const fetchMeetingDetails = async () => {
try {
setLoading(true);
// Fallback URL construction in case config fails
const baseUrl = ""
2025-08-05 01:44:28 +00:00
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}`);
2025-08-05 01:44:28 +00:00
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);
2025-08-25 08:11:29 +00:00
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);
2025-08-05 01:44:28 +00:00
} catch (transcriptError) {
console.warn('No transcript data available:', transcriptError);
setTranscript([]);
2025-08-25 08:11:29 +00:00
setSpeakerList([]);
2025-08-05 01:44:28 +00:00
}
} 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) {
2025-08-26 13:59:15 +00:00
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);
2025-08-05 01:44:28 +00:00
}
};
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);
2025-08-05 01:44:28 +00:00
}
};
2025-08-05 02:58:13 +00:00
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('删除会议失败,请重试');
}
};
2025-08-25 08:11:29 +00:00
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);
};
2025-08-26 13:59:15 +00:00
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 = `
<div>
<h1 style="color: #2563eb; margin-bottom: 30px; font-size: 24px; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px;">
${meeting.title || '会议总结'}
</h1>
<div style="margin-bottom: 30px; background: #f9fafb; padding: 20px; border-radius: 8px;">
<h2 style="color: #374151; font-size: 16px; margin-bottom: 15px;">会议信息</h2>
<p style="margin: 8px 0;"><strong>会议时间</strong>${meetingTime}</p>
<p style="margin: 8px 0;"><strong>创建人</strong>${meeting.creator_username}</p>
<p style="margin: 8px 0;"><strong>参会人数</strong>${meeting.attendees.length}</p>
<p style="margin: 8px 0;"><strong>参会人员</strong>${attendeesList}</p>
</div>
<div style="margin-bottom: 30px;">
<h2 style="color: #374151; font-size: 16px; margin-bottom: 15px;">会议摘要</h2>
<div style="line-height: 1.8;">${renderedHTML}</div>
</div>
<div style="margin-top: 50px; padding-top: 20px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #6b7280;">
<p>导出时间${new Date().toLocaleString('zh-CN')}</p>
</div>
</div>
`;
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"。');
}
};
2025-08-05 02:58:13 +00:00
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
2025-08-05 01:44:28 +00:00
if (loading) {
return <div className="loading-container"><div className="loading-spinner"></div><p>加载中...</p></div>;
}
if (error) {
return <div className="error-container"><p>{error}</p><Link to="/dashboard"><span className="btn-secondary">返回首页</span></Link></div>;
}
if (!meeting) {
return <div className="error-container"><p>未找到会议信息</p><Link to="/dashboard"><span className="btn-secondary">返回首页</span></Link></div>;
}
return (
<div className="meeting-details-page">
<div className="details-header">
<Link to="/dashboard">
<span className="back-link">
<ArrowLeft size={20} />
<span>返回首页</span>
</span>
</Link>
2025-08-05 02:58:13 +00:00
{isCreator && (
<div className="meeting-actions">
<Link to={`/meetings/edit/${meeting_id}`} className="action-btn edit-btn">
<Edit size={16} />
<span>编辑会议</span>
</Link>
<button
className="action-btn delete-btn"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 size={16} />
<span>删除会议</span>
</button>
</div>
)}
2025-08-05 01:44:28 +00:00
</div>
<div className="details-layout">
<div className="main-content">
<div className="details-content-card">
<header className="card-header">
<h1>{meeting.title}</h1>
<div className="meta-grid">
<div className="meta-item">
<Calendar size={18} />
<strong>会议日期:</strong>
<span>{formatDateTime(meeting.meeting_time).split(' ')[0]}</span>
</div>
<div className="meta-item">
<Clock size={18} />
<strong>会议时间:</strong>
<span>{formatDateTime(meeting.meeting_time).split(' ')[1]}</span>
</div>
<div className="meta-item">
<User size={18} />
<strong>创建人:</strong>
<span>{meeting.creator_username}</span>
</div>
<div className="meta-item">
<Users size={18} />
<strong>参会人数:</strong>
<span>{meeting.attendees.length}</span>
</div>
</div>
</header>
2025-08-05 02:58:13 +00:00
<section className="card-section">
<h2><Users size={20} /> 参会人员</h2>
<div className="attendees-list">
{meeting.attendees.map((attendee, index) => (
<div key={index} className="attendee-chip">
<User size={16} />
<span>{typeof attendee === 'string' ? attendee : attendee.caption}</span>
</div>
))}
</div>
</section>
2025-08-05 01:44:28 +00:00
{/* Audio Player Section */}
<section className="card-section audio-section">
<h2><Volume2 size={20} /> 会议录音</h2>
{audioUrl ? (
<div className="audio-player">
{audioFileName && (
<div className="audio-file-info">
<span className="audio-file-name">{audioFileName}</span>
</div>
)}
<audio
ref={audioRef}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
>
<source src={audioUrl} type="audio/mpeg" />
您的浏览器不支持音频播放
</audio>
<div className="player-controls">
<button className="play-button" onClick={handlePlayPause}>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
<div className="time-info">
<span>{formatTime(currentTime)}</span>
</div>
<div className="progress-container"
onClick={handleSeek}
onMouseDown={handleProgressMouseDown}>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }}
></div>
</div>
</div>
<div className="time-info">
<span>{formatTime(duration)}</span>
</div>
<div className="volume-control">
<Volume2 size={18} />
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
</div>
</div>
2025-08-26 13:59:15 +00:00
{/* 分割线 */}
<div className="audio-divider"></div>
{/* 动态字幕显示 */}
<div className="subtitle-display">
{currentSubtitle ? (
<div className="subtitle-content">
<div className="speaker-indicator">
{currentSpeaker}
</div>
<div className="subtitle-text">
{currentSubtitle}
</div>
</div>
) : (
<div className="subtitle-placeholder">
<div className="placeholder-text">播放音频时将在此处显示实时字幕</div>
</div>
)}
</div>
2025-08-05 01:44:28 +00:00
</div>
) : (
<div className="no-audio">
<div className="no-audio-icon">
<Volume2 size={48} />
</div>
<p className="no-audio-message">该会议没有录音文件</p>
<p className="no-audio-hint">录音功能可能未开启或录音文件丢失</p>
</div>
)}
</section>
<section className="card-section">
2025-08-26 13:59:15 +00:00
<div className="summary-header">
<h2><FileText size={20} /> 会议摘要</h2>
{meeting?.summary && (
<button
className="export-pdf-btn-main"
onClick={exportToPDF}
title="导出PDF"
2025-08-05 02:58:13 +00:00
>
2025-08-26 13:59:15 +00:00
<Download size={16} />
<span>导出PDF</span>
</button>
)}
</div>
<div className="summary-content">
{meeting?.summary ? (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{meeting.summary}
</ReactMarkdown>
</div>
) : (
<div className="no-summary">
<div className="no-summary-content">
<FileText size={48} />
<h3>暂无会议总结</h3>
<p>该会议尚未生成总结内容</p>
{isCreator && (
<button
className="generate-summary-cta"
onClick={openSummaryModal}
>
<Brain size={16} />
<span>生成AI总结</span>
</button>
)}
</div>
</div>
)}
2025-08-05 01:44:28 +00:00
</div>
</section>
</div>
</div>
{/* Transcript Sidebar */}
<div className="transcript-sidebar">
<div className="transcript-header">
<h3><MessageCircle size={20} /> 对话转录</h3>
2025-08-25 08:11:29 +00:00
<div className="transcript-controls">
{isCreator && (
2025-08-26 13:59:15 +00:00
<>
<button
className="edit-speakers-btn"
onClick={handleSpeakerEditOpen}
title="编辑发言人标签"
>
<Settings size={16} />
<span>编辑标签</span>
</button>
<button
className="ai-summary-btn"
onClick={openSummaryModal}
title="AI总结"
>
<Brain size={16} />
<span>AI总结</span>
</button>
</>
2025-08-25 08:11:29 +00:00
)}
</div>
2025-08-05 01:44:28 +00:00
</div>
2025-08-26 13:59:15 +00:00
<div className="transcript-content">
{transcript.map((item, index) => (
<div
key={item.segment_id}
2025-08-26 13:59:15 +00:00
ref={(el) => transcriptRefs.current[index] = el}
className={`transcript-item ${currentHighlightIndex === index ? 'active' : ''}`}
>
2025-08-05 01:44:28 +00:00
<div className="transcript-header-item">
2025-08-25 08:11:29 +00:00
<span
className="speaker-name clickable"
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点播放"
>
{item.speaker_tag}
</span>
2025-08-25 08:11:29 +00:00
<div className="transcript-item-actions">
<span
className="timestamp clickable"
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点播放"
>
{formatTime(item.start_time_ms / 1000)}
</span>
2025-08-26 13:59:15 +00:00
{isCreator && (
<button
className="edit-transcript-btn"
onClick={() => handleTranscriptEdit(index)}
title="编辑转录内容"
>
<Edit3 size={14} />
</button>
)}
2025-08-25 08:11:29 +00:00
</div>
</div>
<div
className="transcript-text clickable"
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点播放"
>
{item.text_content}
2025-08-05 01:44:28 +00:00
</div>
</div>
))}
</div>
</div>
</div>
2025-08-05 02:58:13 +00:00
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="delete-modal-overlay" onClick={() => setShowDeleteConfirm(false)}>
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
<h3>确认删除</h3>
<p>确定要删除会议 "{meeting.title}" 此操作无法撤销</p>
<div className="modal-actions">
<button
className="btn-cancel"
onClick={() => setShowDeleteConfirm(false)}
>
取消
</button>
<button
className="btn-delete"
onClick={handleDeleteMeeting}
>
确定删除
</button>
</div>
</div>
</div>
)}
2025-08-25 08:11:29 +00:00
{/* Speaker Tags Edit Modal */}
{showSpeakerEdit && (
<div className="speaker-edit-modal-overlay" onClick={() => setShowSpeakerEdit(false)}>
<div className="speaker-edit-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>编辑发言人标签</h3>
<button
className="close-btn"
onClick={() => setShowSpeakerEdit(false)}
>
<X size={20} />
</button>
</div>
<div className="speaker-edit-content">
<p className="modal-description">根据AI识别的发言人ID为每个发言人设置自定义标签</p>
<div className="speaker-list">
{speakerList.length > 0 ? (
speakerList.map((speaker) => {
const segmentCount = transcript.filter(item => item.speaker_id === speaker.speaker_id).length;
return (
<div key={speaker.speaker_id} className="speaker-edit-item">
<div className="speaker-info">
<label className="speaker-id">发言人 {speaker.speaker_id}</label>
<span className="segment-count">({segmentCount} 条发言)</span>
</div>
<input
type="text"
value={editingSpeakers[speaker.speaker_id] || ''}
onChange={(e) => handleEditingSpeakerChange(speaker.speaker_id, e.target.value)}
className="speaker-tag-input"
placeholder="输入发言人姓名或标签"
/>
</div>
);
})
) : (
<div className="no-speakers-message">
<p>暂无发言人数据请检查转录内容是否正确加载</p>
</div>
)}
</div>
</div>
<div className="modal-actions">
<button
className="btn-cancel"
onClick={() => setShowSpeakerEdit(false)}
>
取消
</button>
<button
className="btn-save"
onClick={handleBatchSpeakerUpdate}
>
<Save size={16} />
保存修改
</button>
</div>
</div>
</div>
)}
2025-08-26 13:59:15 +00:00
{/* Transcript Edit Modal */}
{showTranscriptEdit && (
<div className="transcript-edit-modal-overlay" onClick={() => setShowTranscriptEdit(false)}>
<div className="transcript-edit-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>编辑转录内容</h3>
<button
className="close-btn"
onClick={() => setShowTranscriptEdit(false)}
>
<X size={20} />
</button>
</div>
<div className="transcript-edit-content">
<p className="modal-description">修改选中转录条目及其上下文内容</p>
<div className="transcript-edit-list">
{getEditingItems().map((item) => (
<div key={item.originalIndex} className={`transcript-edit-item ${item.position}`}>
<div className="transcript-edit-header">
<span className="speaker-name">{item.speaker_tag}</span>
<span className="timestamp">{formatTime(item.start_time_ms / 1000)}</span>
{item.position === 'current' && (
<span className="current-indicator">当前编辑</span>
)}
</div>
<textarea
value={editingTranscripts[item.originalIndex] || item.text_content}
onChange={(e) => handleTranscriptTextChange(item.originalIndex, e.target.value)}
className="transcript-edit-textarea"
rows={3}
placeholder="输入转录内容..."
/>
</div>
))}
</div>
</div>
<div className="modal-actions">
<button
className="btn-cancel"
onClick={() => setShowTranscriptEdit(false)}
>
取消
</button>
<button
className="btn-save"
onClick={handleSaveTranscriptEdits}
>
<Save size={16} />
保存修改
</button>
</div>
</div>
</div>
)}
{/* AI Summary Modal */}
{showSummaryModal && (
<div className="summary-modal-overlay" onClick={() => setShowSummaryModal(false)}>
<div className="summary-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3><Brain size={20} /> AI会议总结</h3>
<button
className="close-btn"
onClick={() => setShowSummaryModal(false)}
>
<X size={20} />
</button>
</div>
<div className="summary-modal-content">
<div className="summary-input-section">
<h4>生成新的总结</h4>
<p className="input-description">
系统将使用通用提示词分析会议转录您可以添加额外要求
</p>
<textarea
value={userPrompt}
onChange={(e) => setUserPrompt(e.target.value)}
className="user-prompt-input"
placeholder="请输入您希望AI重点关注的内容请重点分析决策事项和待办任务..."
rows={3}
/>
<button
className="generate-summary-btn"
onClick={generateSummary}
disabled={summaryLoading}
>
{summaryLoading ? (
<>
<div className="loading-spinner small"></div>
<span>正在生成总结...</span>
</>
) : (
<>
<Sparkles size={16} />
<span>生成AI总结</span>
</>
)}
</button>
</div>
{summaryResult && (
<div className="summary-result-section">
<div className="summary-result-header">
<h4>最新生成的总结</h4>
<button
className="export-pdf-btn"
onClick={exportToPDF}
title="导出PDF"
>
<Download size={14} />
<span>导出PDF</span>
</button>
</div>
<div className="summary-result-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{summaryResult.content}
</ReactMarkdown>
</div>
</div>
)}
{summaryHistory.length > 0 && (
<div className="summary-history-section">
<h4>历史总结记录</h4>
<div className="summary-history-list">
{summaryHistory.map((summary, index) => (
<div key={summary.id} className="summary-history-item">
<div className="summary-history-header">
<span className="summary-date">
{new Date(summary.created_at).toLocaleString('zh-CN')}
</span>
{summary.user_prompt && (
<span className="user-prompt-tag">自定义要求</span>
)}
</div>
{summary.user_prompt && (
<div className="user-prompt-display">
<strong>用户要求</strong>{summary.user_prompt}
</div>
)}
<div className="summary-content-preview">
{summary.content.substring(0, 200)}...
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
2025-08-05 01:44:28 +00:00
</div>
);
};
export default MeetingDetails;