imetting_frontend/src/pages/MeetingDetails.jsx

1105 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 = `
<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"。');
}
};
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
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>
{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>
)}
</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>
<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>
{/* 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>
{/* 分割线 */}
<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>
</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">
<div className="summary-header">
<h2><FileText size={20} /> 会议摘要</h2>
{meeting?.summary && (
<button
className="export-pdf-btn-main"
onClick={exportToPDF}
title="导出PDF"
>
<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>
)}
</div>
</section>
</div>
</div>
{/* Transcript Sidebar */}
<div className="transcript-sidebar">
<div className="transcript-header">
<h3><MessageCircle size={20} /> 对话转录</h3>
<div className="transcript-controls">
{isCreator && (
<>
<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>
</>
)}
</div>
</div>
<div className="transcript-content">
{transcript.map((item, index) => (
<div
key={item.segment_id}
ref={(el) => transcriptRefs.current[index] = el}
className={`transcript-item ${currentHighlightIndex === index ? 'active' : ''}`}
>
<div className="transcript-header-item">
<span
className="speaker-name clickable"
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点播放"
>
{item.speaker_tag}
</span>
<div className="transcript-item-actions">
<span
className="timestamp clickable"
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点播放"
>
{formatTime(item.start_time_ms / 1000)}
</span>
{isCreator && (
<button
className="edit-transcript-btn"
onClick={() => handleTranscriptEdit(index)}
title="编辑转录内容"
>
<Edit3 size={14} />
</button>
)}
</div>
</div>
<div
className="transcript-text clickable"
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点播放"
>
{item.text_content}
</div>
</div>
))}
</div>
</div>
</div>
{/* 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>
)}
{/* 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>
)}
{/* 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>
)}
</div>
);
};
export default MeetingDetails;